向后兼容的 API 变更规格写法
我发布过自以为是加法、实际却破坏了兼容的 API 变更。规格看起来没问题,schema diff 看起来也很小,真正捅出篓子的是一条我从没写下来的规则。下面就是我现在要求每一份 API 变更规格都必须写清楚、否则不签字的清单。
现场笔记:兼容要等真实客户端证明
我不会因为 schema validator 通过就说一个变更向后兼容。只有旧客户端、生成客户端和至少一个代表性工作流在新响应下继续正常工作,我才会这么说。
兼容性证据: - 旧 Web 客户端无需改代码即可解析响应。 - 移动端遇到新 enum 值走 fallback。 - 生成 SDK 能基于新 schema 编译。 - 契约测试证明旧 webhook consumer 会忽略新字段。
唯一真正重要的规则:widen 输入,narrow 输出
如果只能记住一句话,就记住这句。服务端可以接受比以前更多的东西,也可以返回比以前更少的东西。反过来做就一定是破坏性变更。本文后面的每一条规则,都只是这句话的推论。
评审规格时,我会在纸上画一条竖线。左边写客户端发什么,右边写服务端返什么。只要输入在收紧,或者输出在扩张,我就会停下来追问。扩张输出是最容易被忽视的那种:大多数人以为往响应里加字段是零成本的,但对用严格 schema 解码的类型化客户端并不是零成本,对那些把未知字段当错误处理的契约也不是零成本。规格必须明说这个 API 生活在哪一种世界里。
optional、required,以及「免费字段」的幻觉
加一个 optional 的输入字段是安全的。加一个 required 的输入字段永远是破坏性变更,哪怕你嘴上说老客户端「应该」开始发它也没用。规格必须把每一个新输入标成 optional 并给出明确默认值,或者直接声明这是破坏性变更、安排版本号升级。
输出字段的形状正好相反。加一个输出字段对忽略未知字段的客户端通常是安全的,对不忽略的客户端就是破坏性变更。如果你的平台两种消费者都有,规格里必须点名说出谁是谁,并声明哪一组能容忍新增字段。不能靠挥挥手糊弄过去。
enum 扩展:最常见的隐性破坏
下面这个例子,是我用真金白银学到这条规则的。我们有一个接口返回 status 字段,取值是 pending、active、cancelled。产品需求要加一个 paused。schema diff 只显示多了一个变体,规格上写着「加法,非破坏性」。
上线一小时内,一个 Rust 消费者就在生产环境 panic 了。他们的代码做的是 exhaustive match:
match status {
Status::Pending => render_pending(),
Status::Active => render_active(),
Status::Cancelled => render_cancelled(),
}
Rust 编译器在编译期就保证了这个 match 是 total 的。在线上增加一个变体,并不会触发他们的构建。用 discriminated union 和 never 类型 default 分支的 TypeScript 客户端,会在运行时掉进同一个坑里。
我现在要求规格里必须出现一句话,二选一:本 enum 的消费者按 open-world 处理。新变体可以在不升级版本号的情况下出现。做 exhaustive match 的客户端必须加 catch-all 分支。或者反过来:本 enum 是 closed。新增变体属于破坏性变更,必须升级版本号。选一个,写进去。
nullable、non-nullable 和默认值陷阱
把一个输出字段从 nullable 改成 non-nullable 是破坏性变更。反过来在理论上是安全的,在实际中很危险,因为从没见过 null 的客户端会直接解引用,然后崩掉。无论哪个方向,规格里都必须写迁移说明,不能只改 schema 就算完事。
默认值变化是大家最容易漏掉的一类。schema 形状没动,diff 工具一声不吭,但行为已经变了。如果一个可选的 page_size 以前默认是 20、现在默认是 50,所有存量客户端都在承担不同的成本、看到不同的分页边界,还可能触发他们以前从没碰过的 rate limit。我把任何默认值变化默认按破坏性变更处理,除非规格里拿消费者审计的结论证明它不是。
重命名、重构和单向门
我从不批准重命名。套路永远是三步:加新名字、把旧名字标成 deprecated、在后续某个大版本里删掉旧名字。规格必须把这三步连同移除日期一起写出来,否则 deprecation 就会变成永久的技术债务。
错误载荷遵循同样的逻辑,而且更狠。新增一个错误码通常是安全的,因为客户端本来就该为未知码准备一个默认分支。重构错误对象的形状是破坏性变更。改变现有错误码的语义——码没变,但含义变了——是最糟糕的情况:隐性破坏,schema diff 里完全看不出来,保证会把客户端的重试逻辑带进沟里。
分页、限流和序列化
有三块地方规格必须显式写清楚,因为大家心里不把它们当「schema」:
分页。下调一个已公开文档化的 max_page_size 是破坏性变更。在保留原默认值的前提下加一个新的可选页大小参数是安全的。改游标编码方式是破坏性变更,即使游标本身是不透明的也一样。
限流。任何收紧已发布上限的动作都是破坏性变更。只要限制在消费者能读到的任何地方被写下来过,就要当契约来对待。
序列化。从 JSON 切到二进制格式是破坏性变更,不管线上格式号称多么「等价」。通过 Accept 协商、并带安全 fallback 的新 content-type 属于加法。规格必须把协商流程画出来,不能光嘴上说。
用 Given/When/Then 写验收标准
- Given a v1 client that decodes status with exhaustive match
When the server returns a newly added status variant
Then the client receives an error the spec explicitly permits
And the change is documented as breaking in the changelog
- Given a client that omits the new optional input field
When the server processes the request
Then the server applies the documented default
And the response is byte-identical to the pre-change response
- Given a published rate limit of N requests per minute
When the spec proposes lowering the limit
Then the change is treated as breaking
And a deprecation window is scheduled before enforcement
评审人的核对清单
批准任何 API 变更规格之前,我会把下面这张清单在嘴里过一遍。只要有一条没答上,规格就打回去。
- 每一个新增输入字段是否都有明确默认值和 optional 标记?
- 每一个新增输出字段是否都点名核对过严格 schema 消费者?
- 每一处 enum 变化是否都声明了 open-world 还是 closed-world 语义?
- 每一处 nullability 变化是否都对照真实客户端代码路径交叉验证过?
- 每一处默认值变化是否在消费者审计通过之前都被标成破坏性变更?
- 任何重命名是否都配了「加—弃—删」三步计划和移除日期?
- 任何错误变化是否都区分清楚了新增、重构和语义漂移?
- 分页边界、限流、序列化格式是否都按一级契约来处理?
我在每份规格开头都要写的两行
两行,没得商量。第一行:本次变更属于 additive / breaking / silently breaking。选一个,给出论证。第二行:受影响的消费者类型分别是 X、Y、Z,每一类的迁移义务如下。如果我没法诚实地写出这两行,规格就还没到可以评审的状态。如果能写出来,文档的其余部分会自己写出来,因为难的决定已经做完了。
评审时看什么
这篇文章适合用在向后兼容 API 变更评审时。别从“原则”聊起,直接拿一条真实改动来对照,看看规格里还缺什么。
- 新增字段是否真的非破坏。
- 枚举扩展是否会打爆旧客户端。
- 废弃窗口和通知方式是否写清。
- 兼容测试是否覆盖旧版本调用方。
兼容性不是 schema diff 里显示绿色就完事。关键是旧调用方是否还能按原来的假设运行。
兼容性不是“没有删字段”
很多破坏性变更看起来很温和:枚举加值、默认排序改变、字段从可空变成空数组、错误码变细。规格要按客户端行为判断兼容性,而不是按 schema diff 的颜色判断。
Compatibility matrix: - add optional response field: safe if old client ignores unknown fields - add enum value: risky unless old client has unknown fallback - change default sort: breaking for paginated clients - nullable -> empty array: risky for clients checking null - new 409 error: safe only if retry policy handles non-2xx by category
边界:内部 API 也可能有老客户端。只要调用方不和服务一起部署,就按公开契约处理。
可复制产物:契约评审包
当工作涉及 API 行为、schema、事件、重试或消费者预期时使用。它会把兼容性和发布证据提前摊开。
API 契约评审包:向后兼容的 API 变更规格写法 本次要做的决策: - 确认契约变化是否兼容,消费者需要什么迁移动作,发布后如何观察风险。 责任人检查: - 产品责任人: - 工程责任人: - QA 或运维评审: 范围边界: - 本次包含: - 本次不包含: - 仍需确认的假设: 验收证据: - 测试或 fixture: - 日志、指标或截图: - 人工复核步骤: 契约边界:没有兼容性分类、消费者影响、重试行为和回滚说明,不进入发布。 评审追问: - 没参加需求会的人还会误解哪里? - 哪个证据能证明这次改动足够安全,可以发布?
二次审阅记录:兼容性需要调用方列表
这篇文章在把兼容性写成“调用方声明”时更有力,而不是服务端自己的意见。这次补充让读者在批准变更前先点名受影响客户端。
批准前检查: - 按名称列调用方,不只写类别。 - 单独标出生成客户端。 - 决定旧客户端是否需要迁移窗口。 - 给最高风险路径附一个旧客户端测试。
编辑复核记录
复核日期:2026-04-28。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
编辑说明与免责声明
最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面