编程 添加一个字段安全,改一个默认值就破坏:判断改动算不算破坏性变更

2026-10-16 00:04:48

添加一个字段安全,改一个默认值就破坏:判断改动算不算破坏性变更

破坏性变更的定义是:一个正确编写的调用方根本无法承受的变更。判断标准要应用在调用方身上,不是应用在 diff 上。当一个只依赖了文档说明行为的调用方,必须改动自己的代码、配置或数据才能继续正常工作时,这个变更就是破坏性的。

判定表

变更是否破坏性原因
移除或重命名一个字段、端点、开关或选项是正确的调用方会引用它
添加一个可选字段或一个新端点否现有的调用不受影响
把一个可选输入变为必填是之前省略它的调用现在会失败
收紧之前接受的校验规则是之前有效的输入现在会被拒绝
改变一个默认值是没有设置过它的调用方会得到新的行为
改变类型(字符串变数字,单个值变数组)是按文档类型编写的解析器会失败
重新排列一个对象里各个键的顺序否除非你文档化过这个顺序
修复一个调用方依赖过的 bug实际上算是偶然形成的契约
提高速率限制或大小上限否原本能正常工作的东西不会停止工作
降低速率限制或大小上限是原本没问题的流量现在会被限流
改变一段错误信息的措辞视情况而定如果你曾经文档化过它,或调用方依据它做匹配,就是破坏性的

不算破坏性变更的:添加一个新端点、添加一个可选的请求参数、在响应中添加一个字段、把必填输入改为可选、提高一个限制、改进一条没人依据它做匹配的错误信息。这类增量变更可以放在小版本里发布。

但增量变更在三种情况下仍然会破坏调用方:一个会拒绝未知字段的反序列化器,会在响应出现第一个新字段时失败;一个新的枚举值,会破坏所有写了穷举式 switch 的调用方;一个变大的响应,可能让调用方撞上某个他们从来不必考虑的大小限制、超时或列宽。

最容易漏掉的四类

  1. 偶然形成的契约。 Hyrum 定律:只要用户足够多,系统里任何一个可以被观察到的行为,最终都会被某个人依赖上。这就是「这本来只是个 bug 修复」站不住脚的原因。
  2. 没有伴随 schema 变化的行为变化。 字段还在,类型没变,值现在代表不同含义。status 原来只有 active/inactive,现在还会返回 suspended,所有写穷举 switch 的调用方都会挂;时间戳从本地时间切到 UTC 会破坏没读文档的人。这类变更在 OpenAPI diff 里完全看不出来。
  3. 收紧了的校验规则。 开始拒绝没有顶级域名的邮箱、拒绝末尾带空格的输入、拒绝超过 80 字符的名字。校验规则变化是最常见的、以「加固」名义发布出去的破坏性变更。
  4. 改变了的默认值。 没设置过它的人(大多数调用方)会在一行代码都没改的情况下得到新行为。默认值的变化会破坏大多数用户,正是因为他们从来没见过这个设置。

三个层次:二进制、源码、行为

  • 二进制兼容:调用方不需要重新构建,换上新 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: 开头的页脚,对应主版本号。

发布一个破坏性变更的步骤

  1. 判断它到底算不算破坏性变更。 用调用方标准而不是 diff。如果两名工程师意见不一致,那它就是破坏性的。
  2. 给它一个版本。 semver 下破坏性变更对应主版本号。带日期或版本的 API 就进新版本,旧版本继续工作到一个确定日期。
  3. 在代码合并之前就写好条目:变化是什么、影响谁、他们必须做什么、截止到什么时候。四项填不全说明没准备好。
  4. 给出一个截止日期,而不是一个发布版本号。 「在 v5 中移除」对不追踪你发布节奏的人毫无意义。「2026 年 11 月 1 日起停止工作」对每个人都是同一个意思。
  5. 提供迁移方案。 把旧调用示例放在新的旁边。
  6. 在旧行为曾经被文档化过的每一个地方发布公告。
  7. 闭合这个循环。 有人要求过这个变更,就告诉他们。

一条合格的条目(收紧校验规则)长这样:

没有域名的邮箱地址将从 2026 年 11 月 1 日起被拒绝。POST /users 和 PATCH /users/:id 目前接受像 alice@localhost 这样的 email 值。从 11 月 1 日起,这些请求将返回 400 invalid_email。影响所有从内部目录创建用户的集成。迁移方法:发送一个完整的合法地址,或者省略该字段,之后再设置。如果你的地址已经带有域名,就无需任何改动。

提前多久通知?足够让调用方发现并完成工作。公开 API 九十天是常见下限。一项针对 119,879 次 Maven Central 升级的研究发现,有 16.6% 违反了语义化版本控制,但只有 7.9% 的客户端项目受到了影响,因为大部分变更触及的是没有任何客户端调用过的代码。破坏与否,要在调用方那里衡量。

为什么不干脆一直避免破坏性变更?那样代价更糟。一个从不破坏任何东西的 API 会不断积累它曾经犯过的每一个错误:命名错误的字段、错误的默认值、本地时间的时间戳。每一个都会永远向每一个新调用方征税。

不得不破坏时:并行提供 → 弃用期 → 梳理调用方 → 删除

  • 新旧并行提供:COM 加 IFoo2 保留 IFoo,原生 DLL 加 FooEx 或新旧 DLL 共存,.NET 升主版本发布新包、旧主版本只做缺陷修复。
  • 设置弃用期:.NET 用 [Obsolete] 编译警告;头文件注释和发布说明写明计划废止日期。
  • 梳理调用方:源码搜索、分发记录、注册表引用。发现无法重新构建的二进制,就延长旧 API 寿命或加封装层。
  • 删除旧 API 并升级主版本号。

绝不一次性直接替换。

参考

推荐文章

程序员茄子在线接单