今天說一說.NET 中的插件技術,即 應用程序熱升級。在很多情況下、我們希望用戶對應用程序的升級是無感知的,並且盡可能不打斷用戶操作的。
雖然在Web 或者 WebAPI上,由於多點的存在可以逐個停用單點進行系統升級,而不影響整個服務。但是 客戶端卻不能這樣做,畢竟用戶一直在使用着。
那么有沒有一種方式,可以在用戶無感知的情況下(即、不停止進程的情況下)對客戶端進行升級呢?
答案是肯定的, 這就是我今天想說的插件技術、可以對應用程序進行熱升級。當然這種方式也同樣適用於 ASP.NET ,
不過當前隨筆是以 WPF為例子的,並且原理是一樣的、代碼邏輯也是一樣的。
一、應用程序域AppDomain
在介紹插件技術之前、我們需要先了解一些基礎性的知識,第一個就是應用程序域AppDomain.
操作系統和運行時環境通常會在應用程序間提供某種形式的隔離。 例如,Windows 使用進程來隔離應用程序。 為確保在一個應用程序中運行的代碼不會對其他不相關的應用程序產生不良影響,這種隔離是必需的。這種隔離可以為應用程序域提供安全性、可靠性, 並且為卸載程序集提供了可能。
在 .NET中應用程序域AppDomain是CLR的運行單元,它可以加載應用程序集Assembly、創建對象以及執行程序。
在 CLR 里、AppDomain就是用來實現代碼隔離的,每一個AppDomain可以單獨創建、運行、卸載。
如果默認AppDomain監聽了 UnhandledException 事件,任何線程的任何未處理異常都會引發該事件,無論線程是從哪個AppDomain中開始的。
如果一個線程開始於一個已經監聽了 UnhandledException事件的 app domain, 那么該事件將在這個app domain 中引發。
如果這個app domian 不是默認的app domain, 並且 默認 app domain 中也監聽了 UnhandledException 事件, 那么 該事件將會在兩個app domain 中引發。
CLR啟用時,會創建一個默認的AppDomain,程序的入口點(Main方法)就是在這個默認的AppDomain中執行。
AppDomain是可以在運行時進行動態的創建和卸載的,正因如此,才為插件技術提供了基礎(注:應用程序集和類型是不能卸載的,只能卸載整個AppDomain)。
AppDomain和其他概念之間的關系
1、AppDomain vs 進程Process
AppDomain被創建在Process中,一個Process內可以有多個AppDomain。一個AppDomain只能屬於一個Process。
2、AppDomain vs 線程Thread
應該說兩者之間沒有關系,AppDomain出現的目的是隔離,隔離對象,而 Thread 是 Process中的一個實體、是程序執行流中的最小單元,保存有當前指令指針 和 寄存器集合,為線程(上下文)切換提供可能。如果說有關系的話,可以牽強的認為一個Thread可以使用多個AppDomain中的對象,一個AppDomain中可以使用多個Thread.
3、AppDomain vs 應用程序集Assembly
Assembly是.Net程序的基本部署單元,它可以為CLR提供元數據等。
Assembly不能單獨執行,它必須被加載到AppDomain中,然后由AppDomain創建程序集中的類型 及 對象。
一個Assembly可以被多個AppDomain加載,一個AppDomain可以加載多個Assembly。
每個AppDomain引用到某個類型的時候需要把相應的assembly在各自的AppDomain中初始化。因此,每個AppDomain會單獨保持一個類的靜態變量。
4、AppDomain vs 對象object
任何對象只能屬於一個AppDomain,AppDomain用來隔離對象。 同一應用程序域中的對象直接通信、不同應用程序域中的對象的通信方式有兩種:一種是跨應用程序域邊界傳輸對象副本(通過序列化對對象進行隱式值封送完成),一種是使用代理交換消息。
二、創建 和 卸載AppDomain
前文已經說明了,我們可以在運行時動態的創建和卸載AppDomain, 有這樣的理論基礎在、我們就可以熱升級應用程序了 。
那就讓我們來看一下如何創建和卸載AppDomain吧
創建:
AppDomainSetup objSetup = new AppDomainSetup(); objSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; this.domain = AppDomain.CreateDomain("RemoteAppDomain", null, objSetup);
創建AppDomain的邏輯非常簡單:使用 AppDomain.CreateDomain 靜態方法、傳遞了一個任意字符串 和 AppDomainSetup 對象。
卸載:
AppDomain.Unload(this.domain);
卸載就更簡單了一行代碼搞定:AppDomain.Unload 靜態方法,參數就一個 之前創建的AppDomain對象。
三、在新AppDomain中創建對象
上文已經說了創建AppDomain了,但是創建的新AppDomain卻是不包含任何對象的,只是一個空殼子。那么如何在新的AppDomain中創建對象呢?
this.remoteIPlugin = this.domain.CreateInstance("PluginDemo.NewDomain", "PluginDemo.NewDomain.Plugin").Unwrap() as IPlugin;
使用剛創建的AppDomain對象的實例化方法: this.domain.CreateInstance,傳遞了兩個字符串,分別為 assemblyName 和 typeName.
並且該方法的重載方法 和 相似功能的重載方法多達十幾個。
四、影像復制程序集
創建、卸載AppDomain都有、創建新對象也可以了,但是如果想完成熱升級,還有一點小麻煩,那就是一個程序集被加載后會被鎖定,這時候是無法對其進行修改的。
所以就需要打開 影像復制程序集 功能,這樣在卸載AppDomain后,把需要升級的應用程序集進行升級替換,然后再創建新的AppDomain即可了。
打開 影像復制程序集 功能,需要在創建新的AppDomain時做兩步簡單的設定即可:
AppDomainSetup objSetup = new AppDomainSetup(); objSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory;
// 打開 影像復制程序集 功能 objSetup.ShadowCopyFiles = "true"; // 雖然此方法已經被標記為過時方法, msdn備注也提倡不使用該方法, // 但是 以.net 4.0 + win10環境測試,還必須調用該方法 否則,即便卸載了應用程序域 dll 還是未被解除鎖定 AppDomain.CurrentDomain.SetShadowCopyFiles(); this.domain = AppDomain.CreateDomain("RemoteAppDomain", null, objSetup);
五、簡單的Demo
現有一接口IPlugin:

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Windows.Input; namespace PluginDemo { public interface IPlugin { int GetInt(); string GetString(); object GetNonMarshalByRefObject(); Action GetAction(); List<string> GetList(); } }
在另外的一個程序集中有其一個實現類 Plugin:

using System; using System.Collections.Generic; using System.Linq; using System.Text; using PluginDemo; namespace PluginDemo.NewDomain { /// <summary> /// 支持跨應用程序域訪問 /// </summary> public class Plugin : MarshalByRefObject, IPlugin { // AppDomain被卸載后,靜態成員的內存會被釋放掉 private static int length; /// <summary> /// int 作為基礎數據類型, 是持續序列化的. /// <para>在與其他AppDomain通訊時,傳遞的是對象副本(通過序列化進行的值封送)</para> /// </summary> /// <returns></returns> public int GetInt() { length += new Random().Next(10000); return length; } /// <summary> /// string 作為特殊的class, 也是持續序列化的. /// <para>在與其他AppDomain通訊時,傳遞的是對象副本(通過序列化進行的值封送)</para> /// </summary> /// <returns></returns> public string GetString() { return "iqingyu"; } /// <summary> /// 未繼承 MarshalByRefObject 並且 不支持序列化 的 class, 是不可以跨AppDomain通信的,也就是說其他AppDomain是獲取不到其對象的 /// </summary> /// <returns></returns> public object GetNonMarshalByRefObject() { return new NonMarshalByRefObject(); } private NonMarshalByRefObjectAction obj = new NonMarshalByRefObjectAction(); /// <summary> /// 委托,和 委托所指向的類型相關 /// <para>也就是說,如果其指向的類型支持跨AppDomain通信,那個其他AppDomain就可以獲取都該委托, 反之,則不能獲取到</para> /// </summary> /// <returns></returns> public Action GetAction() { obj.Add(); obj.Add(); //obj.Add(); return obj.TestAction; } private List<string> list = new List<string>() { "A", "B" }; /// <summary> /// List<T> 也是持續序列化的, 當然前提是T也必須支持跨AppDomain通信 /// <para>在與其他AppDomain通訊時,傳遞的是對象副本(通過序列化進行的值封送)</para> /// </summary> /// <returns></returns> public List<string> GetList() { return this.list; // return new List<Action>() { this.GetAction() }; } } }
在另外的一個程序集中還有一個

using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace PluginDemo.NewDomain { /// <summary> /// 未繼承 MarshalByRefObject, 不可以跨AppDomain交換消息 /// </summary> public class NonMarshalByRefObject { } }
測試程序如下:

using System; using System.Windows; using System.Diagnostics; using System.Runtime.Serialization.Formatters.Binary; namespace PluginDemo { /// <summary> /// MainWindow.xaml 的交互邏輯 /// </summary> public partial class MainWindow : Window { private AppDomain domain; private IPlugin remoteIPlugin; public MainWindow() { InitializeComponent(); } private void loadBtn_Click(object sender, RoutedEventArgs e) { try { unLoadBtn_Click(sender, e); this.txtBlock.Text = string.Empty; // 在新的AppDomain中加載 RemoteCamera 類型 AppDomainSetup objSetup = new AppDomainSetup(); objSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; objSetup.ShadowCopyFiles = "true"; // 雖然此方法已經被標記為過時方法, msdn備注也提倡不使用該方法, // 但是 以.net 4.0 + win10環境測試,還必須調用該方法 否則,即便卸載了應用程序域 dll 還是未被解除鎖定 AppDomain.CurrentDomain.SetShadowCopyFiles(); this.domain = AppDomain.CreateDomain("RemoteAppDomain", null, objSetup); this.remoteIPlugin = this.domain.CreateInstance("PluginDemo.NewDomain", "PluginDemo.NewDomain.Plugin").Unwrap() as IPlugin; this.txtBlock.AppendText("創建AppDomain成功\r\n\r\n"); } catch (Exception ex) { this.txtBlock.AppendText(ex.Message); this.txtBlock.AppendText("\r\n\r\n"); } } private void unLoadBtn_Click(object sender, RoutedEventArgs e) { if (this.remoteIPlugin != null) { this.remoteIPlugin = null; } if (this.domain != null) { AppDomain.Unload(this.domain); this.domain = null; this.txtBlock.AppendText("卸載AppDomain成功\r\n\r\n"); } } private void invokeBtn_Click(object sender, RoutedEventArgs e) { if (this.remoteIPlugin == null) return; this.txtBlock.AppendText($"GetInt():{ this.remoteIPlugin.GetInt().ToString()}\r\n"); this.txtBlock.AppendText($"GetString():{ this.remoteIPlugin.GetString().ToString()}\r\n"); try { this.remoteIPlugin.GetNonMarshalByRefObject(); } catch (Exception ex) { this.txtBlock.AppendText($"GetNonMarshalByRefObject():{ ex.Message}\r\n"); if (Debugger.IsAttached) { Debugger.Break(); } } } } }
按測試程序代碼執行,先Load AppDomain, 然后 Access Other Member, 此時會發現出現了異常,大致內容如下:
創建AppDomain成功
GetInt():1020
GetString():iqingyu
GetNonMarshalByRefObject():程序集“PluginDemo.NewDomain, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null”中的類型“PluginDemo.NewDomain.NonMarshalByRefObject”未標記為可序列化。
是由於 PluginDemo.NewDomain.NonMarshalByRefObject 這個類型未標記可序列化 而引發的。 那么這種情況下和序列化又有什么關系呢?
請繼續往下看。
六、AppDomain間的對象通信
前文說過了,AppDomain 是用來隔離對象的,AppDomain 之間的對象是不可以隨意通信的,這一點在 MSND的備注 中有一段描述:
應用程序域是一個操作系統進程中一個或多個應用程序所駐留的分區。 同一應用程序域中的對象直接通信。 不同應用程序域中的對象的通信方式有兩種:一種是跨應用程序域邊界傳輸對象副本,一種是使用代理交換消息。
MarshalByRefObject 是通過使用代理交換消息來跨應用程序域邊界進行通信的對象的基類。 不是從 MarshalByRefObject 繼承的對象根據值隱式封送。 當遠程應用程序引用根據值封送的對象時,將跨應用程序域邊界傳遞該對象的副本。
MarshalByRefObject 對象在本地應用程序域的邊界內可直接訪問。 遠程應用程序域中的應用程序首次訪問 MarshalByRefObject 時,會向該遠程應用程序傳遞代理。 對該代理后面的調用將封送回駐留在本地應用程序域中的對象。
當跨應用程序域邊界使用類型時,類型必須是從 MarshalByRefObject 繼承的,而且由於對象的成員在創建它們的應用程序域之外無法使用,所以不得復制對象的狀態。
也就是說AppDomain間的對象通信有兩種方式:一種是繼承 MarshalByRefObject ,擁有使用代理交換消息的能力,另外一種是利用序列化、傳遞對象副本。
第一種:表現形式上來說,傳遞的是對象引用。 第二種 傳遞的是對象副本,也就是說不是同一個對象。
也正因此,由於 PluginDemo.NewDomain.NonMarshalByRefObject 即不是 MarshalByRefObject 的子類,也不可以進行序列化,故 不可在兩個不同的AppDomain間通信。
而上面的異常,則是由序列化 PluginDemo.NewDomain.NonMarshalByRefObject 對象失敗導致的異常。
如果一個類型 【不是】 MarshalByRefObject的子類 並且 【沒有標記】 SerializableAttribute,
則該類型的對象不能被其他AppDomain中的對象所訪問, 當然這種情況下的該類型對象中的成員也不可能被訪問到了
反之,則可以被其他AppDomain中的對象所訪問
如果一個類型 【是】 MarshalByRefObject的子類, 則跨AppDomain所得到的是 【對象的引用】(為了好理解說成對象引用,實質為代理)
如果一個類型 【標記】 SerializableAttribute, 則跨AppDomain所得到的是 【對象的副本】,該副本是通過序列化進行值封送的
此時傳遞到其他AppDomain 中的對象 和 當前對象已經不是同一個對象了(只傳遞了副本)
如果一個類型 【是】 MarshalByRefObject的子類 並且 【標記了】 SerializableAttribute,
則 MarshalByRefObject 的優先級更高
另外:.net 基本類型 、string 類型、 List<T> 等類型,雖然沒有標記 SerializableAttribute, 但是他們依然可以序列化。也就是說這些類型都可以在不同的AppDomain之間通信,只是傳遞的都是對象副本。
七、完整的Demo
完整的Demo筆者已上傳至Github, https://github.com/iqingyu/BlogsDemo :
兩個項目為完整的Demo