「這次修正,只要換掉 DLL 就好了嗎?還是呼叫端也要重新建置?」──在維護被多個應用程式參照的共用 DLL 或 COM 元件時,每次發布都得回答這個問題。答錯的話,客戶端正在執行的舊 EXE 可能無法啟動,更糟的情況是,程式雖然照樣啟動,但計算結果卻在不知不覺間變了。
麻煩的是,這個判斷常常只憑「感覺好像有點危險」來做。但實際上,哪些變更會破壞相容性,幾乎可以機械式地判定。原生 DLL 有匯出與呼叫慣例的規則,COM 有「介面不可變」這條明文規定的鐵則1,.NET 則有 Microsoft 自身在開發 .NET 函式庫時所使用的相容性變更規則清單2。
本部落格曾在「COM / ActiveX / OCX 是什麼」中說明 COM 的基礎,並在「什麼是 COM - 為什麼 Windows COM 的設計至今依然優雅」中解說其設計理念。本文則針對 DLL・COM・.NET 組件各自整理「哪些變更會破壞呼叫端」的判斷表,並說明不得不破壞相容性時應遵循的步驟。
本文所使用的術語
要讀懂判斷表,需要一些二進位層級的術語。詳細內容會在各章節中說明,但先逐行掌握一遍,有助於閱讀後續內容。
| 術語 | 一句話說明 |
|---|---|
| ABI(Application Binary Interface) | 已編譯的二進位檔之間所遵守的約定。引數的傳遞方式、結構的記憶體配置、符號名稱的命名方式等,是機器碼層級而非原始碼層級的契約 |
| 呼叫慣例(calling convention) | ABI 的一部分。規定引數要用暫存器還是堆疊傳遞、以什麼順序傳遞,以及堆疊上的引數由呼叫端還是被呼叫端清理(__cdecl / __stdcall 等) |
| vtable(虛擬函式表) | 依固定順序排列函式指標的表格。是 COM 介面的實體,呼叫端是依「第幾個插槽」這個位置來呼叫目標方法 |
| 匯出序數(ordinal) | DLL 匯出表中分配給各函式的編號。呼叫端也可以不用函式名稱,而用這個編號來匯入 |
| IID / CLSID / ProgID | 依序為 COM 介面(契約)的識別碼、實作類別的識別碼、對應到 CLSID 的人類可讀別名(4.1 節) |
| 強式名稱(strong name) | 以「名稱 + 版本 + 文化特性 + 公開金鑰語彙基元」加上簽章,唯一識別 .NET 組件的機制 |
| 繫結重新導向(binding redirect) | 在 .NET Framework 中,將呼叫端要求的組件版本,重新導向到實際要載入的另一個版本的組態檔設定 |
1. 先講結論
- 相容性有二進位相容(不重新建置即可運作)・原始碼相容(重新建置後可運作)・行為相容(行為不變)三層。「不需重新建置=安全」並不成立,必須連行為相容都納入判斷。3
- 原生 DLL 的基本原則是,「新增匯出是安全的,變更・刪除既有匯出是破壞性的」。函式簽章・呼叫慣例・結構配置本身就是二進位契約。
- COM 的介面一旦公開就不可變(immutable)。公開後新增・刪除・重新排列方法都違反規格,變更應以具有新 IID 的新介面(IFoo→IFoo2)方式新增。41
- VB6/VBA 用戶端採用事前繫結,會將 vtable 上的位置燒入程式中,因此是最容易因介面版面配置變更而毀壞的呼叫端。
- 在 .NET 中,「public API 的哪些變更算破壞性」已由 Microsoft 公開為相容性變更規則,除了方法刪除或簽章變更之外,加上 virtual(虛擬化)乃至變更參數名稱都被歸類為破壞性變更。2
- 語意化版本是「有破壞性變更就提升主要版本」的規約,但唯有宣告清楚何謂破壞性變更,它才會真正發揮作用。5 本文的判斷表可作為那個定義使用。
- 不得不破壞相容性時,應依照新舊並行提供 → 設定棄用期間 → 盤點呼叫端 → 廢止的順序進行。原則是不要突然直接替換。
2. 相容性的三層 ── 誰會在何時遭殃
一言以蔽之的「向後相容性」,實際上分為三層。在 .NET 的官方文件中,破壞性變更也是依原始碼相容・二進位相容・行為相容三個觀點來分類的。3
| 層 | 意義 | 一旦破壞會發生什麼 | 主要受影響的人 |
|---|---|---|---|
| 二進位相容 | 呼叫端不重新建置即可在新 DLL 上運作 | 啟動時找不到進入點、執行期發生 MissingMethodException、當機 |
客戶端正在執行的舊 EXE、無法重新建置的他公司應用程式 |
| 原始碼相容 | 呼叫端重新建置後可運作 | 下次建置時發生編譯錯誤 | 公司內的其他團隊、持有原始碼的開發者 |
| 行為相容 | 作為規格的行為不變 | 不會發生錯誤,但結果・時序・例外種類卻改變了 | 終端使用者(以及所有進行故障調查的人) |
若以巢狀結構來理解這三層,關係會更容易掌握。
[行為相容] 行為不變 ← 最外層
└─[原始碼相容] 重新建置後可運作
└─[二進位相容] 不重新建置即可運作 ← 最內層
越內層條件越嚴格,守住內層,呼叫端所需的工夫就越小。這三層中最重要的一點是,即使內層安然無恙,外層仍可能被破壞。例如變更既有函式回傳值意義的修改,可以在保住二進位相容與原始碼相容的同時,只破壞行為相容。這類變更既不會出現連結錯誤,也不會出現編譯錯誤,因此是判斷表中最容易被忽略的一行。
反過來說,如果呼叫端全員都持有原始碼、可以同時重新建置(例如單一儲存庫的公司內部系統),那麼需要守住的就只有原始碼相容與行為相容,二進位相容可以排除在要求之外。「自家 DLL 的呼叫端裡,是否存在無法重新建置的二進位檔」,是閱讀判斷表時的第一個分歧點。
3. 原生 DLL(C/C++)的相容性判斷表
原生 DLL 的相容性,取決於匯出表、呼叫慣例,以及記憶體版面配置。DLL 如何被搜尋・載入,在「Windows DLL 名稱解析機制」中已說明過,而載入成功後的相容性可用下表判斷。
| 變更內容 | 二進位相容 | 備註 |
|---|---|---|
| 新增匯出函式 | 不會破壞 | 最安全的擴充手段。但若依賴 .def 檔的隱含序數,既有序數可能因新增位置而被重新編號,若有依序數連結的用戶端,應明確固定既有序數再加到尾端 |
| 刪除・改名匯出函式 | 會破壞 | 匯入解析失敗,在載入時或 GetProcAddress 發生錯誤 |
| 既有函式的簽章變更(引數新增・刪除・型別變更、回傳值型別變更) | 會破壞 | 堆疊・暫存器的傳遞方式對不上。回傳值也一樣,若從整數(RAX)改為浮點數(XMM0),呼叫端就會用舊的 ABI 讀到垃圾值。有時不會出錯,而是直接失控 |
變更呼叫慣例(__cdecl↔__stdcall) |
會破壞(32bit) | 在 x86 中堆疊善後的責任會互換,可能導致堆疊破壞。x64 的呼叫慣例只有一種,這些指定實質上會被忽略,所以此行僅適用於 32bit DLL |
| 變更匯出序數(ordinal) | 有條件會破壞 | 依序數連結的呼叫端會呼叫到別的函式。若只依名稱連結則不受影響 |
| 對呼叫端配置的結構新增成員 | 會破壞 | 舊呼叫端仍會配置較小的結構並傳入(可用後述的 cbSize 慣例緩解) |
公開結構的封裝・對齊變更(#pragma pack、/Zp、工具鏈變更) |
會破壞 | 即使一個成員都沒動,既有成員的偏移與整體大小仍會改變。cbSize 也無法挽救位置偏移,應在公開標頭中明確固定封裝方式 |
| 僅由 DLL 端配置・釋放的結構內部變更 | 不會破壞 | 若設計上只向外公開指標(控制代碼),內部可自由變更 |
| 回傳值・錯誤碼意義變更 | 不會破壞(但行為相容會被破壞) | 連結成功,行為卻改變了,是最晚被發現的模式 |
| 直接匯出 C++ 類別時新增資料成員・虛擬函式 | 會破壞 | 物件大小或 vtable 版面配置會改變。若只新增非虛擬成員函式,版面配置不變,不會直接破壞既有用戶端,但直接匯出 C++ 類別本身就沒有編譯器間相容性,每次都要面對這種判斷,這一點本身就說明它作為 ABI 是脆弱的 |
上表是用來決定「接下來要變更什麼」,但在實務現場,更常見的是反過來查——也就是「客戶端出現的這個症狀,是由哪一行的變更引起的」。以下整理各行實際會以什麼方式表面化。
| 現場出現的症狀 | 應懷疑的行 |
|---|---|
啟動時出現「找不到程序進入點」之類的對話框,應用程式完全無法啟動。NTSTATUS 為 0xC0000139(STATUS_ENTRYPOINT_NOT_FOUND,原文為 “The procedure entry point %hs could not be located in the dynamic link library %hs.”)6 |
刪除・改名匯出函式。在 C++ 中,若因變更簽章導致名稱修飾(mangled name)改變,結果變成「換了個名字」的情況,也會出現相同症狀 |
GetProcAddress 回傳 NULL,應用程式顯示自訂的錯誤訊息 |
同上。在延遲載入・動態載入的呼叫端,會以這種形式表面化 |
根本找不到 DLL,無法啟動。NTSTATUS 為 0xC0000135(STATUS_DLL_NOT_FOUND)6 |
不是相容性問題,而是配置・搜尋順序的問題。在查判斷表之前,應先懷疑 DLL 的搜尋路徑 |
函式回傳後立刻當機。在偵錯組建(/RTCs 或 /RTC1)中,執行期檢查會將其捕捉為堆疊指標破壞 |
呼叫慣例變更(32bit)。Microsoft 也明確指出,堆疊指標破壞可能是由呼叫慣例不一致所引起7。在發行組建中不會被偵測到,而是在完全不同的地方當機 |
| 沒有出現錯誤,但結構中特定成員的值卻變得怪異。偶爾會發生緩衝區溢位 | 結構新增成員,或封裝・對齊變更。若未導入 cbSize 慣例(3.1 節),很難釐清原因 |
.NET 呼叫端出現 MissingMethodException |
.NET 端的成員刪除・改名・簽章變更(第 5 章) |
| 完全不出現錯誤,但報表或彙總的數值卻變了 | 回傳值・錯誤碼意義變更。屬於只破壞行為相容的狀態,最晚才會被發現 |
從這張表得出的設計方針自古以來未曾改變。將邊界限定在 C ABI(extern "C" 的函式與單純的結構),擴充一律透過新增函式來進行。即使是用 C# 製作原生 DLL,道理也相同,在「如何用 Native AOT 把 C# 做成原生 DLL - 從 C/C++ 呼叫的方法」中談到的匯出面,同樣依此表管理。
3.1 cbSize 慣例 ── 讓結構可擴充的 Win32 智慧
「結構新增成員會破壞相容性」的經典對策,是 Win32 慣用的在結構開頭放入大小欄位的做法。呼叫端在 cbSize 中填入自己編譯時所知道的結構大小後傳入,DLL 端則透過這個大小,判斷「這個呼叫端認得哪一世代的結構」。
typedef struct KS_CONFIG {
DWORD cbSize; // 呼叫端設定 sizeof(KS_CONFIG)
DWORD dwMode;
DWORD dwTimeout;
// 未來的成員務必加在尾端
} KS_CONFIG;
// DLL 端: 用 cbSize 判斷世代,對舊呼叫端以預設值處理
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
timeout = pConfig->dwTimeout; // 新呼叫端
} else {
timeout = DEFAULT_TIMEOUT; // 舊呼叫端
}
判定時之所以不使用 sizeof(KS_CONFIG),而是使用 FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD),是為了以尾端成員為單位,判斷「是否存在到 dwTimeout 為止的世代」。若用 sizeof 判定,一旦未來又在尾端新增成員,條件就會立刻變嚴格,導致「認得 dwTimeout 但不認得新成員」的世代,也會被誤判為舊呼叫端。若每個成員都以這種方式判定,每次擴充時就不必重寫既有的判定邏輯。
實際上,Windows API 的 NOTIFYICONDATA 結構正是以這種方式管理世代的,官方文件也記載了透過設定 cbSize 的值,可以與舊版 Shell32.dll 保持相容。8 自製 DLL 的公開結構,若從最初的版本就放入 cbSize,後續的擴充就能從「破壞性變更」轉移到「判斷表的安全側」。不過新增成員務必加在尾端,既有成員的型別・順序變更依然禁止。還有一點,對於用於輸出的結構,DLL 端的責任會增加。寫入・初始化必須侷限在收到的 cbSize 範圍內。若無條件寫入新版 sizeof 的全部大小,會超出舊呼叫端所配置的較小緩衝區,反而由 DLL 端造成了這個慣例原本應該防止的破壞。
4. COM 介面的鐵則 ── 一旦公開就禁止變更
COM 是對這個問題給出最明快答案的技術。依 COM 規格,介面必須遵守以下規則。
- 介面擁有唯一的 IID(介面識別碼)。1
- 介面不可變(immutable)。一旦建立・公開,定義的任何部分都不得變更。1
- 新增・刪除方法或變更語意,並不是「舊介面的新版本」,而是意味著要建立擁有另一個 IID 的新介面。4
之所以如此嚴格,是因為 COM 介面的實體是 vtable(函式指標表)這種二進位版面配置。C++ 或 VB6 的用戶端會在編譯時把「第 3 個插槽是 GetName」這種位置燒入程式中。若在公開後插入方法,舊用戶端會在毫無錯誤的情況下呼叫到別的方法。正因如此,COM 從規格上徹底消除了「變更」這個操作本身,轉而準備了以下擴充步驟。
// v1: 已公開,不再做任何變更
[object, uuid(1111....)]
interface ICalc : IUnknown {
HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};
// v2: 擁有新 IID 的新介面。繼承 ICalc 並擴充
[object, uuid(2222....)]
interface ICalc2 : ICalc {
HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};
上面的 IDL 將 uuid 省略成 1111.... 這樣的示意範例,原樣是無法編譯的。實務上應使用 Visual Studio 附帶的 GUID 建立工具(guidgen)或 uuidgen 命令產生完整的 GUID。若對兩個不同的介面使用相同的 GUID,就無法區分契約,因此每次新增介面都務必重新產生。
實作類別(coclass)同時實作 ICalc 與 ICalc2,舊用戶端一如既往地使用 ICalc,新用戶端則透過 QueryInterface 要求 ICalc2 來使用。在 RPC/COM 的官方版本控管理論中也整理指出,「繼承舊介面的新介面相當於次要版本升級,若要變更既有方法或型別,則需要不繼承的全新介面(相當於主要版本升級)」。9 呼叫端能透過 QueryInterface 在執行期安全確認對方的支援狀況,正是這套方式得以成立的關鍵。這個機制在設計上的美感,已在「什麼是 COM」中深入探討過。
4.1 CLSID・ProgID・IID 的角色分工
思考 COM 的版本控管時,應將三種識別碼的角色分開來想。10
- IID 是介面(契約)的識別碼。契約一旦改變,必定要換成新的 IID。
- CLSID 是實作類別的識別碼。只要維持既有介面的契約不變,在相同 CLSID 下替換實作是自由的。
- ProgID 是人類可讀的別名(
KomuraSoft.Calc.1),用來在登錄檔中查詢對應的 CLSID。慣例上會同時記載帶版本編號的 ProgID,以及始終指向最新版本的版本無關 ProgID(KomuraSoft.Calc),後者透過CurVer對應到最新版本。10
也就是說,「實作的版本升級」屬於 CLSID 與 ProgID 的範疇,「契約的變更」屬於 IID 的範疇,兩者不可混為一談。若想避免登錄檔登錄本身,可選擇的方案在「什麼是 Reg-Free COM」中有說明。
4.2 VB6/VBA 用戶端特別容易毀壞的原因
當 VB6 或 VBA 透過參照設定(事前繫結)使用 COM 元件時,會在編譯時讀取型別庫來解析呼叫。事前繫結能讓 IntelliSense 與型別檢查生效,執行也較快,是官方推薦的形式11,但代價是與型別庫的版面配置強烈耦合。不僅 COM 介面的 vtable 一旦改變會出問題,就連型別庫上的定義有所變動,也會以「開啟專案後發現參照壞了」「執行時出現錯誤 430/438」之類的形式表面化。
因此,對於呼叫端存在 VB6/VBA/Excel 巨集的元件,必須最嚴格地遵守介面不可變的鐵則。型別庫也有版本(major.minor),契約增加時應提升版本並加以管理。從 .NET 端向 VBA 帶型別公開時的型別庫產生方式,在「從 VBA 帶型別使用 .NET 8 的 DLL - 以 COM 發布 + dscom 產生 TLB」中有說明。另一方面,只用 CreateObject 的延遲繫結用戶端,由於是依名稱解析,對版面配置變更較有抵抗力,但受方法語意變更(行為相容)的影響則是一樣的。
5. .NET 組件的相容性 ── 用官方規則機械式判定
.NET 公開了 Microsoft 在開發 .NET 函式庫自身時所使用的「相容性變更規則」,將變更分類為允許(✔️)・禁止(❌)・需判斷(❓)。2 文件明確指出可直接採用作為自家函式庫的判斷基準,以下摘錄其中主要幾行。
| 對 public API 的變更 | 判定 | 補充 |
|---|---|---|
| 新增方法・型別・成員 | ✔️ 原則安全 | 但若新增會改變既有多載解析結果,需留意。對公開 struct 新增執行個體欄位屬於例外,因大小・版面配置會改變,可能破壞互通性或使用 unsafe 的呼叫端 |
| 刪除・改名 public 型別・成員 | ❌ 破壞性 | 執行期會以 MissingMethodException 等方式失敗 |
| 簽章變更(引數新增・刪除・順序・型別、回傳值型別) | ❌ 破壞性 | 二進位與原始碼兩者皆會破壞 |
| 變更參數名稱 | ❌ 破壞性 | 會破壞 C# 的具名引數與 VB 的延遲繫結。容易被忽略 |
| 對成員新增 virtual | ❌ 破壞性 | 「新增所以安全」看似成立,實則是典型陷阱。可能發生呼叫 IL(call/callvirt)不一致的情況 |
| 刪除 virtual、將虛擬成員改為 abstract | ❌ 破壞性 | 派生類別的覆寫會被破壞 |
| 對非 sealed 的公開型別新增抽象成員 | ❌ 破壞性 | 既有的派生類別沒有實作 |
| 型別改為 sealed | ❌ 破壞性 | 既有的派生類別會無法編譯 |
| 對介面新增成員 | ❓ 需判斷 | 附上預設實作(DIM)可避免破壞既有的實作類別,但條件不少(詳見下方) |
| 變更常數・列舉值的值、改名或刪除列舉成員 | ❌ 破壞性 | 值會在編譯時就嵌入呼叫端 |
| 改為擲回衍生程度更高的例外 | ✔️ 允許 | 因為既有的 catch 仍能正常運作 |
| 在既有程式碼路徑擲回新種類的例外 | ❌ 破壞性 | 僅在新的參數值情況下擲回則可行 |
在這張表中唯一標示 ❓(需判斷)的,是「對介面新增成員」,這也是實務上最令人猶豫的一行。若附上預設實作(DIM: Default Interface Members,預設介面成員),就能在不讓既有實作類別出現實作缺漏的情況下增加成員,但官方規則列出的條件如下。2
- 使用端的最低需求會提升到 .NET Core 3.0 / C# 8.0。由於 DIM 是在此版本引入的,一旦新增預設實作,使用更舊執行環境的使用者就會被排除在外。.NET Framework 不在支援範圍內,因此只要函式庫還留有一個以 .NET Framework 為基礎的用戶端,這個緩解手段就無法使用。
- 有些語言不支援 DIM。由於 .NET 會被多種語言使用,對於以 C# 以外語言實作的介面,無法依賴預設實作。
- 有些情況下執行環境無法決定該呼叫哪個預設實作。在牽涉多個介面的組合中,預設實作的解析可能變得模稜兩可。
- 從 C# 13 開始,對
ref struct所實作的介面新增預設執行個體成員屬於原始碼破壞性變更。因為ref struct既不能裝箱,也無法轉換為介面型別,無法回退到預設實作,執行個體成員必須明確實作。
另一方面,新增靜態、非 abstract、非 virtual 的成員則是允許的。2 若「想新增成員,但使用端仍留有 .NET Framework」,不觸碰介面本身,改用第 4 章 COM 的相同思路新增新介面,或先評估能否用擴充方法替代,會是比較安全的做法。
雖然不如 COM「介面不可變」那麼單純,但思路是一樣的。公開 API 是契約,允許對契約新增內容,但不允許變更既有契約。而虛擬方法或參數名稱這類「看似安全的變更」被歸類為破壞性,正是應該依表判斷、而非憑感覺判斷的理由。
5.1 強式名稱與三個版本編號
.NET 組件有多個版本編號,角色各不相同。12
- AssemblyVersion:執行環境用來識別・載入組件的唯一版本。對具強式名稱的組件而言,.NET Framework 的 CLR 會要求嚴格一致,因此每次提升版本都需要呼叫端進行繫結重新導向(.NET/.NET Core 則會自動接受較高版本)。官方指引建議為了減少重新導向,只將主要版本反映到 AssemblyVersion。
- FileVersion(AssemblyFileVersion):只會在檔案總管的內容中看到,不影響執行環境的行為。建議用來填入 CI 的建置編號。
- InformationalVersion:給人閱讀的自由字串。用於記錄 semver 格式的套件版本或原始碼的 commit hash。
也就是說,在實務上,「相容性的宣告以套件/產品版本(semver)進行,AssemblyVersion 只反映主要版本,FileVersion 用來追蹤建置」這種三層結構較容易處理。
6. 版本編號的訂定方式 ── semver 要有「定義」才會發揮作用
語意化版本(semver)的要點可以用三行說完。進行不相容的變更時提升 MAJOR,向後相容的功能新增提升 MINOR,向後相容的錯誤修正提升 PATCH。5
容易被忽略的是,semver 規格的第一項要求是「使用 semver 的軟體必須宣告公開 API」。5 若未宣告什麼是公開 API,就不存在判定「不相容變更」的基準,是否該提升主要版本,就會憑負責人的心情決定。許多 semver 沒有發揮作用的現場,問題不在版本編號的訂法,而是省略了這項宣告。
在公司內部發佈的 DLL 上,現實可行的運作方式如下。
- 宣告公開 API 的範圍 ── 原生 DLL 為匯出函式與公開標頭,COM 為 IDL/型別庫,.NET 為 public 型別・成員。明確標示「除此以外皆屬內部實作,可能不預告即變更」。
- 採用破壞性變更的定義 ── 將本文第 3、5 章的判斷表,以及 .NET 的變更規則2作為「自家公司的定義」放入儲存庫。
- 將判定自動化 ── 若是 .NET,可用 Package Validation / ApiCompat 工具,機械式檢查與前一版本的二進位相容性。13 可排除審查時「大概沒問題」的判斷。
- 在發布說明中設置相容性欄位 ── 每次都明確標示「不需重新建置/建議重新建置/含破壞性變更」三選一。這是一套在被問「只換掉就好了嗎?」之前,就先用文件給出答案的機制。
7. 不得不破壞相容性時的步驟
當判斷表判定為「破壞性」的變更無論如何都必須進行時,不要直接替換,而應以並行提供的方式推進。
- 新舊並行提供 ── COM 的話,新增
IFoo2並保留IFoo(第 4 章)。原生 DLL 的話,新增函式(FooEx),或並存一個另取名稱的新 DLL。.NET 的話,以提升主要版本的新套件形式發布,舊主要版本則僅持續進行錯誤修正。 - 設定棄用期間 ── .NET 可用
[Obsolete]屬性在編譯時發出警告。原生/COM 則以標頭中的註解與發布說明宣告,並明確標示預定廢止日期。重點在於明確標出日期,而不是「總有一天會刪除」。 - 盤點呼叫端 ── 透過公司內部原始碼搜尋、安裝程式的散佈紀錄,COM 則從登錄檔的參照狀況,列出「還有誰在呼叫舊 API」。若因此發現無法重新建置的二進位檔(離職者留下的工具、他公司應用程式),就針對那部分延長舊 API 的壽命,或用包裝層搭橋銜接。
- 刪除舊 API ── 在盤點確認呼叫端歸零後才刪除,並提升主要版本。
這套步驟是有成本的。正因如此,反過來說,在最初公開時就意識到判斷表、把 API 設計得小而精簡(不公開就不會產生相容性義務),才是最大的相容性對策。
8. 總結
- 相容性應以二進位・原始碼・行為三層來思考。即使不需重新建置,行為相容仍可能被破壞。3
- 原生 DLL 是「新增是安全的,變更既有匯出・簽章・結構版面配置是破壞性的」。應在結構中放入
cbSize,預留擴充空間。8 - COM 介面一旦公開就不可變。變更應以新 IID 的新介面(IFoo2)方式新增,並透過
QueryInterface加以判別。149 若存在事前繫結的 VB6/VBA 用戶端,尤須嚴格遵守。 - .NET 可用官方的相容性變更規則機械式判定。須留意虛擬化・參數名稱變更・改為 sealed 等「看似安全的變更」被歸類為破壞性的情況。2
- AssemblyVersion 只反映主要版本・FileVersion 追蹤建置・以 semver 宣告相容性這種三層結構是較為現實的做法。12
- semver 要宣告公開 API 與破壞性變更的定義才會真正發揮作用。5 應將判斷表當作定義來採用,並以 Package Validation 等工具自動檢查。13
- 要破壞相容性時,依並行提供 → 棄用期間 → 盤點 → 刪除的順序進行。不要直接替換,才能守護客戶端那些老舊的 EXE。
相關文章
- COM / ActiveX / OCX 是什麼 - 差異與關係一次整理
- 什麼是 COM - 為什麼 Windows COM 的設計至今依然優雅
- 從 VBA 帶型別使用 .NET 8 的 DLL - 以 COM 發布 + dscom 產生 TLB
- Windows 的 DLL 名稱解析機制 - 以實務角度整理搜尋順序、Known DLLs、API set、SxS
- 什麼是 Reg-Free COM - 免註冊使用 COM 的機制,以及合用與不合用的情境
- 用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫
相關諮詢領域
合同會社小村軟體承接被其他系統參照的 DLL・COM 元件・.NET 函式庫的相容性設計、公開 API 的盤點與版本控管方針制定,以及不破壞既有用戶端的擴充(IFoo2 方式・並行提供)的設計與實作。
參考連結
-
Microsoft Learn, Interface Design Rules。關於 COM 物件所實作的介面必須擁有唯一的 IID,以及建立・公開後不得變更定義的任何部分(不可變)。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Change rules for compatibility (.NET)。關於 .NET 的 API 變更被分類為允許・禁止・需判斷,public 型別・成員的刪除或改名、簽章變更、參數名稱變更、virtual 的新增・刪除、改為 sealed、常數・列舉值變更等屬於禁止(破壞性),對介面新增成員屬於需判斷,以及函式庫開發者可將其作為自身函式庫的評估基準使用。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Breaking changes (.NET library guidance)。關於破壞性變更被分類為原始碼破壞・行為破壞・二進位破壞,以及在二進位破壞的情況下,針對舊版本編譯的組件會在執行期以 MissingMethodException 等方式失敗。 ↩ ↩2 ↩3
-
Microsoft Learn, Interface Pointers and Interfaces。關於 COM 介面不可變,新增・刪除方法或變更語意不是舊介面的新版本,而是意味著要建立新介面,以及 IID 唯一定義契約。 ↩ ↩2 ↩3
-
semver.org, Semantic Versioning 2.0.0。關於不相容的 API 變更提升 MAJOR,向後相容的功能新增提升 MINOR,向後相容的錯誤修正提升 PATCH,使用 semver 的軟體必須宣告公開 API,以及對公開 API 的不向後相容變更必須提升 MAJOR 版本。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, MS-ERREF 2.3.1 NTSTATUS Values。關於
STATUS_ENTRYPOINT_NOT_FOUND(0xC0000139)的原文為「The procedure entry point %hs could not be located in the dynamic link library %hs.」,STATUS_DLL_NOT_FOUND(0xC0000135)的原文為「This application has failed to start because %hs was not found.」。 ↩ ↩2 -
Microsoft Learn, /RTC (Run-time error checks)。關於
/RTCs(以及/RTC1)會進行堆疊指標檢查以偵測堆疊指標破壞,該破壞可能因呼叫慣例不一致(例如透過__cdecl的函式指標呼叫 DLL 以__stdcall匯出的函式)而發生,以及/RTC無法用於發行(最佳化)組建。 ↩ -
Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h)。關於 cbSize 成員應設定結構大小,結構隨世代不斷擴充,以及透過為 cbSize 設定適當的值,可在與舊版 Shell32.dll 保持相容的情況下使用。 ↩ ↩2
-
Microsoft Learn, The Versioning Theory for RPC and COM。關於在 COM 中,為擴充功能建立新介面是最佳做法,繼承舊介面的新介面相當於次要版本升級,變更既有方法或型別則需要不繼承的全新介面(相當於主要版本升級),以及可用 QueryInterface 確認支援狀況。 ↩ ↩2
-
Microsoft Learn, COM Registry Keys。關於 CLSID 是識別 COM 類別的 GUID,ProgID 是將人類可讀字串對應到 CLSID 但不保證唯一性,版本無關 ProgID 透過 CurVer 對應到最新版本的類別,以及 Interface 機碼登錄 IID。 ↩ ↩2
-
Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project)。關於 VBA 中建議使用參照設定的事前繫結,延遲繫結(CreateObject/ProgID)在撰寫程式碼時看不到成員、執行效能也較差,以及事前繫結需要對目標物件庫進行參照設定。 ↩
-
Microsoft Learn, Versioning (.NET library guidance)。關於 AssemblyVersion 用於執行環境的載入,.NET Framework 中具強式名稱時要求嚴格一致,建議 AssemblyVersion 只包含主要版本,FileVersion 僅用於 Windows 上的顯示、不影響執行期行為,InformationalVersion 用於記錄額外的版本資訊,以及 NuGet 套件版本建議使用 semver 2.0.0。 ↩ ↩2
-
Microsoft Learn, NuGet package compatibility rules。關於應避免二進位破壞性變更,可用 Package Validation 或 ApiCompat 工具自動偵測與基準版本的相容性,以及不得在發布之間調低 AssemblyVersion。 ↩ ↩2
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
為沒有測試的遺留業務應用程式安全地動手修改 ── 特性化測試與重構的實踐
為了在沒有測試的業務應用程式上安全地進行修改,本文以 C# 為例,說明固定目前行為的特性化測試(黃金主檔法)步驟、建立接縫(seam)的方法,以及不將重構與功能新增混在一起的運用規則。
VB6 應用程式能用到什麼時候 ── 執行環境的支援現況與務實的 .NET 遷移做法
VB6 應用程式究竟能用到什麼時候?本文整理 VB6 執行環境的支援政策(Windows 11 也在支援範圍內)與 IDE 支援早已終止這種不對稱現況,並以實務指南的形式,說明全面重寫、自動轉換、階段性遷移的判斷表、遷移前的資產盤點、VB6 與 .NET 的不相容之處,以及...
Windows 的行程間通訊該怎麼選 ── 具名管道 / TCP / gRPC / 共享記憶體 / COM 判斷表
整理 Windows 應用程式之間該如何選擇溝通方式。以判斷表整理具名管道、本機 TCP、gRPC、共享記憶體、檔案協作、COM 各自的強項與陷阱,並從實務角度說明 UI+服務分離・32bit/64bit 橋接・權限邊界等典型架構,以及具名管道的實作範例。
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
PowerShell 呼叫 COM 與 .NET 的實戰 ── 一口氣擴大腳本能觸及的範圍
從 PowerShell 呼叫 .NET 類別的方法、透過 Add-Type 組入 C# 與 Win32 API、COM 操作、Excel 的處理程序殘留與後續處理、Office 無人執行不受支援的原因,到 5.1 與 7 的差異,皆以實務角度解說。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
ActiveX 遷移
整理保留、包裝或替換 COM / ActiveX / OCX 資產的階段性判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
既有資產活用 & 遷移支援
在持續活用 COM / ActiveX / OCX 資產、原生程式碼與 32 位元相依的同時,協助規劃階段性的遷移。
常見問題
整理諮詢這個主題時常見的問題。
- 只是在 DLL 中新增函式的話,呼叫端就不需要重新建置嗎?
- 如果只是新增匯出函式,原則上既有的呼叫端不需修改就能繼續運作。因為只要不變更既有函式的名稱・簽章・呼叫慣例・匯出序數,匯入解析就會和以前一樣成立。不過,如果在呼叫端配置並傳入的結構中新增了成員,或者變更了既有函式回傳值・錯誤碼的意義,即使不重新建置也能執行,行為相容性仍可能被破壞。「新增函式是安全的,變更既有簽章是破壞性的」是基本原則。
- 為什麼不能在 COM 介面公開後才追加方法?
- 因為 COM 規格規定,介面一旦公開就必須維持不可變(immutable)。介面的實體是 vtable(函式指標陣列)這種二進位版面配置的契約,若插入・刪除・重新排列方法,舊有二進位檔就會在編譯時燒入的位置呼叫到另一個方法。就算是加到尾端,雖然既有插槽的位置不會改變,但這次換成新用戶端會把舊元件當成「應該已經有新增方法」的實作來使用,呼叫到不存在的插槽而發生事故,因此即使是新增,只要維持相同的 IID 就依然不被允許。若要增加功能,應新增一個具有新 IID 的新介面(IFoo2),既有的 IFoo 則原封不動地保留。呼叫端可以用 QueryInterface 安全地判斷對方支援新舊哪一種。
- .NET 的 AssemblyVersion・FileVersion・InformationalVersion 該如何區分使用?
- AssemblyVersion 是執行環境用來識別・載入組件的唯一版本,在具強式名稱的情況下,.NET Framework 會要求嚴格一致,因此每次提升版本都需要繫結重新導向。因此官方指引建議只反映主要版本號。FileVersion 只會顯示在檔案總管的內容中,不影響執行環境的行為,適合填入 CI 的建置編號等資訊。InformationalVersion 則是給人閱讀的自由字串,用來記錄 semver 格式的版本或 commit hash。
- 導入語意化版本(semver)就能解決相容性問題嗎?
- 光靠 semver 無法解決問題。semver 是「進行不向後相容的變更時就提升主要版本」這樣的規約,但其前提是要求先宣告「什麼是公開 API、什麼算破壞性變更」。若沒有這個定義就只加上版本編號,判斷基準會因人而異,無法發揮作用。原生 DLL 可採用本文判斷表這類基準,.NET 則可採用 Microsoft 的相容性變更規則,作為「自家公司對破壞性變更的定義」納入發布流程,semver 才會真正發揮意義。