最近在工做中牽涉到了.NET下的一個古老的問題:Assembly的加載過程。雖然網上有不少文章介紹這部份內容,不少文章也是好久之前就已經出現了,但閱讀以後發現,並沒能解決個人問題,有些點寫的不是特別詳細,讓人看完以後感受仍是雲裏霧裏。最後,我決定從新複習一下這個經典而古老的問題,並將所得總結於此,而後會有一個實例對這個問題進行演示,但願可以幫助到你們。html
.NET下Assembly的加載過程
.NET下Assembly的加載,最主要的一步就是肯定Assembly的版本。在.NET下,託管的DLL和EXE都稱之爲Assembly,Assembly由AssemblyName來惟一標識,AssemblyName也就是你們所熟悉的Assembly.FullName,它是由五部分:名稱、版本、語言、公鑰Token、處理器架構組成的,這一點相信你們都知道。有關Assembly Name的詳細描述,請參考:https://docs.microsoft.com/en-us/dotnet/framework/app-domains/assembly-names。那麼版本,就是AssemblyName中的一個重要組成部分。其它四部分相同,版本若是不一樣的話,就不能算做是同一個Assembly。設計這樣一個Assembly的版本策略,微軟自己就是爲了解決最開始的DLL Hell的問題,在維基百科上着關於這段黑歷史的詳細描述,地址是:https://en.wikipedia.org/wiki/DLL_Hell,在此也就很少囉嗦了。git
Assembly版本的重定向和最終肯定
.NET下Assembly的加載過程,其實也是Assembly版本的肯定和Assembly文件的定位過程,步驟以下:github
- 在一個Assembly被編譯的時候,它所引用的Assembly的全名(FullName)就會被編譯器強行寫入Assembly的Metadata,這個值是死的,從ILSpy能夠看到,每一個Reference都有它的全名信息:
例如上圖,System.Data依賴System.Xml,它所須要的版本是4.0.0.0,那麼當CLR加載System.Data的時候,就能夠暫且認爲接下來須要加載的System.Xml版本是4.0.0.0。這裏強調「暫且認爲」,是由於這只是肯定Assembly版本的第一步,那麼最終System.Xml究竟是不是使用4.0.0.0的版本呢?就須要看接下來這步的處理結果,也就是Assembly版本的重定向 - 首先,檢查應用程序的配置文件,看是否存在Assembly版本重定向的設定。咱們暫時先討論應用程序配置文件就在AppDomain內的狀況(若是在AppDomain以外,則須要首先下載配置文件,再繼續,這裏先不深刻討論)。應用程序配置文件常見的有.exe.config和web.config兩種。在配置文件中,能夠在runtime節點下的assemblyBinding中進行配置。例如:
在這個例子中,asm6 Assembly的版本號被重定向到2.0.0.0。那麼假設這就是asm6的最終版本號,那麼接下來當CLR開始加載asm6的時候,若是2.0.0.0的版本沒有找到,則直接拋出FileLoadException(即便3.0.0.0的版本是存在的),整個Assembly加載過程結束。FileLoadException的詳細信息相似於:Could not load file or assembly 'asm6, Version=3.0.0.0, Culture=neutral, PublicKeyToken=c0305c36380ba429' or one of its dependencies. The located assembly's manifest definition does not match the assembly reference - 若是在配置文件中找到了對應的版本重定向設定,那麼,再接着查看Publisher Policy文件。Publisher Policy文件是一個僅包含配置文件的.NET Assembly,被安裝到GAC裏。它的Assembly版本重定向配置內容跟上面的應用程序配置文件的配置內容相同,不一樣的是,它的做用域是全部使用了該Assembly的應用程序。這種作法對於開發系統級通用框架的Assembly升級很是有用,好比.NET Framework。下面就是安裝在GAC裏的Publisher Policy文件的樣本,須要注意:Publisher Policy會override應用程序配置信息中的版本重定向配置,而不是相反。換言之,假如asm6在上面這一步被肯定爲2.0.0.0,而所對應的Publisher Policy文件又將其肯定爲2.5.0.0,那麼,暫且認爲,CLR應該要加載2.5.0.0的版本。同理,「暫且認爲」這個詞表示,版本肯定的過程還未結束
- 接下來,查找machine.config文件。同理,若是machine.config文件中存在版本重定向的設定,那麼就會使用machine.config文件中的這個值,做爲CLR應該去加載的Assembly的版本
至此,Assembly的最終版本已被肯定,接下來就是搜索Assembly文件並進行加載的過程了。web
Assembly文件的搜索和加載過程
如今,CLR已經開始加載肯定版本的Assembly了,接下來就是搜索Assembly文件的過程。這個過程也叫做Assembly Probing。CLR會作如下事情:sql
- 首先,查看所需的Assembly是否已經加載過,若是已經加載了,那就直接使用那個已經加載的Assembly的版本與當前所需的版本進行比對,若是匹配,則使用那個已經加載的Assembly,若是不匹配,則拋出FileLoadException,執行結束
- 而後,看Assembly是否已被強簽名(Strongly Named),若是是,則去GAC裏查找Assembly。若是找到,則直接加載,整個Assembly加載過程結束。若是沒有找到,那麼就進行下一步,繼續搜索Assembly文件。固然,若是Assembly沒有進行強簽名,那麼就跳過這一步,直接繼續
- 接着,CLR開始搜索(Probing)可能的Assembly位置,這又要分多種狀況:
- 首先,查看文件中是否有指定<codeBase>,codeBase配置容許應用程序針對Assembly的不一樣版本指定裝載地址,遵循以下規律:
- 若是所指定的Assembly文件位於當前應用程序域的啓動目錄(或其子目錄)下,則使用相對路徑指定href的值
- 若是所指定的Assembly文件位於其它目錄,或任何其它地方,則href必須給出全路徑,而且Assembly必須強簽名的
- 而後,CLR對應用程序域的根目錄以及相關的子目錄進行探索:
- 假設Assembly的名字是abc.dll,那麼CLR會探索如下目錄:
- [appdomain_base]\abc.dll
- [appdomain_base]\abc\abc.dll
- 假設abc.dll還有語言設置(culture不是neutral),那麼CLR會探索如下目錄:
- [appdomain_base]\[culture]\abc.dll
- [appdomain_base]\[culture]\abc\abc.dll
- 若是找到符合版本的Assembly,則加載,不然進入下一步
- 最後,CLR會查看應用程序配置文件中是否有<probling>節點,若是有,則按probling節點所指定的privatePath值進行逐一探索。這個過程也會考慮culture的因素,相似於上面這步這樣,對相應的子目錄進行搜索。若是找到對應的Assembly,則加載,不然拋出FileLoadException,整個加載過程結束。注意,這裏「逐一探索」的過程,不是遍歷並找最佳匹配的過程。CLR僅根據Assembly的名字(不帶版本號的名字)在privatePath下查找Assembly的文件,找到第一個名字匹配可是版本不匹配的話,就拋異常並終止加載了,它不會繼續搜索privatePath中餘下的其它路徑
在加載Assembly文件失敗的時候,AppDomain會觸發AssemblyResolve的事件,在這個事件的訂閱函數中,容許客戶程序自定義對加載失敗的Assembly的處理方式,好比,能夠經過Assembly.LoadFrom或者Assembly.LoadFile調用「手動地」將Assembly加載到AppDomain。shell
fuslogvw Assembly綁定日誌查看器
在.NET SDK中帶了一個fuslogvw.exe的應用程序,經過它能夠查看詳細的Assembly加載過程。使用方法很是簡單,使用管理員身份啓動Visual Studio 2017 Developer Command Prompt,而後在命令行輸入fuslogvw.exe,便可啓動日誌查看器。啓動以後,點擊Settings按鈕,以啓用日誌記錄功能:數據庫
日誌啓動以後,點擊Refresh按鈕,而後啓動你的.NET應用程序,就能夠看到當前應用程序所依賴的Assembly的加載過程日誌了:json
接下來,我會作一個例子程序,而後使用這個工具來分析Assembly的加載過程。架構
插件系統的實現與Assembly加載過程的分析
理論結合實際,看看如何經過實際代碼來詮釋以上所述Assembly的加載過程。一個比較好的例子就是設計一個簡單的插件系統,並經過觀察系統加載插件的過程,來了解Assembly加載的前因後果。爲了簡單直觀,我把這個插件系統稱爲PluginDemo。這個插件很簡單,主體程序是一個控制檯應用程序,而後咱們實現兩個插件:Earth和Mars,在不一樣的插件的Initialize方法中,會輸出不一樣的字符串。app
整個應用程序的項目結構以下:
該插件系統包含4個C#的項目:
- PluginDemo.Common:它定義了AddIn抽象類,全部的插件實現都須要繼承於這個抽象類。此外,AddInDefinition類是一個用來保存插件Metadata的類。爲了演示,插件的Metadata僅僅包含插件類型的Assembly Qualified Name
- PluginDemo.App:插件系統的應用程序。這個程序執行的時候,會掃描程序目錄下Modules目錄中的DLL,並根據module.xml的Metadata信息,加載相應的插件對象,並執行Initialize方法
- PluginDemo.Plugins.Earth:其中的一個插件實現
- PluginDemo.Plugins.Mars:另外一個插件實現
注意:除了PluginDemo.Common以外的其它三個項目,都對PluginDemo.Common有引用關係。而PluginDemo.App項目僅僅在項目自己依賴於PluginDemo.Plugins.Earth和PluginDemo.Plugins.Mars,它不會去引用這兩個項目。目的就是爲了當PluginDemo.App被編譯時,其他兩個插件項目也會同時被編譯並輸出到指定位置。
在Earth插件的CustomAddIn類中,咱們實現了Initialize方法,並在此輸出一個字符串:
1
2
3
4
5
6
7
8
9
|
public
class
CustomAddIn : AddIn
{
public
override
string
Name =>
"Earth AddIn"
;
public
override
void
Initialize()
{
Console.WriteLine(
"Earth Plugin initialized."
);
}
}
|
在Mars插件的CustomAddIn類中,咱們也實現了Initialize方法,並在此輸出一個字符串:
1
2
3
4
5
6
7
8
9
|
public
class
CustomAddIn : AddIn
{
public
override
string
Name =>
"Mars AddIn"
;
public
override
void
Initialize()
{
Console.WriteLine(
"Mars AddIn initialized."
);
}
}
|
那麼,在插件系統主程序中,就會掃描Modules子目錄下的module.xml文件,而後解析每一個module.xml文件得到每一個插件類的Assembly Qualified Name,而後經過Type.GetType方法得到插件類,進而建立實例、調用Initialize方法。代碼以下:
1
2
3
4
5
6
7
8
9
10
11
12
|
static
void
Main()
{
var
directory =
new
DirectoryInfo(
"Modules"
);
foreach
(
var
file
in
directory.EnumerateFiles(
"module.xml"
, SearchOption.AllDirectories))
{
var
addinDefinition = AddInDefinition.ReadFromFile(file.FullName);
var
addInType = Type.GetType(addinDefinition.FullName);
var
addIn = (AddIn)Activator.CreateInstance(addInType);
Console.WriteLine($
"{addIn.Id} - {addIn.Name}"
);
addIn.Initialize();
}
}
|
接下來,修改App.config文件,修改成:
1
2
3
4
5
6
7
8
|
<?
xml
version="1.0" encoding="utf-8" ?>
<
configuration
>
<
runtime
>
<
assemblyBinding
xmlns="urn:schemas-microsoft-com:asm.v1">
<
probing
privatePath="Modules\Earth;Modules\Mars;" />
</
assemblyBinding
>
</
runtime
>
</
configuration
>
|
此時,運行程序,能夠獲得:
目前沒有什麼問題。接下來,對兩個AddIn分別作一些修改。讓這兩個AddIn依賴於不一樣版本的Newtonsoft.Json,好比,Earth依賴於7.0.0.0的版本,Mars依賴於6.0.0.0的版本,而後分別修改兩個CustomAddIn的Initialize方法,在方法中各自調用一次JsonConvert.SerializeObject方法,以觸發Newtonsoft.Json這個Assembly的加載。此時再次運行程序,你將看到下面的異常:
如今,刷新fuslogvw.exe,找到Newtonsoft.Json的日誌:
雙擊打開日誌,能夠看到以下信息:
從整個過程能夠看出:
- PluginDemo.App.exe正在試圖加載PluginDemo.Plugins.Mars Assembly
- PluginDemo.Plugins.Mars開始調用Newtonsoft.Json
- 掃描應用程序配置文件、Host配置文件以及machine.config文件,均無找到Newtonsoft.Json的重定向信息,此時,Newtonsoft.Json版本肯定爲6.0.0.0
- GAC掃描失敗,繼續查找文件
- 首先查找應用程序當前目錄下有沒有Newtonsoft.Json,以及Newtonsoft.Json子目錄下有沒有Newtonsoft.Json.dll,發現都沒有,繼續
- 而後,經過App.config中的probing的privatePath設定,首先查找Modules\Earth目錄(由於這個目錄放在privatePath的第一個),找到了一個叫作Newtonsoft.Json.dll的Assembly,因而,判斷版本是否相同。結果,找到的是7.0.0.0,而它須要的倒是6.0.0.0,版本不匹配,因而就拋出異常,退出程序
那麼接下來,改一改App.config文件,將privatePath下的兩個值換個位置呢?
再試試:
此時,Earth AddIn又出錯了。那麼,咱們加上版本重定向的配置,指定當程序須要加載7.0.0.0版本的Newtonsoft.Json時,讓它重定向到6.0.0.0的版本:
再次執行,成功了:
看看日誌:
版本已經被重定向到6.0.0.0,而且在Mars目錄下找到了6.0.0.0的Newtonsoft.Json,加載成功了。
這個案例的源代碼能夠點擊此處下載。
總結
本文詳細介紹了.NET下Assembly的版本肯定和加載過程,最後給出了一個實例,對這個過程進行了演示。