.Net Framework中的AppDomain.AssemblyResolve事件的常見用法、問題,以及解決辦法

一、簡述

本文簡要的介紹.NET Framework中System.AppDomain.AssemblyResolve事件的用法、使用注意事項,以及複雜場景下AssemblyResolve事件的污染問題和解決辦法。
System.AppDomain.AssemblyResolve事件可以理解為「找程序集事件「,假設程序集A依賴了程序集B、C,而B、C不在與A相同的目錄下也不在常規的常規探測路徑之內,此時相應AppDomain(一般指AppDomain.Current)的AssemblyResolve事件會被觸發,程序集A中可儘早響應此事件,實現加載程序集B和C到對應的AppDomain。可參考微軟官方文檔:Resolve assembly loads //learn.microsoft.com/en-us/dotnet/standard/assembly/resolve-loads#how-the-assemblyresolve-event-works,該文比較詳細的描述了AssemblyResolve的原理、用法和注意事項。不過該文中雖多次提及注意事項,但給出的例程中並沒有很好的體現注意事項,這是官方給馬虎的小夥子埋下的坑之一。

 

二、典型用法
場景1:讓.NET軟件的安裝目錄更整潔
將所有程序集都放到與主程序集(指.exe程序集也可能是一個.dll)相同的目錄下,在軟件規模稍大時顯得雜亂無章。即使認定用戶不會查看軟件的安裝目錄,對於愛乾淨的開發者來說也不能忍。於是按功能模塊建子文件夾,將相應的dll放入子文件夾內,通過在主程序集入口處立即響應AssemblyResolve事件已實現加載子文件夾中的程序集。PS:主程序集是自己的exe文件時也可通過應用程序配置解決,如有興趣可百度關鍵字」assemblyBinding「。

場景2:有序的組織軟件內共享程序集
有的時候我們編寫的軟件是大型軟件的插件,不巧的是,該大型軟件每年升級版本,插件適配該宿主的多個版本。插件軟件有較多的程序集,其中少數依賴於宿主的API,多數與宿主無關能不妨稱為軟件內共享程序集,此時可將軟件內共享程序集放安裝目錄,與宿主版本相關的程序集則放到安裝目錄下的子目錄。讓宿主啟動後加載對應子目錄下的”主程序集”,該程序集中儘快註冊AssemblyResolve事件以加載軟件內共享程序集。
值得咱們這樣追隨的宿主平台,通常也有別的追求者,大家都用AssemblyResolve事件,林子大了,就可能出現本文主描述的AssemblyResolve污染問題。

場景3:從位元組數組加載程序集
從位元組數組加載程序集…貌似可以做程序集加密!?不過不用期待太多特別是看完本文之後。此處用一段摘自官方文檔中的文字來描述:如果處理程序有權訪問以位元組數組形式存儲的程序集的數據庫,則它可以通過使用可採用位元組數組的一種 Assembly.Load 方法重載來加載位元組數組。

更多用法,歡迎評論討論。

 

三、AssemblyResolve污染問題

這裡解釋何為AssemblyResolve污染,以及問題起因。
AssemblyResolve污染指AppDomain.AssemblyResolve事件的一個或多個響應函數沒有按該事件的響應規範正確處理,影響所有依賴於該事件的功能模塊運行穩定性。通常問題現象是,運行到某個具體功能時提示找不到*.Resources.dll,或*.XmlSerializers .dll,或提未能從程序集xxx中加載類型yyy。

咱們僅討論無意中未按AssemblyResolve響應規範正確處理的情況。此時的不規範響應通常是:遇到不能處理的程序集時應簡單的返回null,但自.NET Framework 4.0開始,事件參數ResolveEventArgs增加了RequestingAssembly屬性,該屬性剛好是一個程序集且名字看着挺像是」正要找的程序集「,於是有些開發者在遇到不能處理的程序集時返回ResolveEventArgs.RequestingAssembly。一旦返回了一個不為null的程序集對象,AssemblyResolve事件的其它響應函數變不會再被調用(本文在」探討「一節中證實這個情況),從而引起本節描述的AssemblyResolve污染問題。

問題1:提未能從程序集xxx中加載類型yyy
如果有問題的響應函數先於咱們的程序響應了AppDomain.AssemblyResolve事件,則一直輪不到咱們程序集中的AppDomain.AssemblyResolve事件,於是出現這個問題。

問題2:提示找不到*.Resources.dll,或*.XmlSerializers .dll
這類衛星程序集或附屬程序集到上下文的加載,從.NET Framework 4.0開始也會觸發事件,要求統一簡單的返回null。摘一段官網說明:
!Important
Beginning with the .NET Framework 4, the AssemblyResolve event is raised for satellite assemblies. This change affects an event handler that was written for an earlier version of the .NET Framework, if the handler tries to resolve all assembly load requests. Event handlers that ignore assemblies they do not recognize are not affected by this change: They return null, and normal fallback mechanisms are followed.

 

四、解決辦法
一種解決辦法是喊話對應的軟件開發商讓修改。不過也許對應的開發商已經跑路,或的確做了修改,但用戶側仍然用了未修改前的舊版本,這種方式是不可靠的。

問題解決思路:
通過反射拿到承載AppDomain.AssemblyResolve事件的Delegate,逐一檢查Delegate中各ResolveEventHandler是否正常,不正常者關小黑屋後改造後再置入AppDomain.AssemblyResolve事件的Delegate。

附例程:

 1         class AssemblyResolveHook
 2         {
 3             ResolveEventHandler _handler;
 4             const string c_no_this_assembly = "NoThisAssembly, Version=1.0.0.0, Culture=zh-CN, PublicKeyToken=null";
 5             
 6             AssemblyResolveHook(ResolveEventHandler handler)
 7             {
 8                 _handler= handler;
 9             }
10             
11             Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
12             {
13                 if (_handler== null) {
14                     return null;
15                 }
16                 
17                 // 這裡是「小黑屋」改造過程
18                 // 此處是對症下藥的較溫和的無害的處理方式
19                 var asm = args.RequestingAssembly;
20                 var asm2 = _handler(sender, args);
21                 if (asm2 != null && asm != null && asm.FullName == asm2.FullName) {
22                     asm2 = null;
23                 }
24                 
25                 return asm2;
26             }
27             
28             /// <summary>
29             /// 使調用本方法之前的所有CurrentDomain_AssemblyResolve事件響應無害化
30             /// </summary>
31             public static void Handsup()
32             {
33                 try {
34                 var domain = AppDomain.CurrentDomain;
35                 var far = domain.GetFieldValue("_AssemblyResolve") as ResolveEventHandler;
36                 if (far != null) {
37                     var invocationList = far.GetInvocationList();
38                     var num = invocationList.Length;
39                     for (int i = 0; i < num; i++) {
40                         var handler = (ResolveEventHandler)invocationList[i];
41                         
42                         // 測試一下這個handler有沒有問題?
43                         // 方法是給一個不可能找到的程序集名稱,看是否返回了程序集,如果是,給關小黑屋
44                         var asm = handler(domain, new ResolveEventArgs(c_no_this_assembly, System.Reflection.Assembly.GetExecutingAssembly()));
45                         if (asm != null) {
46                             handler = new ResolveEventHandler(new AssemblyResolveHook(handler).CurrentDomain_AssemblyResolve);
47                         }
48 
49                         far = i == 0 ? handler : (ResolveEventHandler)Delegate.Combine(far, handler);
50                     }
51                     
52                     domain.SetFieldValue("_AssemblyResolve", far);
53                     }
54                 }
55                 catch (System.Exception ex) {
56                     System.Diagnostics.Debug.WriteLine(ex.ToString());
57                 }
58             }
59         }

AssemblyResolveHook

幾點解釋:
問:例程中的GetFieldValue/SetFieldValue,沒有這樣的方法?
答:這是擴展方法,能看到這裡的你,也不會在乎反射的這幾行代碼怎麼寫

問:AssemblyResolveHook.Handsup 調用之後,新添加的 AssemblyResolve 事件響應有問題怎麼辦?
答:一般AssemblyResolve事件會在第一時間響應,故可延遲調用AssemblyResolveHook.Handsup

問:怎麼知道字段名是 _AssemblyResolve?
答:反正用VS2022社區版,光標放代碼的AppDomain上按F12,就能看到答案。其它版本VS應該也行

問:被關小黑屋了,如果對方要註銷 AssemblyResolve 事件的響應咋辦?
答:太多問題了…

 

五、探討

本問題提及AssemblyResolve事件的某響應函數一旦返回了非null程序集,就不會再調用後續的響應函數,可以光標放代碼的AppDomain上按F12,找到對應的 .NET Framwork 代碼證實。
如下:

 1 [SecurityCritical]
 2 private RuntimeAssembly OnAssemblyResolveEvent(RuntimeAssembly assembly, string assemblyFullName)
 3 {
 4 ResolveEventHandler assemblyResolve = _AssemblyResolve;
 5 if (assemblyResolve == null) {
 6 return null;
 7 }
 8 
 9 Delegate[] invocationList = assemblyResolve.GetInvocationList();
10 int num = invocationList.Length;
11 for (int i = 0; i < num; i++) {
12 Assembly asm = ((ResolveEventHandler)invocationList[i])(this, new ResolveEventArgs(assemblyFullName, assembly));
13 RuntimeAssembly runtimeAssembly = GetRuntimeAssembly(asm);
14 if (runtimeAssembly != null) {
15 return runtimeAssembly;
16 }
17 }
18 
19 return null;
20 }

OnAssemblyResolveEvent

如果哪天微軟對上述代碼稍加修改,本關小黑屋方法就可以退休了。

(全文完,本文最早由yangzhj發表於博客園,轉載需註明出處)