zoukankan      html  css  js  c++  java
  • 实现可用的插件系统

    Jusfr 原创,文章所用代码已给出,转载请注明来自博客园

    1. 插件机制与 AppDomain
    2. 示例与现实
    3. 目标与设计
    4. [Serializable] 与 MarshalByRefObject
    5. 思路与实现
    6. 后记

    开始之前还是得说:插件机制老生常谈,但一下子到某工厂或 MAF 管线我相信不少园友吃不消。授人以鱼不如授人以渔,个人觉得思考过程的引导和干货一样重要,不然大家直接看 MSDN 或者 API 文档好了。

    1. 插件机制与 AppDomain

    “CLR不提供缷载单独程序集的能力。如果CLR允许这样做,那么一旦线程从某个方法返回至已缷载的一个程序集中的代码,应用程序就会崩溃。健壮性和安全性是CLR最优先考虑的目标,如果允许应用程序以这样的一种方式崩溃,就和它的设计初衷背道而驰了。缷载应用程序集必须缷载包含它的整个 AppDoamin 。” ———— 出自《CLR via C#》519页。

    想要达到插件化目的,必须手动创建 AppDomain 作为插件容器和边界,在需要时卸载 AppDomain 以达到卸载插件的目的。这里不得不提及 MEF 和 MAF。MEF 使用 Import 与 Export 进行类型发现和元数据查找,还维护了组件生命周期,但与插件机制并无关联,多数情况下把它归纳到注入工具比较合适;MAF 极为强大但仍然是上述原理的运用,过于厚重关注有限。

    2. 示例与现实

    .Net 下插件限制已经在文章开始的时候进行了描述,机制就是自定义 AppDomain 的创建与缷载,实现并不复杂,贴一段 Demo:

    1     static void Main(string[] args) {
    2         var pluginDomain = AppDomain.CreateDomain("ad#1");
    3         var pluginType = typeof(Plugin); // Other ways
    4         var pluginInstance = (IPlugin)pluginDomain.CreateInstanceAndUnwrap(pluginType.Assembly.FullName, pluginType.FullName);
    5 
    6         // Do stuff with pluginInstance
    7         AppDomain.Unload(pluginDomain);
    8     }

    我们可以通过反射拿到定义在其他程序集中的 pluginType ,并在 AppDomain.Unload() 调用后删掉该程序集,它满足动态缷载的要求。

    但是这个 Demo 程序实在是有太多问题:

    1)如果 IPlugin 是空的标记接口,那么宿主无法调用实现类的业务逻辑;如果 IPlugin 是非空的业务接口,那么类库职责与应用职混淆在了一起?
    2)接口实现类和关联类型必须使用 [Serializable] 标记或者从 MarshalByRefObject 派生,由于生产环境存在相当多的数据类型及引用,可能需要把业务上的数据结构改个遍,甚至不能实现;
    3)插件的隔离性没有体现出来,不同插件可能有不同的数据库连接和独立的第三方类库引用,程序发布成为难题;

    3. 目标与设想

    前文列举的问题就是我们要解决的问题:

    1)可运行时加载/缷载,基本原理在 Demo 中得到了体现,但是实现得非常丑陋,管理 AppDomain 是核心的底层逻辑,不应该出现在启动过程中;
    2)划清类库开发与应用开发边界,我期望创建出可重复使用的插件机制而不要混入一大坨业务逻辑;
    3)保证隔离性,插件需要拥有独立配置文件、各自升级的能力;

    我们先进入下一节作些准备工作;

    4. [Serializable] 与 MarshalByRefObject

    .Net 进程总是会创建默认 AppDomain,由于插件化需要额外的 AppDomain,难免出现跨 AppDomain 边界访问对象的问题,比如宿主调用插件、为插件传递参数、获取插件的计算结果等等,我们知道有两种方法可以使用:标记 [Serializable] 以按值封送、从 MarshalByRefObject 派生以按引用封送。

    举例,我们定义某接口包含了推送消息的方法 bool Push(Message message) ,如果期望在自定义 AppDomain 中创建实现类,那么该实现类需要标记 [Serializable] 以按值封送或从 MarshalByRefObject 派生以按引用封送;额外地,按引用封送时,被依赖的 Message 对象也需要满足跨边界访问要求。

    那么按值封送时类型 Message 不用特殊处理 ?确实如此,简单解释下,为封送方式的选择作出解释。

    使用过 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter 的同学应该和 "SerializationException: Type 'xxoo' in Assembly 'ooxx' is not marked as serializable." 打过交道。按值封送是一个序列化和反序列化的过程,看起来我们在自定义 AppDomain 中进行了类型实例化并拿到引用,实际上发生了更多事情:原始实例被序列化为字节数组传回调用逻辑所在 AppDomain,然后字节数组反序列化,该类型所在和相关的程序集被视需求加载,最后得到了是对原始对象的精确拷贝及该拷贝的引用,而原始类型实例会在垃圾回收中被销毁。

    按值封送的类型实例化过程中,相关程序集已在调用方 AppDomain 完成加载即我们已经拥有 Message 类型信息,调用 Push() 方法时不会存在跨 AppDomain 边界访问对象的问题,故 Message 对象无须处理。

    按引用封送拿到的是类型实例的代理,我们通过它与原始对象打交道。基于上述描述和可缷载的插件化要求,我们应该选择按引用封送。

    接着关注下性能问题,以下是基本测试。

     1     public interface IPlugin {
     2         Int32 X { get; set; }
     3     }
     4 
     5     public class Plugin : IPlugin {
     6         public Int32 X { get; set; }
     7     }
     8 
     9     [Serializable]
    10     public class MarshalByRefValuePlugin : IPlugin {
    11         public Int32 X { get; set; }
    12     }
    13 
    14     public class MarshalByRefTypePlugin : MarshalByRefObject, IPlugin {
    15         public Int32 X { get; set; }
    16     }
    17 
    18     public class MarshalByRefTypePluginProxy : MarshalByRefObject {
    19         private readonly IPlugin h = new Plugin();
    20 
    21         public void Proceed() {
    22             h.X++;
    23         }
    24     }

    MarshalByRefTypePluginProxy 相对其他实现比较特殊,它是一个装饰器模式;调用测试如下,PerformanceRecorder 是我写的测试类,它内部包含一个 Stopwatch,接收整型数及一个委托列表,返回每个委托执行声明次数所需要的时间等结果;

     1     static void Main(string[] args) {
     2         var h1 = new Plugin();
     3         var h2 = new MarshalByRefValuePlugin();
     4         var h3 = new MarshalByRefTypePlugin();
     5 
     6         AppDomain ad = AppDomain.CreateDomain("ad#2");
     7         var t1 = typeof(MarshalByRefTypePlugin);
     8         var h4 = (IPlugin)ad.CreateInstanceAndUnwrap(t1.Assembly.FullName, t1.FullName);
     9         var t2 = typeof(MarshalByRefValuePlugin);
    10         var h5 = (IPlugin)ad.CreateInstanceAndUnwrap(t2.Assembly.FullName, t2.FullName);
    11 
    12         var t3 = typeof(MarshalByRefTypePluginProxy);
    13         var py = (MarshalByRefTypePluginProxy)ad.CreateInstanceAndUnwrap(t3.Assembly.FullName, t3.FullName);
    14 
    15         var records = PerformanceRecorder.Invoke(100000,
    16             () => h1.X++, () => h3.X++, () => h2.X++, () => h4.X++, () => h5.X++, py.Proceed);
    17             
    18         foreach (var r in records) {
    19             Console.WriteLine("{0} {1,4} {2}",
    20                 r.RunningTime, r.CollectionCount, r.TotalMemory);
    21         }
    22     }

    可以看到结果:标记 [Serializable] 的 MarshalByRefValuePlugin,由于实例调用并不会发生跨 AppDomain 边界的对象访问,无论是直接创建还是使用自定义 AppDomain 创建都没有显著的性能差异;而继承自 MarshalByRefObject 的 MarshalByRefTypePlugin,在默认 AppDomain 中调用时性能十分接近,一旦在自定义 AppDomain 中创建、在默认 AppDomain 中访问时,性能直跌谷底。

    00:00:00.0016055    3 63004
    00:00:00.0020829    6 67988
    00:00:00.0019477    9 67988
    00:00:01.7473949  146 71648
    00:00:00.0020485  149 71648
    00:00:00.0770707  152 71648
    Press any key to continue . . .

    
    

    采取装饰器模式的 MarshalByRefTypePluginProxy 很有意思,它依赖 IPlugin 实例工作,因为 IPlugin 调用发生在自定义 AppDomain 内部,这里没有跨 AppDomain 边界的对象访问! 虽然相比直接调用存在不小性能差距,但相比直接引用 IPlugin 在自定义 AppDomain 中的实例还是高效太多,有所启示吗?

    X++ 就是业务逻辑,通过调用 MarshalByRefTypePluginProxy.Proceed() 间接调用业务逻辑,我们得到了性能收益,同时因为不再对 IPlugin 的实现有封送要求,我们做到了对业务逻辑没有入侵。

    5. 思路与实现

    一方面接口可以有相当多的实现,而去操作每个实例过于细粒度;另一方面实践中我们常常以项目即 Visual Studio 里的 Project 定义业务,所以我选择使用项目编译结果作为插件边界。使用文件夹分隔能很方便地保证物理隔离,同时配合 AppDomainSetup 初始化 AppDomain 能做到配置文件和第三方类库引用独立!

    另一方面前文提到的 MarshalByRefTypePluginProxy 相对直接的插件调用有一定的性能优势,我们可以将其与自定义 AppDomain 关联、充当宿主与插件的桥梁,达到调用业务逻辑、插件管理的目的。

    核心类型为 IPluginCatalog 与 IPluginCatalogProxy。前者并供应用开发人员扩展以操作业务逻辑,后者聚合前者,通过路径管理自定义 AppDomain 和 IPluginCatalog 实例;IPluginResolver 承担默认的类型发现职责。

    IPluginCatalog 与相关实现:IPluginCatalog 仅定义了插件目录,泛型 IPluginCatalog<out T> 定义了插件类型查找方法,PluginCatalog<T> 继承自 MarshalByRefObject 作为默认实现,FindPlugins() 被标记为虚方法,应用开发人员可以很方便地重写,而 InitializeLifetimeService() 方法返回 null 以避免原始对象被垃圾回收。

    IPluginCatalogProxy 与相关实现: IPluginCatalogProxy 定义了泛型的 Construct<T, P>() 方法和约束,T 被要求从 IPluginCatalog<P> 定义。PluginCatalogProxy.Construct() 方法调用前会检查内部字典以创建或获取自定义 AppDomain,接着在该 AppDomain 上创建类型为 T 的 IPluginCatalog<P> 实例;Release() 方法执行 AppDomain 的查找和卸载逻辑,用户扩展的 IPluginCatalog 实例还可以定义资源清理工作,例如停止计数器、释放数据库连接。

    注意:本例中的IPluginCatalog 实现及类型的实例均调用了的使用了 AppDomain.CreateInstanceAndUnwrap(string assemblyName, string typeName) 重载,该方法将调用目标类型的无参构造函数,其他重载更强大也很复杂,请自行查看。

    逻辑不过百来行,就不打包了。

      1 using System;
      2 using System.Collections.Generic;
      3 using System.ComponentModel.Composition.Hosting;
      4 using System.IO;
      5 using System.Reflection;
      6 using System.Linq;
      7 using System.Text;
      8 using System.Threading.Tasks;
      9 
     10 namespace ChuyeEventBus.Plugin {
     11     #region 类型发现相关
     12     public interface IPluginResolver {
     13         IEnumerable<T> FindAll<T>(String pluginFolder);
     14     }
     15 
     16     public class MefPluginResolver : IPluginResolver {
     17         public IEnumerable<T> FindAll<T>(String pluginFolder) {
     18             var catalog = new AggregateCatalog();
     19             catalog.Catalogs.Add(new DirectoryCatalog(pluginFolder));
     20             var container = new CompositionContainer(catalog);
     21             return container.GetExportedValues<T>();
     22         }
     23     }
     24     
     25     public class ReflectionPluginResolver : IPluginResolver {
     26         public IEnumerable<T> FindAll<T>(String pluginFolder) {
     27             var basePluginType = typeof(T);
     28             var pluginTypes = Directory.EnumerateFiles(pluginFolder, "*.dll", SearchOption.TopDirectoryOnly)
     29                 .Concat(Directory.EnumerateFiles(pluginFolder, "*.exe", SearchOption.TopDirectoryOnly))
     30                 .SelectMany(f => Assembly.LoadFrom(f).ExportedTypes)
     31                 .Where(t => basePluginType.IsAssignableFrom(t) && t != basePluginType 
     32                     && !t.IsInterface && !t.IsAbstract);
     33             foreach (var pluginType in pluginTypes) {
     34                 yield return (T)Activator.CreateInstance(pluginType);
     35             }
     36         }
     37     }
     38     
     39     #endregion
     40     
     41     public interface IPluginCatalog {
     42         String PluginFolder { get; set; }
     43     }
     44     
     45     public interface IPluginCatalog<out T> : IPluginCatalog {
     46         // 这里其实并不希望被跨 AppDoamin 访问,文末有补救
     47         IEnumerable<T> FindPlugins();
     48     }
     49     
     50     public class PluginCatalog<T> : MarshalByRefObject, IPluginCatalog<T> {
     51         public String PluginFolder { get; set; }
     52 
     53         // 避免原始对象被释放
     54         public override object InitializeLifetimeService() {
     55             return null;
     56         }
     57 
     58         public virtual IEnumerable<T> FindPlugins() {
     59             var resolver = new ReflectionPluginResolver();
     60             return resolver.FindAll<T>(PluginFolder);
     61         }
     62     }
     63     
     64     public interface IPluginCatalogProxy {
     65         // T 类型需要有无参构造函数
     66         T Construct<T, P>(String pluginFolder) where T : IPluginCatalog<P>, new();
     67         void Release(String pluginFolder);
     68         void ReleaseAll();
     69     }
     70     
     71     public class PluginCatalogProxy : IPluginCatalogProxy, IDisposable {
     72         private readonly Dictionary<String, AppDomain> _pluginDomains
     73             = new Dictionary<String, AppDomain>();
     74 
     75         public T Construct<T, P>(String pluginFolder) where T : IPluginCatalog<P>, new() {
     76             var pluginCatalogType = typeof(T);
     77             //todo: 如果期望区分同一目录获取不同的 IPluginCatalog<P> 实例,则需要做更多工作
     78             var pluginDomain = CreatePluginDomain(pluginFolder);
     79             var pluginCatalog = (IPluginCatalog)pluginDomain.CreateInstanceAndUnwrap(
     80                   pluginCatalogType.Assembly.FullName,
     81                   pluginCatalogType.FullName);
     82             pluginCatalog.PluginFolder = pluginFolder;
     83             return (T)pluginCatalog;
     84         }
     85 
     86         protected virtual AppDomain CreatePluginDomain(String pluginFolder) {
     87             var cfg = GetPluginConfiguration(pluginFolder);
     88             var bins = new[] { pluginFolder.Substring(AppDomain.CurrentDomain.BaseDirectory.Length) };
     89             var setup = new AppDomainSetup();
     90             if (File.Exists(cfg)) {
     91                 setup.ConfigurationFile = cfg;
     92             }
     93             setup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory;
     94             setup.PrivateBinPath = String.Join(";", bins);
     95 
     96             AppDomain pluginDoamin;
     97             if (!_pluginDomains.TryGetValue(pluginFolder, out pluginDoamin)) {
     98                 pluginDoamin = AppDomain.CreateDomain(pluginFolder, null, setup);
     99                 _pluginDomains.Add(pluginFolder, pluginDoamin);
    100             }
    101             return pluginDoamin;
    102         }
    103 
    104         // 可以定义自己的规则
    105         protected virtual String GetPluginConfiguration(String pluginFolder) {
    106             var config = Path.Combine(pluginFolder, "main.config");
    107             if (!File.Exists(config)) {
    108                 config = Path.Combine(pluginFolder, Path.GetFileName(pluginFolder) + ".dll.config");
    109             }
    110             if (!File.Exists(config)) {
    111                 var configs = Directory.GetFiles(pluginFolder, "*.dll.config", SearchOption.TopDirectoryOnly);
    112 
    113                 if (config.Length > 1) {
    114                     Debug.WriteLine(String.Format("Unknown configuration as too many .dll.config files in "{0}""
    115                         , Path.GetFileName(pluginFolder)));
    116                 }
    117                 else if (config.Length == 1) {
    118                     config = configs[0];
    119                 }
    120             }
    121             return config;
    122         }
    123 
    124         public void Release(String pluginFolder) {
    125             AppDomain pluginDoamin;
    126             if (_pluginDomains.TryGetValue(pluginFolder, out pluginDoamin)) {
    127                 AppDomain.Unload(pluginDoamin);
    128                 _pluginDomains.Remove(pluginFolder);
    129             }
    130         }
    131 
    132         public void ReleaseAll() {
    133             var unloadTasks = _pluginDomains.Select(async p =>
    134                 await Task.Run(action: () => AppDomain.Unload(p.Value))).ToArray();
    135             Task.WaitAll(unloadTasks);
    136             _pluginDomains.Clear();
    137         }
    138 
    139         public void Dispose() {
    140             ReleaseAll();
    141         }
    142     }
    View Code

    业务逻辑的入口在哪里?我们来看一个场景和实例。计数应用需要从特定队列出队,然后操作数据库。我们定义接口 IFeature 及其实现;扩展 PluginCatalog<IFeature> 添加 StartAll() 作为业务入口;

     1     public interface IFeature {
     2         void Start();
     3     }
     4 
     5     public class MyFeature : IFeature {
     6 
     7         public void Start() {
     8             Console.WriteLine("MyFeature.Start()");
     9             // Grab message from message queue, calculate & persistence 
    10         }
    11     }
    12 
    13     public class MyPluginCatalog : PluginCatalog {
    14 
    15         public void StartAll() {
    16             Console.WriteLine("MyPluginCatalog.StartAll()");
    17             foreach (var feature in FindPlugins()) {
    18                 feature.Start();
    19             }
    20         }
    21     }

    PluginCatalogProxy.Construct() 方法获取到了用户定义的 PluginCatalog<T> 子类对象,而 FindPlugins() 在 MyPluginCatalog 内部使用,使得任何 IFeature 都不需要跨 AppDomain 边界访问;这里忽略掉了不是重点的 Timer 相关代码。

    1     static void Main(string[] args) {
    2         var pluginCatalogProxy = new PluginCatalogProxy();
    3         var pluginFolder = AppDomain.CurrentDomain.BaseDirectory; // Define your own plugin folder
    4         var pluginCatalog = pluginCatalogProxy.Construct(pluginFolder);
    5 
    6         pluginCatalog.StartAll();
    7         pluginCatalogProxy.Release(pluginFolder);
    8     }

    IPluginCatalog 是前文 MarshalByRefTypePluginProxy 逻辑的体现,配合 PluginCatalogProxy.Construct() 方法,应用开发人员可以获取到自定义 IPluginCatalog 实现类的实例而不仅仅是 IPluginCatalog 接口,这为应用开发人员提供业务入口,并将业务逻辑隔离在自定义 AppDomain 中处理,规避了实现类的跨 AppDomain 边界问题; PluginCatalogProxy 管理维护着自定义 AppDomain 的生命周期,控制了其可见性。

    应用开发人员通过引用 PluginCatalogProxy 和自定义 IPluginCatalog 实例可以完成业务调用、资源清理;也可以重写相关实现定制 AppDomain;在上层应用中通过文件监视,动态的插件加载、卸载不在话下;原理并不复杂,园友完全可以自行实现,处理好 AppDomain 边界问题即可。

    在技术上,可以对 PluginCatalogProxy 使用单例模式,只是丧失了对其内部实现的修改能力; IPluginCatalog<out T>.FindPlugins() 也只是希望在子类调用而不是任何地方,可以在其实现中显式实现该接口来达到目的,大概是这样子:

     1 public class PluginCatalog : MarshalByRefObject, IPluginCatalog {
     2         //...
     3         protected virtual IEnumerable FindPlugins() {
     4             var resolver = new ReflectionPluginResolver();
     5             return resolver.FindAll(PluginFolder);
     6         }
     7 
     8         IEnumerable IPluginCatalog.FindPlugins() {
     9             return FindPlugins();
    10         }
    11     }
    View Code

    6. 后记

    泛型与逆变使用可能有些晦涩,看多两次也不是太难理解,思路最重要。

    关于插件的部署方式,我的实践如前文所提,宿主程序的根目录下创建文件夹,各业务实现再分别创建子文件夹;为了达到不停止插件宿主更新插件的目标,我们并不能直接在上述文件夹中进行类型发现和加载,而是需要使用一个拷贝目录,监视插件目录并原样复制,当发现插件目标更新时,优雅地停止相关业务逻辑、卸载对应 AppDomain、更新对应的文件拷贝、重新启动业务逻辑。

    不得说说 Asp.Net,实践中我们知道无论是修改 Web.config 还是覆盖新的 dll,下次访问时站点会再次 JIT 编译,原理和刚才描述的大致相同,站点被复制到了特定临时文件夹,w3wp 通过额外的 AppDomain 寄宿了我们的站点。从这个意义 MVC 应用的插件化重点并不在于如何管理 AppDomain,路由注册、虚拟目录和视图查找才是重点,盗图一张帮助理解。

    插件系统中的异常处理是不小的话题,自定义 AppDomain 里的异步线程下未处理异常是进程 Crash 的罪魁祸首——— w3wp 进程常常这么没了,而MAF 具有防止宿主崩溃的特性;而资源监控和分配需要更深入的实践。

    以上代码已在项目中使用,稍后整理了丢上来。

    Jusfr 原创,转载请注明来自博客园

  • 相关阅读:
    flex自适应小例子
    hasOwnProperty 递归 简单回调 链式调用
    H5音频和视频
    html特殊字符
    css巧妙实现分隔线
    SQL Server中数据的存储
    SQL高级查询
    SQL SERVER查询到的数据转为Json格式
    SQL动态生成列
    SQL合并查询数据,以逗号分隔
  • 原文地址:https://www.cnblogs.com/Jusfr/p/4501844.html
Copyright © 2011-2022 走看看