添加一个字段安全,改一个默认值就破坏:判断改动算不算破坏性变更
破坏性变更的定义是:一个正确编写的调用方根本无法承受的变更。判断标准要应用在调用方身上,不是应用在 diff 上。当一个只依赖了文档说明行为的调用方,必须改动自己的代码、配置或数据才能继续正常工作时,这个变更就是破坏性的。
判定表
| 变更 | 是否破坏性 | 原因 |
|---|---|---|
| 移除或重命名一个字段、端点、开关或选项 | 是 | 正确的调用方会引用它 |
| 添加一个可选字段或一个新端点 | 否 | 现有的调用不受影响 |
| 把一个可选输入变为必填 | 是 | 之前省略它的调用现在会失败 |
| 收紧之前接受的校验规则 | 是 | 之前有效的输入现在会被拒绝 |
| 改变一个默认值 | 是 | 没有设置过它的调用方会得到新的行为 |
| 改变类型(字符串变数字,单个值变数组) | 是 | 按文档类型编写的解析器会失败 |
| 重新排列一个对象里各个键的顺序 | 否 | 除非你文档化过这个顺序 |
| 修复一个调用方依赖过的 bug | 实际上算是 | 偶然形成的契约 |
| 提高速率限制或大小上限 | 否 | 原本能正常工作的东西不会停止工作 |
| 降低速率限制或大小上限 | 是 | 原本没问题的流量现在会被限流 |
| 改变一段错误信息的措辞 | 视情况而定 | 如果你曾经文档化过它,或调用方依据它做匹配,就是破坏性的 |
不算破坏性变更的:添加一个新端点、添加一个可选的请求参数、在响应中添加一个字段、把必填输入改为可选、提高一个限制、改进一条没人依据它做匹配的错误信息。这类增量变更可以放在小版本里发布。
但增量变更在三种情况下仍然会破坏调用方:一个会拒绝未知字段的反序列化器,会在响应出现第一个新字段时失败;一个新的枚举值,会破坏所有写了穷举式 switch 的调用方;一个变大的响应,可能让调用方撞上某个他们从来不必考虑的大小限制、超时或列宽。
最容易漏掉的四类
- 偶然形成的契约。 Hyrum 定律:只要用户足够多,系统里任何一个可以被观察到的行为,最终都会被某个人依赖上。这就是「这本来只是个 bug 修复」站不住脚的原因。
- 没有伴随 schema 变化的行为变化。 字段还在,类型没变,值现在代表不同含义。
status原来只有active/inactive,现在还会返回suspended,所有写穷举switch的调用方都会挂;时间戳从本地时间切到 UTC 会破坏没读文档的人。这类变更在 OpenAPI diff 里完全看不出来。 - 收紧了的校验规则。 开始拒绝没有顶级域名的邮箱、拒绝末尾带空格的输入、拒绝超过 80 字符的名字。校验规则变化是最常见的、以「加固」名义发布出去的破坏性变更。
- 改变了的默认值。 没设置过它的人(大多数调用方)会在一行代码都没改的情况下得到新行为。默认值的变化会破坏大多数用户,正是因为他们从来没见过这个设置。
三个层次:二进制、源码、行为
- 二进制兼容:调用方不需要重新构建,换上新 DLL 就能运行。破坏后:启动时找不到入口点、运行时抛
MissingMethodException、崩溃。主要影响客户那边正在运行的旧 EXE、无法重新构建的第三方应用。 - 源代码兼容:调用方重新构建之后就能运行。破坏后:下次构建时出现编译错误。主要影响公司内部其他团队、持有源代码的开发者。
- 行为兼容:作为规范约定的行为不发生变化。破坏后:没有报错,但结果、时序、异常类型发生变化。主要影响终端用户以及所有排查故障的人。
三个层次中,即便内层保持完好,外层依然可能被破坏。一个更改现有函数返回值含义的修复,可以同时保持二进制兼容和源代码兼容,单单破坏行为兼容——这类改动既没有链接错误也没有编译错误,是最容易被忽视的。
原生 DLL(C/C++)
| 变更 | 结果 | 说明 |
|---|---|---|
| 添加导出函数 | 不会破坏 | 若依赖 .def 文件隐式序号,添加位置不同会导致序号重编号;有按序号链接的客户端时应固定现有序号再追加 |
| 删除/重命名导出函数 | 会破坏 | 导入解析失败,加载时或调用 GetProcAddress 时报错 |
| 更改现有函数签名(增删参数、改参数类型、改返回值类型) | 会破坏 | 栈与寄存器传递方式对不上;把整数(RAX)改成浮点(XMM0),调用方仍按旧 ABI 读就是垃圾数据,有时不报错直接跑飞 |
更改调用约定(__cdecl ↔ __stdcall) | 会破坏(32 位) | x86 上清理栈的责任方互换导致栈破坏;x64 只有单一调用约定,只涉及 32 位 DLL |
| 更改导出序号(ordinal) | 有条件地会破坏 | 按序号链接的调用方会调用到另一个函数 |
| 给由调用方分配的结构体添加成员 | 会破坏 | 旧调用方仍分配并传入较小版本(可用 cbSize 惯例缓解) |
更改公开结构体的打包/对齐方式(#pragma pack、/Zp) | 会破坏 | 即使一个成员没碰,偏移量和大小也会变 |
| 更改只由 DLL 一侧分配和释放的结构体内部 | 不会破坏 | —— |
| 更改返回值或错误码的含义 | 不会破坏(但会破坏行为兼容) | —— |
| 直接导出 C++ 类时添加数据成员或虚函数 | 会破坏 | 对象大小或 vtable 布局变化 |
cbSize 惯例。 在结构体开头放一个表示大小的字段。调用方把编译时知道的 sizeof 填进 cbSize 传入,DLL 一侧通过这个大小判断「这个调用方知道的是哪一代结构体」。Windows API 的 NOTIFYICONDATA 正是这样管理世代的。新增成员必须放在末尾;对输出结构体,DLL 写入和初始化必须控制在接收到的 cbSize 范围内,否则会溢出旧调用方分配的较小缓冲区。
COM:IID 一旦公开就不可变
COM 接口的铁律:接口拥有唯一 IID;接口是不可变的(immutable),一旦创建并公开,定义的任何部分都不得更改。添加/删除方法或更改语义,意味着创建一个拥有不同 IID 的新接口。
COM 接口的实体就是 vtable。C++/VB6 客户端在编译时把「第 3 个槽位是 GetName」这种位置信息烧录进去,公开后插入方法,旧客户端会在没有任何报错的情况下调用到另一个方法。
扩展方式是 IFoo → IFoo2(ICalc/ICalc2):实现类同时实现两者,旧客户端用 ICalc,新客户端通过 QueryInterface 请求 ICalc2。CLSID 是实现类标识符,IID 是契约标识符,ProgID 是别名(保留带版本号的和版本无关的,通过 CurVer 指向最新)。VB6/VBA 早期绑定与类型库布局强绑定,连类型库定义变化都会以「引用已损坏」或运行时错误 430/438 出现。
.NET 的兼容性变更规则
Microsoft 官方给出的这套判定,可以直接拿来自家库当标准:
- 添加方法、类型、成员 → 原则安全。若添加会改变现有重载解析结果则需留意;给公开 struct 添加实例字段是例外。
- 删除、重命名 public 类型或成员 → 破坏性,运行时抛
MissingMethodException。 - 更改签名(增删参数、调顺序或类型、改返回值类型)→ 破坏性。
- 更改参数名 → 破坏性。会破坏 C# 具名参数和 VB 延迟绑定,很容易被忽视。
- 给成员添加
virtual→ 破坏性。看起来像「只是添加,应该安全」,但调用用的 IL 指令call/callvirt可能不一致。 - 删除
virtual或把虚成员改abstract→ 破坏性。 - 给非 sealed 公开类型添加抽象成员 → 破坏性。
- 把类型改为
sealed→ 破坏性。 - 给接口添加成员 → 需要判断(默认实现 DIM 可缓解,但有语言与运行时限制)。
- 常量、枚举值取值变更,以及枚举成员重命名或删除 → 破坏性(值在编译时嵌入调用方代码)。
- 改为抛派生程度更高的异常 → 允许(现有
catch仍工作)。 - 在现有代码路径上抛新种类的异常 → 破坏性。
AssemblyVersion 与 FileVersion。 AssemblyVersion 是运行时用来识别和加载的版本号,.NET Framework 强名称要求严格匹配,需要绑定重定向,官方建议只在 AssemblyVersion 体现主版本号。FileVersion 只显示在资源管理器里,不影响运行时行为,推荐填 CI 构建编号。InformationalVersion 面向人类,用来记录 semver 或提交哈希。实务上分三层:用包/产品版本(semver)声明兼容性,AssemblyVersion 只体现主版本号,FileVersion 追踪构建。
semver 只有在声明了公开 API 之后才生效
semver 规范第一条要求就是「使用 semver 的软件必须声明自己的公开 API」。没声明什么是公开 API,「不兼容的改动」就没有判定标准。
公司内部分发 DLL 可以这样运作:声明公开 API 范围(原生 DLL = 导出函数与公开头文件,COM = IDL/类型库,.NET = public 类型与成员)→ 把上面的判断表作为「本公司的定义」放进仓库 → 用 Package Validation/ApiCompat 自动化检查二进制兼容性 → 发布说明中设置「无需重新构建/建议重新构建/包含破坏性变更」三种取值之一的兼容性栏。
发布前用 CI 比对契约
在 CI 里把 PR 上的契约和主分支契约比较,出现破坏性差异就让构建失败:
- REST(OpenAPI)→ oasdiff
- gRPC(Protobuf)→ buf breaking(可按 wire 或源码层面比较)
- GraphQL → GraphQL Inspector
- Rust crate → cargo-semver-checks
- TypeScript 包 → API Extractor
偶然契约和行为变化这两类在 schema 里看不到,得靠评审时问一句「一个正确的调用方会察觉到这个变化吗?」
提交信息里也要标记:Conventional Commits 用冒号前的 !(feat(api)!: remove the legacy export endpoint),或用 BREAKING CHANGE: 开头的页脚,对应主版本号。
发布一个破坏性变更的步骤
- 判断它到底算不算破坏性变更。 用调用方标准而不是 diff。如果两名工程师意见不一致,那它就是破坏性的。
- 给它一个版本。 semver 下破坏性变更对应主版本号。带日期或版本的 API 就进新版本,旧版本继续工作到一个确定日期。
- 在代码合并之前就写好条目:变化是什么、影响谁、他们必须做什么、截止到什么时候。四项填不全说明没准备好。
- 给出一个截止日期,而不是一个发布版本号。 「在 v5 中移除」对不追踪你发布节奏的人毫无意义。「2026 年 11 月 1 日起停止工作」对每个人都是同一个意思。
- 提供迁移方案。 把旧调用示例放在新的旁边。
- 在旧行为曾经被文档化过的每一个地方发布公告。
- 闭合这个循环。 有人要求过这个变更,就告诉他们。
一条合格的条目(收紧校验规则)长这样:
没有域名的邮箱地址将从 2026 年 11 月 1 日起被拒绝。
POST /users和PATCH /users/:id目前接受像alice@localhost这样的 email 值。从 11 月 1 日起,这些请求将返回 400invalid_email。影响所有从内部目录创建用户的集成。迁移方法:发送一个完整的合法地址,或者省略该字段,之后再设置。如果你的地址已经带有域名,就无需任何改动。
提前多久通知?足够让调用方发现并完成工作。公开 API 九十天是常见下限。一项针对 119,879 次 Maven Central 升级的研究发现,有 16.6% 违反了语义化版本控制,但只有 7.9% 的客户端项目受到了影响,因为大部分变更触及的是没有任何客户端调用过的代码。破坏与否,要在调用方那里衡量。
为什么不干脆一直避免破坏性变更?那样代价更糟。一个从不破坏任何东西的 API 会不断积累它曾经犯过的每一个错误:命名错误的字段、错误的默认值、本地时间的时间戳。每一个都会永远向每一个新调用方征税。
不得不破坏时:并行提供 → 弃用期 → 梳理调用方 → 删除
- 新旧并行提供:COM 加
IFoo2保留IFoo,原生 DLL 加FooEx或新旧 DLL 共存,.NET 升主版本发布新包、旧主版本只做缺陷修复。 - 设置弃用期:.NET 用
[Obsolete]编译警告;头文件注释和发布说明写明计划废止日期。 - 梳理调用方:源码搜索、分发记录、注册表引用。发现无法重新构建的二进制,就延长旧 API 寿命或加封装层。
- 删除旧 API 并升级主版本号。
绝不一次性直接替换。
参考
- Changeloop:破坏性变更——哪些算、哪些不算,以及如何安全发布
- KomuraSoft:DLL・COM 接口的向后兼容性
- Microsoft Learn:Change rules for compatibility、Breaking changes(.NET library guidance)(https://learn.microsoft.com)
- Semantic Versioning 2.0.0