.NET Framework 組件版本衝突的問題與解決方法

相依性組件版本衝突

在 .NET Framework 裡,我們將程式碼編譯建置後的結果稱為「組件」,是共同運作及構成一個功能邏輯單位的型別與資源集合。組件可以是「可執行檔(.exe)」或是「動態連結程式庫(.dll)」的型式。

現代化的 .NET 應用程式通常依賴於一至多個 NuGet 函式庫套件,透過安裝並使用這些套件,節省了大量重複造輪子的時間,提高了開發人員的生產力。

另外,在開發規模較大的應用程式時,我們通常會將應用程式切割為多個專案來進行,並將專案間共用的程式邏輯抽取出來成為另一個類別庫,來避免程式邏輯的重複。

那麼,問題來了,如果我們的應用程式和共用類別庫相依於同一個 NuGet 函式庫套件的不同版本時,會發生什麼事呢?來看看以下一個簡化過的例子:

相依性組件版本衝突

我們可以看到,應用程式加入了共用類別庫的參考,並相依於「Newtonsoft.Json 版本 11」,而共用類別庫則相依於「Newtonsoft.Json 版本 12」,這造成了相依性組件的版本衝突,讓我們的應用程式無法正確地載入 Newtonsoft.Json 組件。

問題重現

以 ASP.NET MVC 5 應用程式為例,打開 Visual Studio,新增一個名稱為「VersionConflicts」的 ASP.NET MVC 5 專案,作為我們的應用程式。

從選單裡找到「工具 > NuGet 套件管理員 > 套件管理器主控台」打開「套件管理器主控台」,輸入指令「Install-Package Newtonsoft.Json -Version 11.0.2 -Project VersionConflicts」,用來在我們的應用程式安裝及建立對 Newtonsoft.Json 版本 11 組件的相依性。

在專案的 Controllers 目錄下新增一個控制器「HomeController.cs」,內容如下:

using System.Web.Mvc;
namespace VersionConflicts.Controllers
{
public class HomeController : Controller
{
public ActionResult Index()
{
Response.Write(Newtonsoft.Json.JsonConvert.SerializeObject(new
{
msg = "序列化自「應用程式」"
}));
return Content("");
}
}
}

在方案中新增一個類別庫專案,,命名為「SharedLibrary」,用來模擬我們的共用類別庫。

再次打開「套件管理器主控台」,這次要在我們的共用類別庫安裝及建立對 Newtonsoft.Json 版本 12 組件的相依性,不同於應用程式相依於版本 11,這將造成相依性組件的版本衝突。在套件管理器主控台中輸入指令「Install-Package Newtonsoft.Json -Version 12.0.3 -Project SharedLibrary」。

將共用類別庫中的 Class1.cs 取代為以下內容:

namespace SharedLibrary
{
public class Class1
{
public static string SerializeObject()
{
return Newtonsoft.Json.JsonConvert.SerializeObject(new
{
msg = "序列化自「共用類別庫」"
});
}
}
}

回到應用程式專案 VersionConflicts,加入對共用類別庫 SharedLibrary 專案的參考。

將 HomeController.cs 中的 Index() 動作方法修改如下:

public ActionResult Index()
{
Response.Write(SharedLibrary.Class1.SerializeObject());
Response.Write("<br>");
Response.Write(Newtonsoft.Json.JsonConvert.SerializeObject(new
{
msg = "序列化自「應用程式」"
}));
return Content("");
}

確定 VersionConflicts 為啟設專案後,按下「Ctrl + F5」啟動但不偵錯應用程式。由於我們的應用程式有相依性組件的版本衝突,以致於應用程式無法正確地載入相依性組件。

我們也能在 Visual Studio 的錯誤清單視窗中,看到相依性組件版本衝突的警告。

可能的解決方法

1. 使相依性組件的版本一致

解決組件版本衝突的基本策略,就是消除組件版本的不一致,如果能夠修改相依性,讓衝突的組件都採用同一個版本,那麼這是最簡單的解決方法。例如在我們的例子中,「應用程式」以及「共用類別庫」對 Newtonsoft.Json 組件的相依性都是我們能控制的,我們可以對這兩個專案進行 NuGet 套件的更新,讓它們都參考至最新的,或一致的版本組件,例如 Newtonsoft.Json 版本 12。

打開「套件管理器主控台」,輸入以下的指令可以更新方案中所有專案的 Newtonsoft.Json 套件至最新版本:

Update-Package Newtonsoft.Json

更新方案中所有專案的相依性套件版本後,應「重建方案」(選單:建置 > 重建方案),以刪除所有已編譯的組件並重新建置方案下的所有專案。

2. 重新導向相依性組件版本

如果沒辦法從專案來修改相依性,例如共用類別庫是已編譯過的DLL,那麼我們可以強制要求 .NET Framework,當執行應用程式發生組件版本衝突時,要載入的組件版本,這樣的動作稱為「重新導向組件版本」。以我們的例子來說,在應用程式 VersionConflicts 專案,編輯根目錄下的「Web.config」檔案,在 /configuration/runtime 區段,加上 assemblyBinding 組態設定,編輯後的組態類似於底下的樣子:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!– 省略… –>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="11.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
<!– 省略… –>
</configuration>

如果應用程式有多個相依性組件版本衝突,必須在 assemblyBinding 組態下為每一個衝突的相依性組件增加一個 dependentAssembly 節點。

我們來詳細看看 dependentAssembly 下的 assemblyIdentity 元素,屬性 name 為相依性組件的名稱,也是 NuGet 套件的 Id,屬性 publicKeyToken 為組件強式名稱的雜湊值,culture 屬性則是組件的文化特性。

<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />

這些組件相關的資訊都能使用 PowerShell 取得,在 PowerShell 下執行以下 Script (記得修改 dll 檔的路徑):

[System.Reflection.Assembly]::LoadFile(“C:\your\dll\path\Newtonsoft.Json.dll").FullName

執行後 PowerShell 將印出組件的顯示名稱,包含組件的名稱、版本、文化特性以及組件強式名稱的雜湊值,範例如下:

Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed

再來看看 bindingRedirect 元素,oldVersion 為要載入的組件版本範圍,newVersion 則是要重新導向的組件版本。

<bindingRedirect oldVersion="0.0.0.0-12.0.0.0″ newVersion="11.0.0.0″ />

我們所加入這整個 dependentAssembly 節點的意思為,當應用程式要載入 Newtonsoft.Json 組件時,不論版本為 oldVersion 範圍裡的那一個版本(0.0.0.0-12.0.0.0),都載入 newVersion 所指定的版本(11.0.0.0)。

3. 同時載入不同版本的相依性組件

首先我們必須認識到,讓應用程式同時載入不同版本的相同組件並不是一個好的作法,因為相同組件的不同版本間僅有部分的差異,我們將載入其他相同部分重複的程式邏輯。另外,組件的設計者在設計組計時,多數不會料想到組件會被重複載入,這將造成許多不可預期的例外。

如果已經充分了解風險,我們還是可以強制 .NET Framework 執行應用程式時,同時載入不同版本的相同組件。方法包括了:

1. 處理 AssemblyResolve 事件,動態載入不同版本組件 (不需要是強式名稱簽署組件)
2. 在 Web.config 裡,加上 assemblyBinding 組態,並在 dependentAssembly 節點使用 <codeBase> 元素指定組件版本 (必須是強式名稱簽署組件)
3. 將不同版本的相同組件安裝到全域組件快取 (GAC)

3.1. 處理 AssemblyResolve 事件,動態載入不同版本組件 (不需要是強式名稱簽署組件)

首先將範例中「應用程式」(VersionConflicts 專案),以及「共用類別庫」(SharedLibrary 專案),兩者對 Newtonsoft.Json 的參考,屬性「複製到本機」都設為 False。

以 SharedLibrary 專案為例,先在方案總管中,展開專案的參考清單,選取 Newtonsoft.Json 項目,按下 F4 編輯參考屬性,將屬性「複製到本機」設為 False。

編輯參考屬性,將屬性「複製到本機」設為 False。

接著,手動將不同版本的 Newtonsoft.Json.dll 複製到應用程式(VersionConflicts 專案) bin 目錄下的不同版本名稱資料夾裡面,目錄結構如下圖:

回到 Visual Studio,打開方案總管,在應用程式 VersionConflicts 專案中,將 bin 目錄下手動加入的資料夾,如V11、V12,開啟右鍵選單「加入至專案」,這樣在 Web 應用程式發佈時,才會將這些手動加入的資料夾一併複製到發佈目錄。

將 bin 目錄下手動加入的資料夾「加入至專案」

在 VersionConflicts 專案下,打開 Global.asax.cs,在 Application_Start() 方法中,註冊 AssemblyResolve 事件處理函式,以動態載入不同版本組件。範例如下:

using System;
using System.IO;
using System.Reflection;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
namespace VersionConflicts
{
public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
AppDomain.CurrentDomain.AssemblyResolve += (sender, resolveArgs) =>
{
string assemblyInfo = resolveArgs.Name; // e.g "Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed"
var parts = assemblyInfo.Split(',');
string name = parts[0];
var version = Version.Parse(parts[1].Split('=')[1]);
string fullName;
if (name == "Newtonsoft.Json" && version.Major == 11)
{
// 如果參考的組件為版本11,則載入V11資料夾下的組件
fullName = Path.Combine(HttpRuntime.AppDomainAppPath, @"bin\V11\Newtonsoft.Json.dll");
}
else if (name == "Newtonsoft.Json" && version.Major == 12)
{
// 如果參考的組件為版本12,則載入V12資料夾下的組件
fullName = Path.Combine(HttpRuntime.AppDomainAppPath, @"bin\V12\Newtonsoft.Json.dll");
}
else
{
return null;
}
return Assembly.LoadFile(fullName);
};
AreaRegistration.RegisterAllAreas();
RouteConfig.RegisterRoutes(RouteTable.Routes);
}
}
}

如果有多個組件版本衝突,我們必須在 AssemblyResolve 事件函式中增加判斷式分別處理。

3.2. 在 Web.config 裡,加上 assemblyBinding 組態,並在 dependentAssembly 節點使用 <codeBase> 元素指定組件版本 (必須是強式名稱簽署組件)

如果版本衝突的組件是強式名稱簽署組件,那麼我們可以不必撰寫程式碼處理 AssemblyResolve 事件,可以用更簡單的組態設定方式,來同時載入相同組件的不同版本。請先參考上一小節,將組件參考屬性「複製到本機」都設為 False、手動複製組件到 bin 目錄下的不同版本資料夾,以及將手動複製的組件資料夾「加入至專案」。

接著只要在 Web.config 裡,加上 assemblyBinding 組態,並在 dependentAssembly 節點使用 <codeBase> 元素指定組件版本即可,修改後的 Web.config 類似於以下的樣子:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!– 省略… –>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral"/>
<codeBase version="11.0.0.0" href="bin/V11/Newtonsoft.Json.dll"/>
<codeBase version="12.0.0.0" href="bin/V12/Newtonsoft.Json.dll"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
<!– 省略… –>
</configuration>

相同地,如果有多個組件版本衝突,必須在 assemblyBinding 組態下為每一個衝突的相依性組件增加一個 dependentAssembly 節點。

3.3. 將不同版本的相同組件安裝到全域組件快取 (GAC)

將不同版本的相同組件安裝到全域組件快取 (GAC),.Net Framework 在執行應用程式時,就會依據組件參考的不同版本,同時載入對應版本的組件。我們可以利用 gacutil.exe 安裝組件至 GAC,首先在 Visual Studio 中按下「Ctrl + `」打開「開發人員提示字元」(或選單:檢視 > 終端機),執行以下指令將 SharedLibrary 專案所參考的 Newtonsoft.Json 版本 12,以及 VersionConflicts 專案所參考的 Newtonsoft.Json 版本 11,都安裝到 GAC 即可。

gacutil -i SharedLibrary\bin\Debug\Newtonsoft.Json.dll

gacutil -i VersionConflicts\bin\Newtonsoft.Json.dll

將不同版本的相同組件安裝到全域組件快取 (GAC)

將組件安裝至 GAC 是一個簡單解決組件版本衝突的好方法,條件是組件必須是強式名稱簽署組件,另外較不適合 WebFarm 負載平衡式伺服器陣列的架構,因為必須在伺服器陣列下的每一台主機安裝組件。

使用以上可能的解決方法解決相依性組件版本衝突後,在 Visual Studio 中確後目前啟始專案為 VersionConflicts,按下「Ctrl + F5」啟動但不偵錯應用程式,可以看到即使相依性組件參考至不同版本,應用程式仍能正確執行。

總結

現代化的 .NET 應用程式通常依賴於一至多個 NuGet 函式庫套件,而隨著應用程式規模的增加,我們通常會將應用程式切割為多個專案來進行,專案間時常會發生相依性組件的版本衝突,本篇文章介紹了相依性組件版本衝突的原因,以及可能的解決方法。

參考資料

https://blog.alantsai.net/posts/2017/10/donet-dll-hell-problem-how-to-use-bindingredirect
https://blog.darkthread.net/blog/assemblyinformationversion
https://michaelscodingspot.com/dotnet-dll-hell/

發表留言