.NET DLL 部署驗證不能只看建置結果。當網站專案透過 HintPath 參考另一個儲存庫產生的 DLL,本機使用 ProjectReference 建置成功,並不能證明提交後的二進位、CI 建置輸入與執行目錄一致。真正要驗證的是同一批相依組件是否來自同一個來源快照、是否一起複製,以及執行期實際載入的成員契約是否符合呼叫端。

這篇文章面向維護跨儲存庫 .NET 解決方案的開發者與發布負責人。重點不是把 DLL 當成一般檔案搬運,而是把它視為具有版本、相依圖與公開成員的部署契約。所有範例都使用本機測試專案與虛構元件,不連接真實服務或資料。

為什麼建置成功仍可能在執行期失敗

直接答案: .NET DLL 部署驗證必須比對編譯輸入與執行輸出。編譯器只驗證建置當下看到的參考組件;若發布目錄仍保留舊 DLL,或只替換相依圖中的其中一支,執行期就可能載入與編譯期不同的成員集合,最後出現 MissingMethodException、TypeLoadException 或延後編譯檔案的成員不存在錯誤。

一個常見模型是:元件儲存庫產生多支類別庫,網站儲存庫則把產物放在 Libraries 之類的目錄,再由 與 指向檔案。本機為了快速除錯,工程師可能暫時改用 ``。兩種參考都能讓程式碼編譯,但它們回答的是不同問題:前者驗證已提交 DLL,後者驗證目前工作區原始碼。

風險通常沿著三個邊界出現:

  • 編譯輸入邊界: 呼叫端究竟讀取工作區專案,還是讀取已提交的 DLL。
  • 輸出複製邊界: Private 或 Copy Local 是否讓正確檔案進入輸出資料夾。
  • 執行載入邊界: 程序實際載入哪個路徑、哪個組件版本,以及該組件是否真的具有所需成員。

因此,「零錯誤」只能證明某一組輸入成功編譯。它不能自動證明部署目錄已更新,也不能證明同名 DLL 在參考目錄與執行目錄具有相同位元組。檔案時間也不夠可靠,因為複製、解壓縮或還原都可能改變時間;相反地,內容變更也可能在保留時間後被忽略。

把內部 DLL 視為不可拆分的版本集合

直接答案: 只要多支 DLL 之間會直接呼叫彼此的公開成員,就應把它們當成一個可驗收的版本集合;任何一支改變公開契約,都必須重新建置相依圖、整組進入候選發布目錄,並以清單與雜湊證明沒有混入舊檔。

假設 Component.Models 新增屬性,而 Component.Utilities 開始呼叫它。若參考目錄同時包含兩支新版,網站建置自然通過;但若執行目錄只收到新版 Utilities,舊版 Models 仍會被載入。呼叫端直到執行該路徑時才發現方法不存在。這不是隨機故障,而是二進位集合失去一致性的必然結果。

較穩健的發布單位至少包含以下資料:

  1. 元件集合名稱與來源提交識別。
  2. 每支 DLL 的相對路徑、SHA-256、組件版本與檔案版本。
  3. 產生這批檔案的目標框架與建置組態。
  4. 呼叫端候選輸出目錄的同一份資訊。
  5. 驗收時間與驗收工具版本,但不放入環境專屬值。

雜湊回答「位元組是否相同」,組件版本回答「作者宣告的相容版本為何」,反射則回答「執行期可見的型別與成員是否存在」。三者不能互相取代。只比檔案大小可能漏掉等長差異;只比版本可能遇到未遞增版本;只列成員則無法證明整份檔案一致。

若 DLL 直接納入版本控制,二進位衝突也不能靠任選一側解決。正確流程是先合併元件原始碼,再由該合併結果重建整個相關集合,最後重新產生清單。否則挑選任一舊檔,都可能遺失另一側已合併的公開契約。長期而言,具版本的套件來源通常比手動維護檔案更容易稽核,但遷移前仍要先建立上述集合驗收。

HintPath、Copy Local 與執行目錄的責任邊界

直接答案: MSBuild 的 Reference 項目可用 HintPath 指定組件位置,Private 則控制參考是否複製到輸出資料夾;然而設定正確只代表建置規則成立,仍須直接檢查輸出檔、雜湊與程序載入位置,不能把專案檔設定當成部署完成證據。

Microsoft 的 MSBuild 文件將 HintPath 定義為參考組件的相對或絕對路徑,並說明 Private 對應 Visual Studio 的 Copy Local。這提供一個可測試的契約:在乾淨輸出目錄建置後,指定 DLL 應出現在呼叫端輸出。若不存在,應先檢查參考項目、條件式屬性與實際目標框架,而不是直接手動複製掩蓋問題。

以下是刻意簡化的專案片段:


  
    ../ComponentLib/bin/Release/net10.0/ComponentLib.dll
    true
  

這個設定仍有四個需要驗證的假設。第一,路徑下的檔案是預期組態產物;第二,目標框架資料夾沒有因升級而改變;第三,增量建置沒有沿用過期輸出;第四,後續封裝或搬運步驟沒有再次覆蓋 DLL。建議每次從空白候選目錄開始,避免殘留檔讓缺漏看起來完整。

Assembly.GetName().Version 可以讀取載入組件所宣告的版本;反射可列出型別的屬性或方法。檢查時同時記錄 assembly.Location,才能避免「檢查了正確版本,程序卻從另一個路徑載入」的錯覺。對不能直接載入的不受信任檔案,不應在主要程序中任意執行;本文的反射步驟只針對自行建置的本機測試組件。

建立 CI 等價建置與發布前閘門

直接答案: CI 等價建置必須使用與自動化流程相同的儲存庫邊界、已提交專案檔、固定 SDK、Release 組態與 DLL 參考形式;完成後再對候選輸出做清單、雜湊、版本、成員及啟動測試,任一差異都應阻擋發布。

本機 ProjectReference 的價值是快速開發與逐步除錯,但它會讓尚未同步到網站儲存庫的元件修改立即可見。若 CI 只取得網站儲存庫,這種綠燈不具有等價性。至少要保留一條驗證路徑,暫時回到已提交的 與,以乾淨還原與乾淨輸出重新建置呼叫端。

發布閘門可以拆成五層,而且每層都要有清楚的失敗條件:

  • 來源層: 專案檔沒有意外指向工作區外的 sibling 路徑。
  • 建置層: 固定 SDK 與 Release 建置成功,沒有被忽略的複製警告。
  • 集合層: 預期 DLL 全部存在,沒有多餘、缺漏或重名來源。
  • 契約層: 必要型別與成員存在,組件版本符合清單。
  • 位元組層: 元件來源與候選輸出中的對應檔案 SHA-256 完全相同。

此外,要把執行期編譯的 View、模板或其他呼叫端一起納入版本邊界。若可見檔案先更新、承載資源或方法的組件稍後才更新,中間狀態仍會失敗。原子性不一定要求單一檔案,但要求外部觀察者只能看到完整舊集合或完整新集合。可利用候選目錄先組裝、驗證,再以平台支援的可回復切換方式取代目前版本。

回復也要以集合為單位。保存上一批已驗收清單與完整檔案,才能在新批次失敗時回到已知一致狀態。只回復最先報錯的一支 DLL,往往會建立另一種混合版本。North Star Metric 應是「候選集合驗證一次通過率」;輔助 KPI 可追蹤雜湊差異數、缺漏檔案數、CI 等價建置失敗率與發布後載入錯誤數,而不是只看建置成功率。

驗證與重現:固定 .NET 10.0.400 的安全測試

直接答案: 本次在 Windows 本機以 .NET SDK 10.0.400 建立類別庫與 HintPath 呼叫端,實際完成 Release 建置、Copy Local、反射版本與成員檢查,以及來源與輸出 DLL 的 SHA-256 比對;所有操作都限制在獨立測試資料夾。

前置條件是安裝 .NET SDK 10.0.400、Git Bash 提供 sha256sum,並準備空白測試資料夾。測試類別庫宣告 AssemblyVersion=1.2.3.4,公開唯讀成員回傳固定標記;呼叫端使用 HintPath 與 true,沒有網路或資料庫相依。

  1. 在測試根目錄放置鎖定 10.0.400 的 global.json,建立 ComponentLib 類別庫與 Consumer 主控台專案。
  2. 先以 Release 建置類別庫,再建置只透過 HintPath 參考 DLL 的呼叫端。
  3. 以 --no-build 執行呼叫端,確認版本、公開成員值與載入檔名。
  4. 對元件輸出與呼叫端輸出中的同名 DLL 執行 SHA-256,比對兩行摘要必須一致。

請先依下列相對路徑建立五個完整檔案;這些內容對應已執行的隔離測試,不需要額外的類別定義。

global.json

{
  "sdk": {
    "version": "10.0.400",
    "rollForward": "disable"
  }
}

ComponentLib/ComponentLib.csproj


  
    net10.0
    1.2.3.4
    1.2.3.4
    true
  

ComponentLib/ReleaseMarker.cs

namespace ComponentLib;

public static class ReleaseMarker
{
    public static string Contract => "atomic-set-v1";
}

Consumer/Consumer.csproj


  
    Exe
    net10.0
  
  
    
      ../ComponentLib/bin/Release/net10.0/ComponentLib.dll
      true
    
  

Consumer/Program.cs

using System;
using System.IO;
using System.Reflection;
using ComponentLib;

var assembly = typeof(ReleaseMarker).Assembly;
var version = assembly.GetName().Version?.ToString() ?? "missing";
var property = assembly.GetType("ComponentLib.ReleaseMarker")?.GetProperty("Contract");
var value = property?.GetValue(null)?.ToString() ?? "missing";

Console.WriteLine($"AssemblyVersion={version}");
Console.WriteLine($"Contract={value}");
Console.WriteLine($"Location={Path.GetFileName(assembly.Location)}");

return version == "1.2.3.4" && value == "atomic-set-v1" ? 0 : 1;
dotnet --version
dotnet build ComponentLib/ComponentLib.csproj -c Release --nologo
dotnet build Consumer/Consumer.csproj -c Release --nologo
dotnet run --project Consumer/Consumer.csproj -c Release --no-build
sha256sum ComponentLib/bin/Release/net10.0/ComponentLib.dll Consumer/bin/Release/net10.0/ComponentLib.dll

預期主控台輸出如下;程式退出碼檢查版本與標記,載入檔名及兩份雜湊仍須另外比對。不同目錄或建置輸入可能產生不同摘要,驗收重點是同一次建置的來源與複製檔相等,而非所有讀者都取得本文的歷史摘要。

AssemblyVersion=1.2.3.4
Contract=atomic-set-v1
Location=ComponentLib.dll

實際結果:兩個專案皆為 0 warnings、0 errors;程式輸出 AssemblyVersion=1.2.3.4、Contract=atomic-set-v1 與 Location=ComponentLib.dll;兩份 DLL 的 SHA-256 均為 f03cdf456619224c9094374681365e1c12137d613695a67660e3268d068095ec。這證明該隔離案例的 HintPath 複製、版本讀取、成員存取與位元組一致性均通過,不代表其他專案可跳過自己的乾淨建置與相依集合盤點。

失敗條件也要明確:SDK 不是固定版本、任一建置非零退出、輸出 DLL 缺少、版本或固定標記不同、載入檔名不符,或兩個摘要不同,都算失敗。為驗證閘門真的有效,可在複本中改變類別庫後只重建元件、不重建呼叫端;此時摘要應不同,閘門必須拒絕該候選集合。

發布前檢查清單與決策結論

直接答案: 安全部署 DLL 的決策標準不是「有沒有 build 過」,而是「已提交來源、參考目錄、候選輸出與執行載入是否屬於同一批可證明的版本集合」;若答案不完整,就應停止搬運並重新產生整批產物。

發布前可逐項確認:

  • 已列出所有內部 DLL 與直接、傳遞相依,不只處理最先報錯的檔案。
  • 呼叫端以已提交的 DLL 參考形式完成 CI 等價 Release 建置。
  • 來源專案的目標框架、SDK 與呼叫端相容,沒有寫死過期輸出路徑。
  • 候選輸出從空白目錄建立,沒有沿用上一批殘留。
  • 每支 DLL 都有 SHA-256、組件版本、檔案版本與來源提交識別。
  • 必要型別與成員已由本機測試工具反射驗證。
  • 來源參考目錄與候選輸出同名檔的摘要一致。
  • View、模板與其他執行期呼叫端與 DLL 同批切換。
  • 上一批完整集合仍可回復,且回復不會只替換單一檔案。

這個主題位於 BOFU 底層轉換:讀者已經有跨儲存庫 DLL 搬運或混合版本問題,需要可直接採用的驗收方法。最有價值的改變不是增加更多人工複製步驟,而是把版本集合、CI 等價建置與可觀察失敗條件寫成自動閘門。當每一批候選產物都能以清單、反射與雜湊被重建與驗證,執行期錯誤就不再只能靠事故後猜測。