DLL・COM 接口的向后兼容性 ── 判断哪些改动会破坏调用方的对照表

· 更新日期: · · COM, DLL, .NET, C#, C++, 向后兼容性, 版本管理, 遗留技术, 现有资产利用, 判断表

「这次修复,只要替换 DLL 就行,还是调用方也需要重新构建?」──如果你维护着被多个应用引用的共用 DLL 或 COM 组件,每次发布都会遇到这个问题。一旦答错,客户那边正在运行的旧 EXE 可能会无法启动,更糟糕的是,它照样能启动,只是计算结果在悄无声息中发生了变化。

麻烦的是,这种判断往往凭「感觉上有点危险」这种直觉来做出。实际上,哪些改动会破坏兼容性,几乎可以机械式地判定出来。原生 DLL 有关于导出和调用约定的规则,COM 有「接口不可变」这条明文规定的铁律1,.NET 则有一份 Microsoft 自己在开发 .NET 库时所使用的兼容性变更规则清单2

本博客已经在「COM / ActiveX / OCX 是什么」中讲解了 COM 的基础知识,并在「什么是 COM - 为什么 Windows COM 的设计至今依然优美」中解析了它的设计思想。本文将针对 DLL、COM、.NET 程序集这三者,分别以判断表的形式整理「哪些改动会破坏调用方」,并进一步说明在不得不破坏兼容性时应遵循的步骤。

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 名称解析机制 - 搜索顺序与 SxS」中讲解过,而加载成功之后的兼容性,可以用下表来判断。

改动内容 二进制兼容 备注
添加导出函数 不会破坏 最安全的扩展手段。不过,如果依赖 .def 文件的隐式序号,添加的位置不同,现有序号有时会被重新编号,因此如果存在按序号链接的客户端,应明确固定现有序号,再把新函数加到末尾
删除・重命名导出函数 会破坏 导入解析失败,在加载时或调用 GetProcAddress 时报错
更改现有函数签名(增删参数、更改参数类型、更改返回值类型) 会破坏 栈与寄存器的传递方式会对不上。返回值同样如此:如果把整数(RAX)改成浮点数(XMM0),调用方仍按旧 ABI 读取,读到的就是垃圾数据。有时不会报错,而是直接跑飞
更改调用约定(__cdecl__stdcall) 会破坏(32 位) 在 x86 上,清理栈的责任方会互换,导致栈被破坏。x64 只有单一的调用约定,这些指定实际上会被忽略,因此这一行只涉及 32 位 DLL
更改导出序号(ordinal) 有条件地会破坏 按序号链接的调用方会调用到另一个函数。如果全部按名称链接,则不受影响
给由调用方分配的结构体添加成员 会破坏 旧的调用方仍会分配并传入较小的版本(可以用后文介绍的 cbSize 惯例来缓解)
更改公开结构体的打包/对齐方式(#pragma pack/Zp、工具链变更) 会破坏 即使一个成员都没碰,现有成员的偏移量和整体大小也会发生变化。cbSize 也无法挽救这种位置错位,因此应在公开头文件中明确固定打包方式
更改只由 DLL 一侧分配和释放的结构体内部 不会破坏 如果设计上只把指针(句柄)暴露给外部,内部就可以自由更改
更改返回值或错误码的含义 不会破坏(但会破坏行为兼容) 链接依然成功,行为却发生了变化,是最容易被拖到很晚才发现的模式
直接导出 C++ 类时添加数据成员或虚函数 会破坏 对象大小或 vtable 布局会发生变化。如果只添加非虚成员函数,布局不变,不会直接破坏现有客户端;但直接导出 C++ 类本来就没有跨编译器兼容性,每次都要面对这种判断,本身就说明这个 ABI 很脆弱

从这张表可以得出的设计准则,多年来一直没有变化:把边界限定在 C ABI(extern "C" 函数与简单结构体)之内,靠添加函数来扩展功能。即便是从 C# 生成原生 DLL 也是同样的道理,「如何从 C/C++ 调用 C# Native AOT DLL」中处理的导出面,同样按这张表来管理。

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;      // 旧的调用方
}

实际上,Windows API 的 NOTIFYICONDATA 结构体正是用这种方式做世代管理的,官方文档也说明了通过设置合适的 cbSize 值,可以与旧版本的 Shell32.dll 保持兼容。6 如果自己开发的 DLL 从最初版本起就在公开结构体中放入 cbSize,之后的扩展就能从「破坏性变更」转移到「判断表中安全的一侧」。不过,新增成员必须放在末尾,更改现有成员的类型或顺序仍然是禁止的。还有一点:对于用作输出的结构体,DLL 一侧的责任会增加。写入和初始化必须始终控制在接收到的 cbSize 范围之内。如果无条件按新版 sizeof 的大小写入,就会溢出旧调用方分配的较小缓冲区,这样一来,DLL 反而制造出了这项惯例本应防止的破坏。

4. COM 接口的铁律 ── 一旦公开,禁止更改

COM 是对这个问题给出了最明确答案的技术。按照 COM 规范,接口遵循以下规则。

  • 接口拥有唯一的 IID(接口 ID)。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);
};

实现类(coclass)会同时实现 ICalcICalc2,旧客户端一如既往使用 ICalc,新客户端则通过 QueryInterface 请求 ICalc2 来使用。RPC/COM 的官方版本管理理论也是这样整理的:「继承自旧接口的新接口相当于次版本升级,而更改现有方法或类型则需要一个不继承旧接口的全新接口(相当于主版本升级)」。7 正是因为 QueryInterface 能让调用方在运行时安全地确认对方支持的情况,这套方式才得以成立。这一机制在设计上的优美之处,在「什么是 COM - 为什么 Windows COM 的设计至今依然优美」中已经深入探讨过。

4.1 CLSID、ProgID、IID 的分工

在考虑 COM 的版本管理时,需要把三种标识符的角色区分开来看。8

  • IID 是接口(契约)的标识符。契约一旦改变,就必须变成新的 IID。
  • CLSID 是实现类的标识符。只要遵守已公开接口的契约,就可以在保持同一个 CLSID 的情况下自由替换实现。
  • ProgID 是人类可读的别名(如 KomuraSoft.Calc.1),用于在注册表中查询对应的 CLSID。惯例是同时保留带版本号的 ProgID,以及始终指向最新版本的版本无关 ProgID(KomuraSoft.Calc),后者通过 CurVer 与最新版本相对应。8

也就是说,「实现的版本升级」属于 CLSID 和 ProgID 的范畴,而「契约的变更」属于 IID 的范畴,两者不能混为一谈。如果想完全避免注册表登记,「什么是 Reg-Free COM——无需注册使用 COM 的机制」中介绍了相应的选项。

4.2 VB6/VBA 客户端为何特别容易被破坏

当 VB6 或 VBA 通过引用设置(早期绑定)使用 COM 组件时,会在编译时读取类型库来解析调用。早期绑定能启用 IntelliSense 和类型检查,执行速度也更快,是推荐的形式,9 但代价是会与类型库的布局产生强绑定。不仅接口的 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 ❌ 破坏性 会破坏派生类中的重写(override)
给非 sealed 的公开类型添加抽象成员 ❌ 破坏性 现有的派生类没有相应的实现
把类型改为 sealed ❌ 破坏性 现有的派生类会变得无法编译
给接口添加成员 ❓ 需要判断 附加默认实现(DIM)可以缓解,但存在语言与运行时方面的限制条件
常量、枚举值的取值变更,以及枚举成员的重命名或删除 ❌ 破坏性 这些值会在编译时被嵌入到调用方代码中
改为抛出派生程度更高的异常 ✔️ 允许 因为现有的 catch 依然能够正常工作
在现有代码路径上抛出新种类的异常 ❌ 破坏性 只针对新增的参数取值抛出则是可以的

虽然不像 COM 的「接口不可变」那么简单明了,但思路是一致的:公开 API 是一份契约,可以向契约中添加内容,但不能更改已有的契约。而像虚方法、参数名这类「看起来很安全的改动」被归入破坏性一侧,恰恰说明了为什么应该依据表格而非直觉来判断。

5.1 强名称与三种版本号

.NET 程序集拥有多个版本号,各自承担不同的角色。10

  • AssemblyVersion:运行时用来识别和加载程序集的唯一版本号。对于强名称程序集,.NET Framework 的 CLR 要求严格匹配,因此每次升级都需要调用方添加绑定重定向(.NET/.NET Core 则会自动接受更高的版本)。为了减少重定向,官方指南建议只在 AssemblyVersion 中体现主版本号
  • FileVersion(AssemblyFileVersion):只会显示在资源管理器的属性中,不影响运行时行为,被推荐用来填入 CI 的构建编号。
  • InformationalVersion:面向人类的自由字符串,用于记录 semver 格式的包版本号或源代码的提交哈希。

也就是说,在实务中,「用包/产品版本(semver)来声明兼容性,AssemblyVersion 只体现主版本号,FileVersion 用来追踪构建」这种三层结构是比较好用的形式。

6. 版本号的编排方式 ── semver 只有在有了「定义」之后才能生效

语义化版本控制(semver)的要点用三行就能写完:做出不兼容的改动就升级 MAJOR,向后兼容的功能新增就升级 MINOR,向后兼容的缺陷修复就升级 PATCH5

容易被忽视的一点是,semver 规范的第一条要求就是「使用 semver 的软件必须声明自己的公开 API」。5 如果没有声明什么是公开 API,「不兼容的改动」就没有判定标准,是否应该升级主版本号,就全凭负责人当天的心情来决定。很多 semver 没有真正发挥作用的现场,问题不在于版本号的编排方式,而在于省略了这项声明。

在公司内部分发的 DLL 上,一种现实可行的运作方式如下。

  1. 声明公开 API 的范围 ── 原生 DLL 的话是导出函数与公开头文件,COM 的话是 IDL/类型库,.NET 的话是 public 类型与成员。要明确写明「除此之外的部分均属内部实现,可能在不预告的情况下发生变化」。
  2. 采用破坏性变更的定义 ── 把本文第 3 章、第 5 章的判断表,以及 .NET 的变更规则2,作为「本公司的定义」放进代码仓库。
  3. 让判定自动化 ── 如果是 .NET,可以用 Package Validation / ApiCompat 工具,机械化地检查与上一版本之间的二进制兼容性。11 这样就能排除评审时「大概没问题吧」这种凭感觉的判断。
  4. 在发布说明中设置兼容性栏 ── 每次都明确标注「无需重新构建/建议重新构建/包含破坏性变更」这三种取值之一。这是一套在被问到「只要替换就行吗?」之前,就已经用文档给出答案的机制。

7. 不得不破坏兼容性时的步骤

当判断表判定为「破坏性」的改动确实无法避免时,应采用新旧并行提供的方式来推进,而不是直接替换。

  1. 新旧并行提供 ── COM 的话就添加 IFoo2 并保留 IFoo(第 4 章)。原生 DLL 的话就添加新函数(FooEx),或者让另一个名字的新 DLL 与旧 DLL 共存。.NET 的话就以升级了主版本号的新包发布,旧的主版本只继续做缺陷修复。
  2. 设置弃用期 ── .NET 可以用 [Obsolete] 特性在编译时给出警告。原生/COM 则通过头文件注释和发布说明来声明,并明确写出计划废止的日期。要点在于给出明确的日期,而不是「以后找时间删掉」这种说法。
  3. 梳理调用方 ── 通过公司内部的源代码搜索、安装程序的分发记录,以及 COM 情况下注册表的引用状况,列出「还有谁在调用旧 API」的清单。如果在这个过程中发现了无法重新构建的二进制文件(已离职员工留下的工具、第三方应用),就针对这部分适当延长旧 API 的寿命,或者用封装层搭一座桥。
  4. 删除旧 API ── 在梳理确认调用方数量为零之后再删除,并升级主版本号。

这套流程是要付出成本的。正因如此,矛盾的是,在最初公开时就带着判断表的意识,把 API 设计得尽量小(不公开的部分就不产生兼容性义务),才是最有效的兼容性对策。

8. 总结

  • 兼容性应该按二进制、源代码、行为这三个层次来考虑。即使不需要重新构建,行为兼容依然可能被破坏。3
  • 原生 DLL 的原则是「新增是安全的,更改现有导出、签名、结构体布局则是破坏性的」。给结构体加上 cbSize,可以预留扩展空间。6
  • COM 接口一旦公开就不可变。任何改动都应以拥有新 IID 的新接口(IFoo2)的形式追加,并通过 QueryInterface 加以区分。147 如果存在使用早期绑定的 VB6/VBA 客户端,更要严格遵守这一点。
  • .NET 可以依据官方的兼容性变更规则机械式地做出判定。要特别留意虚拟化、更改参数名、sealed 化这类「看起来安全的改动」被归为破坏性变更这一点。2
  • AssemblyVersion 只体现主版本号、FileVersion 追踪构建、semver 声明兼容性,这种三层结构是比较现实可行的做法。10
  • semver 只有先声明公开 API 与破坏性变更的定义,才能真正发挥作用5 可以把判断表作为这份定义,并用 Package Validation 等工具进行自动检查。11
  • 需要破坏兼容性时,遵循并行提供 → 弃用期 → 梳理 → 删除的顺序。绝不一次性直接替换,才能保护客户那边正在运行的旧 EXE。

相关文章

相关咨询领域

合同会社小村软件(KomuraSoft LLC)处理被其他系统引用的 DLL、COM 组件、.NET 库的兼容性设计,公开 API 的梳理与版本管理方针的完善,以及不破坏现有客户端的扩展(IFoo2 模式、新旧并行提供)的设计与实现。

参考资料

  1. Microsoft Learn, Interface Design Rules。关于 COM 对象所实现的接口必须拥有唯一的 IID,以及创建并公开之后,定义的任何部分都不得更改(即不可变性)这一点。  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET)。关于 .NET 的 API 变更被分类为允许、禁止、需要判断,删除或重命名 public 类型/成员、更改签名、更改参数名、添加或删除 virtual、sealed 化、更改常量/枚举值等均被归为禁止(破坏性),给接口添加成员则需要判断,以及库的开发者可以将这些规则用作自己库的评估标准这些内容。  2 3 4 5

  3. Microsoft Learn, Breaking changes (.NET library guidance)。关于破坏性变更被分类为源代码破坏、行为破坏、二进制破坏,以及二进制破坏会导致针对旧版本编译的程序集在运行时因 MissingMethodException 等异常而失败这些内容。  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces。关于 COM 接口是不可变的,添加、删除方法或更改其语义并不意味着创建旧接口的新版本,而是意味着创建新接口,以及 IID 唯一定义契约这一点。  2 3

  5. semver.org, Semantic Versioning 2.0.0。关于做出不兼容的 API 变更时升级 MAJOR、向后兼容的功能新增升级 MINOR、向后兼容的缺陷修复升级 PATCH,使用 semver 的软件必须声明公开 API,以及对公开 API 的不兼容变更必须升级 MAJOR 版本这些内容。  2 3 4

  6. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h)。关于在 cbSize 成员中设置结构体大小,该结构体历经多代不断扩展,以及通过设置合适的 cbSize 值可以在保持兼容的同时使用旧版本 Shell32.dll 这些内容。  2

  7. Microsoft Learn, The Versioning Theory for RPC and COM。关于在 COM 中,为扩展功能而创建新接口是最佳做法,继承自旧接口的新接口相当于次版本,而更改现有方法或类型则需要一个不继承旧接口的全新接口,以及可以通过 QueryInterface 确认支持情况这些内容。  2

  8. Microsoft Learn, COM Registry Keys。关于 CLSID 是标识 COM 类的 GUID,ProgID 是将人类可读的字符串映射到 CLSID、但不保证唯一性,版本无关 ProgID 通过 CurVer 映射到最新版本的类,以及 Interface 键用于注册 IID 这些内容。  2

  9. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project)。关于在 VBA 中推荐使用基于引用设置的早期绑定,延迟绑定(CreateObject/ProgID)在编写代码时看不到成员、执行性能也较差,以及早期绑定需要对目标对象库设置引用这些内容。 

  10. Microsoft Learn, Versioning (.NET library guidance)。关于 AssemblyVersion 被运行时用于加载,在 .NET Framework 下强名称程序集要求严格匹配,建议 AssemblyVersion 中只包含主版本号,FileVersion 仅用于 Windows 显示、不影响运行时行为,InformationalVersion 用于记录附加的版本信息,以及推荐 NuGet 包版本使用 semver 2.0.0 这些内容。  2

  11. Microsoft Learn, NuGet package compatibility rules。关于应当避免二进制破坏性变更,Package Validation 与 ApiCompat 工具可以自动检测与基线版本之间的兼容性,以及 AssemblyVersion 在版本之间不得降低这些内容。  2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

如果只是给 DLL 添加一个函数,调用方就不需要重新构建了吗?
原则上,只要仅仅添加导出函数,现有的调用方就能照常运行。因为只要不改变现有函数的名称、签名、调用约定、导出序号,导入解析就会和以前一样成立。不过,如果给调用方分配并传入的结构体添加了成员,或者改变了现有函数返回值、错误码的含义,即使不重新构建也能运行,行为兼容性仍可能被破坏。基本原则是:「添加函数是安全的,更改现有签名则是破坏性的」。
为什么不能在 COM 接口发布之后再追加方法?
因为按照 COM 规范的规则,接口一旦公开就是不可变的(immutable)。接口的本质是 vtable(函数指针的排列)这一二进制布局契约,一旦插入、删除或重新排列方法,旧的二进制文件就会在编译时烧录好的位置上调用到另一个方法。即使是追加到末尾、不改变现有槽位的位置,也会出现新的问题——新客户端会把旧组件当作「应该已经具备新增方法」的实现来获取,进而调用一个实际上并不存在的槽位,因此即便保持同一个 IID,这种追加同样是不允许的。如果想增加功能,应该添加一个拥有新 IID 的新接口(IFoo2),并让现有的 IFoo 保持原样。调用方可以通过 QueryInterface 在运行时安全地判断对方支持的是新接口还是旧接口。
.NET 的 AssemblyVersion、FileVersion、InformationalVersion 应该如何区分使用?
AssemblyVersion 是运行时用来识别和加载程序集的唯一版本号,在使用强名称的情况下,.NET Framework 要求严格匹配,因此每次升级都需要添加绑定重定向(binding redirect)。正因如此,官方指南建议只在 AssemblyVersion 中体现主版本号。FileVersion 只会显示在资源管理器的属性中,不影响运行时行为,适合用来填入 CI 的构建编号之类的信息。InformationalVersion 是面向人类的自由字符串,用于记录 semver 格式的版本号或提交哈希。
只要引入语义化版本控制(semver),就能解决兼容性问题吗?
光靠 semver 并不能解决问题。semver 是「做出不兼容的改动就升级主版本号」这样一种约定,但它的前提是要求你先声明「什么是公开 API、什么算破坏性变更」。如果没有这个定义,仅仅贴上版本号,判断标准就会因人而异,难以真正发挥作用。原生 DLL 可以采用本文这样的判断表作为标准,.NET 则可以采用 Microsoft 的兼容性变更规则,将其作为「本公司对破坏性变更的定义」并纳入发布流程,semver 才能真正发挥意义。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表