← 文章列表

.NET AssemblyLoadContext 插件架構:共用契約、相依解析與可卸載驗證

.NET AssemblyLoadContext 插件架構:共用契約、相依解析與可卸載驗證

.NET 插件最難處理的問題通常不是把 DLL 載入記憶體,而是維持型別身分一致、讓每個插件解析自己的相依套件,並證明可回收的載入內容確實能卸載。本文以 .NET 8 的安全測試專案說明一套可重現方法,不連接外部系統,也不進行不可逆操作。

AssemblyLoadContext 插件隔離的核心結論

AssemblyLoadContext 插件架構應讓主程式持有唯一契約組件,插件內容則透過獨立載入內容與 AssemblyDependencyResolver 解析私有相依;若需要更新後釋放檔案,再以 collectible 模式配合弱參考與完整垃圾回收週期驗證卸載。

AssemblyLoadContext(ALC)是 .NET 執行階段的組件載入與解析範圍。它不是單純的資料夾搜尋器,也不是程序或容器等級的安全邊界。同名、同版本的契約 DLL 若分別被 Default ALC 與插件 ALC 載入,執行階段仍可能視為兩個不同型別世界;因此,插件實作看似繼承同一個介面,主程式轉型時仍會得到失敗結果。

穩定設計的第一條規則是「契約只載入一次」。主程式引用精簡的契約專案,插件建置時可參考它,但發佈輸出不攜帶第二份契約 DLL。自訂載入內容遇到契約組件名稱時回傳 null,讓執行階段回到 Default ALC 使用主程式已載入的版本。這不是忽略錯誤,而是明確指定共用邊界。

第二條規則是「插件相依由插件自己解析」。AssemblyDependencyResolver 依插件元件的 dependency 資訊解析受控路徑,避免把所有套件塞進主程式目錄。第三條規則是「可卸載必須測量」。呼叫 Unload() 只會啟動協作式卸載;只要執行緒、靜態欄位、事件處理器或其他強參考仍抓住插件型別,載入內容就不會被回收。

為什麼共用契約會發生型別轉換失敗

型別身分不只由命名空間、型別名稱與組件名稱決定,載入它的 AssemblyLoadContext 也參與判定;主程式與插件若各載入一份契約,即使檔案內容相同,主程式眼中的介面仍可能不是插件實作的那個介面。

設想 Contract.IPlugin 由主程式正常參考,而插件輸出又包含 Contract.dll。自訂 ALC 若從插件資料夾載入該檔,插件的 DemoPlugin 便實作「插件 ALC 裡的 IPlugin」。主程式轉型使用的卻是「Default ALC 裡的 IPlugin」。文字名稱相同不代表執行階段身分相同,這也是最常見的「明明有實作介面卻不能 cast」原因。

修正要同時處理建置與執行兩層。建置層透過專案參考的 Private="false",確認插件輸出沒有契約 DLL;執行層則在自訂 ALC 的 Load 覆寫中辨識契約名稱並回傳 null。兩層缺一不可:只改輸出但載入策略不清楚,未來打包變動可能重新引入副本;只改載入策略但保留多餘 DLL,則產物內容與架構意圖不一致,增加維護誤判。

契約本身也應保持薄而穩定。把資料庫驅動、記錄框架或大型領域模型放進契約,會擴大主程式與插件的版本耦合。契約只保留必要介面、簡單資料交換型別與明確的生命週期方法。介面變更優先採向後相容的新增方式,並透過測試插件檢查舊版本行為;不要假設相同檔名自然等於相容。

用 AssemblyDependencyResolver 管理插件相依

AssemblyDependencyResolver 適合把插件的 dependency 描述轉成實際組件路徑,再交由自訂 ALC 載入;它能讓每個插件維持可理解的相依集合,但不會自動解決共用契約、生命週期或安全信任問題。

典型載入內容在建構時接收插件主 DLL 的絕對路徑,建立 resolver;Load 收到 AssemblyName 後,先排除共用契約,再詢問 ResolveAssemblyToPath。若沒有結果就回傳 null,保留執行階段的正常 fallback。原生函式庫則可覆寫 LoadUnmanagedDll 並使用 ResolveUnmanagedDllToPath,但本文的測試專案只驗證受控組件,避免擴張測試範圍。

sealed class PluginContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver resolver;

    public PluginContext(string pluginPath)
        : base(isCollectible: true) => resolver = new(pluginPath);

    protected override Assembly? Load(AssemblyName name)
    {
        if (name.Name == typeof(IPlugin).Assembly.GetName().Name)
            return null; // 使用 Default ALC 中唯一的契約

        var path = resolver.ResolveAssemblyToPath(name);
        return path is null ? null : LoadFromAssemblyPath(path);
    }
}

這段程式的設計重點不是行數,而是解析順序。共用邊界先被明確排除,插件私有相依再由 resolver 處理,無法解析者最後回到標準行為。請勿用遞迴掃描整顆磁碟或任意載入名稱相近的 DLL,因為那會讓建置產物、執行結果與故障原因失去可預測性。

「檔案存在」也不等於「一定能解析」。Default ALC 通常依應用程式的 dependency 描述與既定規則運作;單純把未宣告引用的檔案複製到輸出資料夾,不能當成可靠的架構契約。若主程式確實需要額外元件,應建立明確參考、受控解析事件或固定探測規則,並在完整主程式啟動測試中驗證,而不是只用插件側的單元測試推論。

collectible 卸載的限制與設計原則

Collectible ALC 的卸載是協作式流程,不是呼叫 Unload() 後立即清空;應將載入與執行封裝在不可內聯的方法,清除強參考,保留 WeakReference,接著執行多輪 GC 與 finalizer 等待,最後以弱參考是否存活判定。

常見阻擋來源包括仍在執行插件程式碼的執行緒、主程式保存的插件實例、事件訂閱、靜態集合、例外物件,以及由其他載入內容持有的反射或序列化快取。這些根參考可能跨越原先想像的邊界。因而,插件介面除了工作方法,也需要清楚的停止與釋放協定;主程式卸載前先停止新工作、等待執行結束、解除事件,再釋放實例。

弱參考測試只能證明指定情境下的物件圖已可回收,不能證明所有工作負載都能卸載。測試至少應涵蓋純載入、執行主要入口、錯誤路徑與常用資料轉換流程。若某條路徑建立長生命週期快取,測試要如實顯示弱參考仍存活,而不是增加無限 GC 次數掩蓋問題。

是否使用 collectible 也要回到需求。若插件只在主程式啟動時載入、更新時可重啟測試程序,非 collectible 設計更簡單。若需要在不中止測試主機的情況下替換插件,才值得承擔生命週期治理成本。ALC 提供的是載入隔離,不會把不受信任程式碼變安全;來源不明的插件應放在更強的程序或 sandbox 邊界中處理。

驗證與重現:在 .NET 8 測試專案檢查三個條件

本次在 Windows 排程環境以 .NET SDK 8.0.100 實際建置並執行隔離測試;建置成功,插件輸出未含契約 DLL,介面轉型與回傳值正確,collectible ALC 在有限 GC 週期後不再存活。

前置條件是安裝 .NET SDK 8.0.100,準備 ContractPluginHost 三個本機測試專案,且不連接任何外部服務。global.json 將 SDK 固定為 8.0.100;Contract 宣告 IPlugin,Plugin 實作它並把專案參考設為 Private="false",Host 使用前述 PluginContext。完整檢查依下列順序執行:

  1. 在測試根目錄執行 dotnet --version,通過條件是輸出精確為 8.0.100;若找不到 SDK 或版本不同,先停止,不把其他版本結果視為本次查核。
  2. 執行 dotnet build Plugin/Plugin.csproj -c Release --nologo,通過條件是結束碼為零、零錯誤,且 Plugin/bin/Release/net8.0/Contract.dll 不存在;任一條件不符即失敗。
  3. 執行 Host 並傳入插件 DLL 絕對路徑,通過條件是 SHARED_CONTRACT_CAST=TruePLUGIN_RESULT=plugin-okCOLLECTIBLE_UNLOADED=True 同時可觀察;缺少任何一行即失敗。
dotnet --version
dotnet build Plugin/Plugin.csproj -c Release --nologo
dotnet run --project Host/Host.csproj -c Release -- \
  "C:/sandbox/alc-check/Plugin/bin/Release/net8.0/Plugin.dll"

排程環境的真實結果為:SDK 固定版本可用;Plugin 與 Contract 建置完成,零建置錯誤;產物檢查顯示契約未出現在插件輸出;執行輸出顯示共用契約轉型為 True、插件結果為 plugin-ok、collectible 卸載為 True。這是功能查核,不是效能 benchmark,因此本文不宣稱載入速度、記憶體節省比例或吞吐改善。

若要刻意重現錯誤,可在獨立測試副本把插件專案參考改成會複製契約,並讓自訂 ALC 載入該副本;預期主程式轉型失敗。此負向測試只能在可丟棄的測試專案執行,完成後刪除測試產物,不應對既有部署目錄直接試驗。

導入檢查清單與架構取捨

導入前應逐項確認契約所有權、相依解析、生命週期、更新方式與可觀察失敗條件;只要其中一項仍依賴「剛好在同一資料夾」,插件系統就還沒有形成可維護的載入邊界。

  • 契約組件由主程式載入且只出現一份,插件輸出檢查納入建置測試。
  • 自訂 ALC 明確排除共用契約,插件私有相依交由 AssemblyDependencyResolver
  • 每個插件的主 DLL 與 dependency 描述保持一致,不以任意資料夾掃描補洞。
  • 插件介面定義停止與釋放順序,主程式不長期保存插件型別或反射物件。
  • collectible 情境使用 WeakReference 與有限 GC 週期驗證,失敗時保留診斷證據。
  • 更新策略區分可重啟測試主機與必須動態替換兩種需求,不為不需要的能力增加複雜度。
  • ALC 不作為不受信任程式碼的防護邊界;需要更強隔離時改用程序或 sandbox。

架構決策可以濃縮成三句話:共用型別只由主程式載入,插件相依由插件解析,可卸載能力以真實生命週期測試證明。這三項若能在每次建置重現,插件系統才具備可預測的型別身分、部署內容與更新行為。

官方依據與結論

本文查核 Microsoft Learn 的 ALC 概念文件、官方插件教學與 AssemblyDependencyResolver API 文件,三個 HTTPS 頁面均實際開啟成功,且共同支持載入內容、共用契約與 resolver 的核心設計。

官方概念文件指出,每個 ALC 對單純組件名稱只載入一個版本,並說明載入內容與型別轉換問題;官方插件教學展示自訂 ALC 搭配 AssemblyDependencyResolver,同時提醒共用插件介面組件不應被複製到插件輸出;API 文件則界定 resolver 依元件 dependency 資訊解析受控與原生相依路徑的用途。

最終結論不是「所有插件都必須自訂 ALC」,而是先判斷是否需要版本隔離與動態卸載。需要時,請把共用契約、插件相依與生命週期視為三個獨立設計面,分別用產物檢查、執行轉型與弱參考回收測試驗證。如此才能把偶然可執行的 DLL 載入,提升為可重現、可診斷、可維護的 .NET 插件架構。

廣告