實現可用的插件系統


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 原創,轉載請注明來自博客園


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM