琢磨半天,就用「ASP.NET /bin 組件載入跟你想的不一樣」當標題吧! 如果讀者朋友們跟我一樣到現在才恍然大悟,用這標題非常貼切;如果是大家早就知道的知識,拿這標題嗆我自己也十分到位。呵~

分享一則最近被導正的一則 ASP.NET 組件載入觀念。

以 ODP.NET 為例,當我們在 ASP.NET 專案引用 Oracle.DataAccess.dll,編譯產生的 /bin 目錄會包含 Oracle.DataAccess.dll,習慣上會一起被部署到 IIS 主機。當 Oracle.DataAccess.dll 是 32 位元版本,IIS AppPool 就必須啟用 「Enabled 32-Bit Applications」,否則將會出現經典的 Oracle Client 32/64 版本錯誤「Could not load file or assembly 'Oracle.DataAccess' or one of its dependencies. An attempt was made to load a program with an incorrect format. / 無法載入檔案或組件 'Oracle.DataAccess' 或其相依性的其中之一。 試圖載入格式錯誤的程式。」

依據這個現象,我一直順理成章解釋為「因 ASP.NET 網頁會使用 32 位元版 /bin/Oracle.DataAccess.dll,故要調整 AppPool 配合」,但事實並不是這樣。

由官方文件執行階段如何找出組件, .NET Runtime 解析組件的程序如下:

  1. 檢查應用程式組態檔、發行者原則檔和電腦組態檔決定版本 (延伸閱讀:發行者原則檔)
  2. 檢查該組件名稱是否已被繫結,若是就沿用先前載入的組件 (若先前載入失敗,則直接回傳失敗不再重試)
  3. 檢查 GAC,如果有就直接使用安裝到 GAC 的組件
  4. 啟用探查組件程序
    在此階段才會嘗試由應用程式所在目錄的 DLL 檔載入組件

由此可知,若組件已被安裝到 GAC,其優先權將大於 /bin 下的 DLL,用一段 ASP.NET MVC 程式可驗證這點。在 Home.Index()中,藉由 OracleConnection 型別的 CodeBase 檢查 DLL 所在位置,並試連 Oracle 進行查詢以確定資料庫功能正常:

public class HomeController : Controller
{
	private static string cs = "data source=ora_server;user id=blah;password=****";
	public ActionResult Index()
	{
		var codeBase = typeof(Oracle.DataAccess.Client.OracleConnection).Assembly.CodeBase;
		using (var cn = new OracleConnection(cs))
		{
			var guid = cn.Query("SELECT sys_guid() AS G FROM DUAL")
				.Select(o => new Guid(o.G as byte[])).First();
			return Content(guid + "\n" + codeBase, "text/plain");
		}
	}
}

編譯產生的 bin 目錄如下,包含 Oracle.DataAccess.dll:

將檔案部署到 IIS 主機(該主機預先安裝 32 與 64 兩種版本的 Oracle Client),先不開啟 AppPool 32 位元模式,不意外噴出錯誤:

啟用 AppPool 32 位元模式,程式可執行,但注意 Oracle.DataAccess 的 CodeBase 並非 /bin/Oracle.DataAccess.dll,而是 C:/Windows/Microsoft.Net/assembly/GAC_32/:

接著取消 AppPool 32 位元模式並刪除 /bin/Oracle.DataAccess.dll。神奇的事發生了,程式依然可以跑,此時使用的 Oracle.DataAccess.dll 來自 C:/Windows/Microsoft.Net/assembly/GAC_64/:

最後一個測試拉回開發主機,我刻意用gacutil /u Oracle.DataAccess將 ODP.NET 元件從 GAC 移除,此時 ASP.NET 總算改用 /bin/Oracle.DataAccess.dll:

由以上測試得到結論:安裝 Oracle Client 時會一併安裝 ODP.NET 到 GAC (註:ODAC 12.2 版起不再自動註冊 GAC),故實務上 ASP.NET 會使用裝在 GAC 的組件,並不需要部署 /bin/Oracle.DataAccess.dll。若同時安裝 32、64 兩種版本的 Oracle Client,.NET Runtime 還會依 AppPool 是否啟用 32 位元模式選用適當的版本。
(註:有一種例外狀況是刻意不要用主機 GAC 安裝的 ODP.NET 版本,在 web.config 指定特別版號並部署到 /bin 目標下,但 Unmanaged ODP.NET 需搭配主機安裝的 Oracle Client 版本,且幾乎都會被發行者原則設定導向 GAC 版本,實務上不常見 )

雖然 /bin/Oracle.DataAccess.dll 形同空包彈,一旦它被複製到 /bin,AppPool 卻又必須配合它啟用 32 位元模式,這又是怎麼一回事?

來看一個有趣實驗,我們另開一個完全沒用到 Oracle 的 ASP.NET/ASP.NET MVC 網站,測試 OK 後在 /bin 下放入 32 位元版 Oracle.DataAccess.dll 並刻意不開啟 AppPool 32 位元模式,奇妙的事發生了 - 網頁冒出 Oracle.DataAccess Incorrect Image 錯誤。

由此可知 DLL 只要放進 /bin 資料夾就會被載入,不管網站有沒有用到它。而 /bin 下所有 DLL 都會被載入這事亦可由錯誤訊息尋得蜘絲馬跡:

Could not load file or assembly 'Oracle.DataAccess' or one of its dependencies. An attempt was made to load a program with an incorrect format.
[ConfigurationErrorsException: Could not load file or assembly 'Oracle.DataAccess' or one of its dependencies. An attempt was made to load a program with an incorrect format.]
   System.Web.Configuration.CompilationSection.LoadAssemblyHelper(String assemblyName, Boolean starDirective) +12551344
   System.Web.Configuration.CompilationSection.LoadAllAssembliesFromAppDomainBinDirectory() +499
   System.Web.Configuration.AssemblyInfo.get_AssemblyInternal() +131
   System.Web.Compilation.BuildManager.GetReferencedAssemblies(CompilationSection compConfig) +331
   System.Web.Compilation.BuildManager.CallPreStartInitMethods(String preStartInitListPath, Boolean& isRefAssemblyLoaded) +148
   System.Web.Compilation.BuildManager.ExecutePreAppStart() +172
   System.Web.Hosting.HostingEnvironment.Initialize(ApplicationManager appManager, IApplicationHost appHost, IConfigMapPathFactory configMapPathFactory, HostingEnvironmentParameters hostingParameters, PolicyLevel policyLevel, Exception appDomainCreationException) +1151

System.Web 有個 BindingManager.GetReferencedAssemblies() 負責取得所有頁面編譯所需的參照組件清單,其中則呼叫了 CompilationSeciont.LoadAllAssembliesFromAppDomainBinDirectory(), 追進 MS Reference Source 上的原始碼,可印證 ASP.NET 在啟動過程搜尋 /bin/*.DLL 並試圖載入的行為:

internal Assembly[] LoadAllAssembliesFromAppDomainBinDirectory() {
	// Get the path to the bin directory
	string binPath = HttpRuntime.BinDirectoryInternal;
	FileInfo[] binDlls;
	Assembly assembly = null;
	Assembly[] assemblies = null;
	ArrayList list;

	if (!FileUtil.DirectoryExists(binPath)) {
		// This is expected to fail if there is no 'bin' dir
		System.Web.Util.Debug.Trace("Template", "Failed to access bin dir \"" + binPath + "\"");
	}
	else {
		DirectoryInfo binPathDirectory = new DirectoryInfo(binPath);
		// Get a list of all the DLL's in the bin directory
		binDlls = binPathDirectory.GetFiles("*.dll");

		if (binDlls.Length > 0) {
			list = new ArrayList(binDlls.Length);

			for (int i = 0; i < binDlls.Length; i++) {
				string assemblyName = Util.GetAssemblyNameFromFileName(binDlls[i].Name);

				// Don't autoload generated assemblies in bin (VSWhidbey 467936)
				if (assemblyName.StartsWith(BuildManager.WebAssemblyNamePrefix, StringComparison.Ordinal))
					continue;

				if (!GetAssembliesCollection().IsRemoved(assemblyName)) {
					assembly = LoadAssemblyHelper(assemblyName, true);
				}
				if (assembly != null) {
					list.Add(assembly);
				}
			}
			assemblies = (System.Reflection.Assembly[])list.ToArray(typeof(System.Reflection.Assembly));
		}
	}

	if (assemblies == null) {
		// If there were no assemblies loaded, return a zero-length array
		assemblies = new Assembly[0];
	}

	return assemblies;
}

經過這番挖掘,ASP.NET 老骨頭對 /bin DLL 的運作再多了一點了解,並導正幾個觀念:

  1. 當元件有被安裝到 GAC,/bin 下放置 DLL 的動作是多餘的
  2. 當元件同時安裝 GAC_32、GAC_64 兩種版本,可實現依 AppPool 是否啟用 32 位元模式自動選用合適版本
    延伸閱讀:GAC 與其實體路徑
  3. 放進 /bin/ 的所有 DLL 都會被 ASP.NET 執行環境載入,不管有沒有被用到

活到老,學到老,繼續加油~

Clarifying some ASP.NET assembly loading concepts. The assembly installed in GAC will be used first, so most of time the unmanaged ODP.NET dll is not used by ASPX or MVC action, but all the dlls under /bin will be loaded during ASP.NET hosting environment initialization.


Comments

# by denie

學到了

# by Ho.Chun

太精采了! 學習

Post a comment