向后兼容的 API 变更规格写法

向后兼容的 API 变更规格写法
Spec Coding 编辑部 · Spec-First 工程实践内容

我发布过自以为是加法、实际却破坏了兼容的 API 变更。规格看起来没问题,schema diff 看起来也很小,真正捅出篓子的是一条我从没写下来的规则。下面就是我现在要求每一份 API 变更规格都必须写清楚、否则不签字的清单。

发布于 2026-03-09 · ✓ 已更新 2026-05-06 · 阅读约 7 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

现场笔记:兼容要等真实客户端证明

我不会因为 schema validator 通过就说一个变更向后兼容。只有旧客户端、生成客户端和至少一个代表性工作流在新响应下继续正常工作,我才会这么说。

兼容性证据:
- 旧 Web 客户端无需改代码即可解析响应。
- 移动端遇到新 enum 值走 fallback。
- 生成 SDK 能基于新 schema 编译。
- 契约测试证明旧 webhook consumer 会忽略新字段。

唯一真正重要的规则:widen 输入,narrow 输出

如果只能记住一句话,就记住这句。服务端可以接受比以前更多的东西,也可以返回比以前更少的东西。反过来做就一定是破坏性变更。本文后面的每一条规则,都只是这句话的推论。

评审规格时,我会在纸上画一条竖线。左边写客户端发什么,右边写服务端返什么。只要输入在收紧,或者输出在扩张,我就会停下来追问。扩张输出是最容易被忽视的那种:大多数人以为往响应里加字段是零成本的,但对用严格 schema 解码的类型化客户端并不是零成本,对那些把未知字段当错误处理的契约也不是零成本。规格必须明说这个 API 生活在哪一种世界里。

optional、required,以及「免费字段」的幻觉

加一个 optional 的输入字段是安全的。加一个 required 的输入字段永远是破坏性变更,哪怕你嘴上说老客户端「应该」开始发它也没用。规格必须把每一个新输入标成 optional 并给出明确默认值,或者直接声明这是破坏性变更、安排版本号升级。

输出字段的形状正好相反。加一个输出字段对忽略未知字段的客户端通常是安全的,对不忽略的客户端就是破坏性变更。如果你的平台两种消费者都有,规格里必须点名说出谁是谁,并声明哪一组能容忍新增字段。不能靠挥挥手糊弄过去。

enum 扩展:最常见的隐性破坏

下面这个例子,是我用真金白银学到这条规则的。我们有一个接口返回 status 字段,取值是 pendingactivecancelled。产品需求要加一个 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 变更规格之前,我会把下面这张清单在嘴里过一遍。只要有一条没答上,规格就打回去。

我在每份规格开头都要写的两行

两行,没得商量。第一行:本次变更属于 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 版本化 · schema 演进 · enum 扩展 · 破坏性变更

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

编辑说明与免责声明

最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。