API 变更

破坏性变更:哪些算、哪些不算,以及如何安全发布

阅读约 2 分钟 更新于

破坏性变更,就是一个正确编写的调用方根本无法承受的变更。这个定义很重要,因为大多数关于某个变化”算不算数”的争论,实际上都是关于到底是谁用错了方法的争论。如果一个调用方遵循了你的文档,而你的变更让他们的代码停止工作了,那这个变更就是破坏性的。你原本的意图是什么,跟这个判断毫无关系。

这就是全部的判断标准。这篇文章剩下的内容都由它推导而来:哪些变更通不过这个标准,哪些能通过,如何在合并之前发现问题,以及一旦确认自己正在发布这样的变更,该怎么做。

什么算破坏性变更?

要把这个测试标准应用在调用方身上,而不是应用在 diff 上。当一个只依赖了文档说明行为的调用方,必须改动自己的代码、配置或数据才能继续正常工作时,这个变更就是破坏性的。移除一个字段、重命名一个端点、收紧校验规则、改变默认值、改变一个值的类型,都符合这个标准。添加一个可选字段不符合。修复一个 bug 通常也不符合,但下面有一个重要的例外。

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

什么不算破坏性变更?

当此前能正常工作的每一个调用依然能原样工作、含义也保持不变时,这个变更就是非破坏性的。添加一个新端点、添加一个可选的请求参数、在响应中添加一个字段、把必填输入改为可选、提高一个限制,以及改进一条没人依据它做匹配的错误信息,都能通过这个测试。这类增量变更可以放在小版本里发布,配上一条普通的体验日志条目即可。

增量变更在三种情况下仍然会破坏调用方。一个会拒绝未知字段的反序列化器,会在响应出现第一个新字段时失败,所以要尽早在文档里写明,调用方必须忽略自己不认识的字段。一个新的枚举值,会破坏所有写了穷举式 switch 的调用方(下文还会讲到)。而一个变大的响应,可能会让调用方撞上某个他们从来不必考虑的大小限制、超时或列宽。

表格里有四行值得更仔细地看一看,因为分歧往往就发生在那里。

团队最容易忽略的四种破坏性变更

偶然形成的契约。 如果你的 API 三年来一直返回同一个未在文档中写明的字段,那么一定有调用方在这个字段的基础上构建了自己的逻辑。Hyrum 定律是这个道理的简短版本:只要用户足够多,你系统里任何一个可以被观察到的行为,最终都会被某个人依赖上。这正是”这本来只是个 bug 修复”站不住脚的原因。这个修复也许是正确的,但仍然可能是破坏性的。把它当作破坏性变更来发布。

没有伴随 schema 变化的行为变化。 字段还在那里,类型也没变,但这个值现在代表了不同的含义。一个原本只会是 active 或 inactive 的 status,如果现在还会返回 suspended,就会破坏所有写了穷举式 switch 语句的调用方。一个从本地时间切换到 UTC 的时间戳,会破坏所有没有把文档读上两遍的人。OpenAPI 文件的 diff 里完全看不出这些东西。

收紧了的校验规则。 你开始拒绝没有顶级域名的邮箱地址、拒绝末尾带空格的输入、拒绝长度超过 80 个字符的名字。所有恰好一直在发送这类数据的调用方,现在都会为一个上周还能正常工作的请求收到一个 400 错误。校验规则的变化,是最常见的、以”加固”修复的名义发布出去的破坏性变更。

改变了的默认值。 明确设置过这个值的人不会注意到任何变化。而没有设置过它的人(也就是大多数调用方)会在一行代码都没改的情况下得到新的行为。默认值的变化会破坏你的大多数用户,正是因为他们从来没见过这个设置。

如何在破坏性变更上线之前发现它?

在 CI 里,把拉取请求上的契约和主分支上的契约做比较,一旦出现破坏性差异就让构建失败。大多数接口格式都有对应的 schema 比较工具,每一种都了解自己所属格式的破坏性规则:

接口工具比较的内容
REST(OpenAPI)oasdiff两份 OpenAPI 规范,并给出破坏性变更报告
gRPC(Protobuf)buf breaking.proto 文件,可按线路格式或源码层面比较
GraphQLGraphQL Inspector两份 schema,标出破坏性和危险的变更
Rust cratecargo-semver-checks公开 API 与上一个已发布版本的对比
TypeScript 包API Extractor一份提交到仓库里的包公开 API 报告

这些工具能可靠地发现被移除的字段、被重命名的操作和被改变的类型。但上面四类里的前两类,也就是偶然形成的契约和行为变化,它们看不到,因为这两类都不会体现在 schema 里。用工具拦住那些显而易见的,其余的则靠评审时问一句”一个正确的调用方会察觉到这个变化吗?“。同一个 CI 任务,也很适合用来要求提交体验日志条目,具体做法见在 CI 中强制要求体验日志条目,而gRPC 与 Protobuf 的 API 变更则逐一讲解了线路层面的各种情形。

如何在提交信息里标记破坏性变更?

在Conventional Commits中,破坏性变更用冒号前的 !(feat(api)!: remove the legacy export endpoint)来标记,或者用一个以 BREAKING CHANGE: 开头、后面跟着说明的页脚来标记。两种写法都对应一个主版本号。把这个页脚当作体验日志条目的初稿来写,写明谁会受到影响、他们必须做什么。约定式提交与体验日志一文讲了这套约定能帮你走多远。

同样的规则也适用于库。在语义化版本控制下,移除一个公开函数、收窄一个参数类型或者改变一个返回值,都对应一个主版本号。但库并不总是遵守它:一项针对 119,879 次 Maven Central 升级的研究发现,有 16.6% 违反了语义化版本控制,但只有 7.9% 的客户端项目受到了影响,因为这些变更中的大部分触及的是没有任何客户端调用过的代码。破坏与否,要在调用方那里衡量。

如何发布一个破坏性变更

要公开地、在一个确定的日期、带着一条迁移路径去发布它。下面这些步骤是按顺序排列的,而最后一步正是大多数团队会跳过的那一步:告诉那些受到影响的人,他们一直在等待的事情现在已经发生了。

  1. 判断它到底算不算破坏性变更。 使用上面的测试标准,而不是 diff。如果两名工程师意见不一致,那它就是破坏性的;这种分歧本身就证明了,调用方完全有可能合理地依赖过原来的行为。
  2. 给它一个版本。 在语义化版本控制下,破坏性变更对应一个主版本号。如果你运行的是带日期或带版本号的 API,它就应该进入一个新版本,而旧版本继续工作到一个确定的日期为止。如果你无法进行版本管理,那你发布的就不是一个破坏性变更,而是一次带有体验日志条目的故障。哪种方案来承载版本,正是API 版本控制最佳实践这篇文章的主题。
  3. 在代码合并之前就写好条目。 这条条目有一个固定的形式:变化是什么、影响谁、他们必须做什么、截止到什么时候。如果这四项你填不全,说明这个变更还没准备好。发布说明模板正是出于这个原因,把这类条目放在最前面,并用日期而不是版本号来标注。
  4. 给出一个截止日期,而不是一个发布版本号。 “在 v5 中移除”对不追踪你发布节奏的人毫无意义。“2026 年 11 月 1 日起停止工作”对每个人来说都是同一个意思。
  5. 提供迁移方案。 把旧的调用示例代码放在新的旁边。如果这个变更是重命名,就在同一句话里说出新旧两个名字。如果是一个被移除的字段,就说明那份数据去了哪里。
  6. 在旧行为曾经被文档化过的每一个地方都发布公告。 体验日志、描述该端点的文档页面、SDK 的发布说明,以及响应中的停用标头(如果有的话)。只在一个地方发布公告,等于只告诉了那些恰好看到那个地方的人。
  7. 闭合这个循环。 如果有客户要求过这个变更,或者报告过导致它的那个 bug,就在它上线时告诉他们。这一步能把一件”施加在用户身上的事情”,变成一件”和用户一起完成的事情”。

一条好的破坏性变更条目应该是什么样的

一条好的条目,会在第一行就点明受影响的调用方,写明日期,并包含修复方法。以下是我们使用的形式,针对收紧校验规则这种情形写的例子:

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

这类通知到底应该放在哪里,以及旁边还应该放上什么,API 体验日志一文有详细说明。

结尾的这个百分比不是装饰。它告诉读者到底该不该担心,而这正是他们打开这条条目时想问的问题。

为什么不干脆一直避免破坏性变更

因为那样做的代价更糟。一个从不破坏任何东西的 API,会不断积累它曾经犯过的每一个错误:命名错误的字段、错误的默认值、本地时间的时间戳。每一个都会永远地向每一个新调用方征税,只为了保护那些原本花一个下午就能完成迁移的调用方。那些以稳定性著称的团队,很少破坏东西,而当他们这样做时,一定是按计划进行的,带着一条迁移路径,以及一条真正送达了目标受众的警示。

那份警示背后的机制,是姊妹篇文章停用一个 API的主题。宣布它的那条条目,会以和体验日志信息流里其他任何一条条目相同的方式起草:来自已经合并的 pull request,为人工审核而保留,然后发布到受影响的调用方已经在阅读的地方。

FAQ

破坏性变更和非破坏性变更有什么区别? 破坏性变更会迫使一个正确的调用方修改自己的代码、配置或数据才能继续工作。非破坏性变更则让每一个现有的调用都保持工作且含义不变,这就是为什么新增通常是安全的,而移除、重命名和收紧规则通常不安全。

添加一个必填字段算破坏性变更吗? 算。每一个现有的调用都缺少这个字段,所以现在每一个现有的调用都会失败。要么把它添加为带有合理默认值的可选字段,要么给这个端点加上版本。

一次 bug 修复算破坏性变更吗? 有可能算。如果调用方依赖了那个有 bug 的行为,修复它就会破坏他们,无论文档写了什么。把任何改变了可观察输出的修复都当作破坏性变更来处理,除非你能证明没有人依赖过它。

语义化版本控制适用于 web API 吗? 这条规则本身适用:破坏性变更会得到一个新的主版本号,旧版本会在一段声明过的时间内继续工作。这个编号往往体现在 URL 或日期标头里,而不是一个包版本号。

提前多久通知才算足够? 足够让调用方发现这条通知并完成相应的工作。对公开 API 来说,九十天是一个常见的下限;对那些出货给终端用户、无法远程更新的代码来说,需要更长的时间。


本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。

changeloop 相关页面: 发布说明模板, 开发者文档

changeloop
打造闭环 changelog 的团队。用户提出需求,你的团队交付,提出需求的人得知结果。