破坏性 API 变更详解

破坏性 API 变更,是指对 API 已发布契约所做的、能让一个严格按旧契约写对的客户端在新契约下失败的任何变更——客户端自己什么都没改。这一句话就是全部理论;本文其余部分都是它的推论:哪些变更算数、哪些著名的陷阱看起来无害实则有害、如何在规范升级部署之前审查它、以及版本策略如何为客户端争取时间。想把理论用在两份真实文档上,就用 OpenAPI Diff 对比新旧规范,对照本文阅读判定结果。

契约是对未来的一份承诺

API 规范不是随手写的文档;它是一份承诺。当规范声明 GET /pets 接受一个可选的 limit 参数、并以 200 返回一个包含字符串 name 的对象时,每个客户端作者都正是按这些来构建的:不需要时就省略参数、按状态码分支、把字段当字符串读。客户端的正确性是按承诺来评判的,因此“破坏性”的定义是不对称的——只要一个写对的旧客户端可能失败,这项变更就是破坏性的,哪怕大多数客户端碰巧能挺过去。一个由旧规范生成的严格 SDK 因类型变更而失败,就已经足够:被打破的是承诺,不是客户端。

这个框架能化解大多数关于某项变更“算不算数”的争论。删除端点是破坏性的,因为承诺里包含它的存在。新增端点不是,因为没有任何旧客户端对自己从不调用的路径许下过什么。新增一个可选的响应字段是安全的,因为旧解析器会忽略未知字段——在任何像样的格式里,只增不减的输出不可能让读取方失效。相反,新增一个必填输入会伸进每个已有的请求里,让那些没带该字段的请求失效。检验方法永远相同:把昨天的合法流量放到明天的规则下重放一遍,看看有什么会失败。

实践中有两个推论重要。第一,契约是已发布的内容,而不是服务器今天碰巧做的事:客户端靠嗅探流量发现的未文档化字段不在承诺之内,删除它不礼貌,但严格来说不算破坏——这正说明应该只把你打算保留的东西写进文档。第二,契约包括它所依托的标准的整个表面:RFC 9110 所固定的 HTTP 方法与状态语义也是承诺的一部分,因此重新定义 200 或 404 的含义,即使规范文件里根本没提状态含义,也构成一次契约变更。

经典陷阱

生产环境中最常见的破坏是添加必填字段。团队往请求体里加一个字段、因为新业务规则需要它而标为必填、然后发布——于是每一个现有客户端,由于都不发送这个字段,开始收到 400。安全版本很无聊:把字段加为可选并在服务器端给默认值,或者只在新端点上要求它。第二个陷阱是删除枚举值:某个状态值、类别或排序方式因为新界面不再提供而被退役,而存量客户端数据、旧版本手机应用和合作伙伴集成在多年里还会继续发送它。保留接受一个枚举值几乎不花成本;撤回它,应该按删除端点来对待。

类型变更是第三个陷阱,有收窄和放宽两种伪装。收窄——字符串变枚举、整数变小范围、自由对象变固定形态——会拒绝以前合法的输入,表面上就是破坏性的。放宽更微妙:把响应字段从字符串改成字符串或空、把整数 id 改成字符串 id,看起来大方,却会弄坏每个按旧类型构建的严格解析器和生成的 SDK。响应一侧还有一面镜像陷阱:一直存在的字段变成可选。客户端无条件地读它;第一个省略它的响应,就是别人代码库里的一次空指针。

第四个陷阱是语义型的,没有任何对比工具能抓到它:字段保持名字和类型,却改变了含义。价格悄悄地从分变成元、时间戳从 UTC 变成当地时间、曾经有序的列表不再有序——规范对比是空的,而每个客户端都是错的。语义破坏正是机械对比只是审查的下限而非上限的原因:它清掉那些不可能弄坏任何人的变更,让人的注意力集中在那些只是可能弄坏人的变更上。

在部署之前审查供应商的规范升级

供应商不断发布新的规范版本,而“升级 SDK 然后重新部署”正是安静的破坏抵达生产环境的方式。一套有纪律的审查只需要十五分钟。第一,拿到两份文档——你的部署今天实际运行所依据的规范,和候选规范——并机械地对比它们,而不是读变更日志,因为变更日志是营销文档:它们列功能,不列删除。OpenAPI Diff 在本地生成这份清单;当 API 是内部的时候,它的规范不应该被粘贴进陌生人的托管工具,这一点很重要。

第二,把破坏性清单当作对照你自己用法的检查表来逐项核对。大多数供应商的破坏对你来说是理论上的:一个你从不调用的端点被删了、一种你从不创建的资源多了一个必填字段。危险的条目是那些与你的流量相交的——这正是审查需要你的调用清单、而不只是对比结果的原因。第三,检查未文档化的表面:你按其分支的状态码、你解析的错误形态、你读取的标头。同一种保护 webhook 消费者的习惯——固定并测试负载契约,正如 webhook 签名详解 所述——适用于任何由别人掌控的契约。

最后,分阶段上线。让一个预发布客户端指向新版本,重放录制下来的生产请求(一份脱敏过的抓取,按 HAR 指南 规定的方式处理),在任何用户之前比较响应。如果供应商提供弃用窗口或版本锁定,就用它:最便宜的破坏性变更,是你按自己的节奏、在客户端准备好之后采纳的那一次,而不是供应商发布日的那一次。

版本策略:URL、标头,还是永不破坏

对破坏性变更的经典回答是版本标识符,经典的争论是把它放在哪里。URL 版本(/v1/、/v2/)明确、缓存友好、路由 trivially 简单、在每一行日志里都可见;它的代价是它把整个 API 一起版本化,于是一个破坏性端点会拖着每个客户端一起迁移。标头版本——版本标头或带日期的内容协商方案——让 URL 保持干净、还能按资源单独版本化,代价是不可见:版本从日志、链接和浏览器地址栏里消失,调试变成考古。两者都没有错;业界只是更愿意付 URL 的代价,因为凌晨三点时可运维性胜过优雅。

更深的策略是让版本变得罕见。只增不减地演进——永不删除、永不设为必填、永不改类型;只添加可选的东西——能让单个版本无限期地活下去,存活最久的公共 API 事实上就是这样运作的。它要求一开始就有纪律:宽容的输入接受、从第一天起就把 id 定为字符串类型(以便范围可以增长)、为可扩展而设计的枚举、以及在撤回任何东西之前长时间警告的弃用流程。语义化版本——如 semver.org 所编纂,而 OpenAPI 倡议自己的规范刻意把这件事留给发布者——提供了信号层:主版本提升宣告一次破坏,客户端可以据此锁定。

无论选择哪种策略,它只有作为有机制支撑的社会契约才管用:各版本必须在声明过的窗口内真正并行运行,弃用必须在客户端作者看得见的地方宣布(变更日志、响应标头、给大消费者的直接通知),旧版本的使用量必须被度量,于是“已经没有人用了”是一个事实而不是希望。一个发布了却不能并行运行的版本不是策略;它只是给破坏改了个名字。

对比工具的位置

机械对比工具在两个时刻证明自己的价值。在审查中,它把“有什么被弄坏了吗?”从一下午的逐行对照,变成一份可以逐项勾掉的清单——而且同样有用的是,它能证明一个否定结论:当一个版本声称自己只增不减时,一份空的破坏性清单是证据,而不是感觉。在持续集成中,同样的比较可以对每个动到规范的拉取请求运行,在破坏性变更没有伴随项目政策所要求的版本提升或批准时,让构建失败。分类表就是政策;工具只是拒绝让它在截止时间被忘掉。

它的局限定义了其余的工作流。对比工具只读已声明的契约:它看不见行为、性能、语义,以及客户端所依赖的未文档化怪癖,而且它会摘要病态地深的模式而不是无限展开。因此完整的门禁是一条流水线,而不是一个页面:机械对比管契约、重放真实客户端调用的契约测试管行为、分阶段发布管语义、监控管三者都漏掉东西的那一天。这样使用时,对比是最便宜的一层——十五秒,删掉最尴尬的一类事故:那种破坏其实一直写在规范里、只是没人对比过两份文档的事故。

这才是本文真正主张的习惯。规范保存起来便宜,对比起来更便宜;事故不便宜。把你拥有或消费的每份契约的每个已发布版本都保存下来,在每个候选版本上线之前与生产中的版本对比,并把破坏性清单当作这次发布的真正议程。做到这一点的团队,不再争论某项变更“本应不应该”弄坏任何人——他们有那份清单,带着日期、归档着,在它上线之前就有了。

来源与延伸阅读

常见问题

添加必填字段总是破坏性的吗?

是的,在任何现有客户端已经在调用的端点上:昨天合法的请求今天被拒绝,这就是破坏性的定义。标准的出路是:把字段加为可选并在服务器端给默认值;只在新端点或新版本上要求它;或者宣布一个弃用窗口,在此期间该字段是“可接受但会警告”的,之后才变成必填。

URL 版本还是标头版本——哪个更好?

对大多数团队来说是 URL 版本:它在日志和链接中可见、易于路由和缓存、而且对它是什么很诚实。标头版本适合需要按资源粒度版本化、且已经有强可观测性的平台。两者都胜过最差的选项——无版本的破坏性变更。最好的策略其实是只增不减的演进,它让版本化成为罕见事件而不是例行税负。

工具能抓到每一个破坏性变更吗?

不能。对比工具能抓到规范声明的一切:删除、新增必填输入、类型变更、枚举撤回。它抓不到语义变更——同一字段、新的含义——也抓不到规范从未声明的行为。这就是为什么机械对比应该与契约测试和分阶段发布一起进入流水线,而不是被单独供起来。