关于 API 版本管理的争论,几乎总是从错误的一端开始:版本号该放在哪里。其实这是整个话题里后果最轻的一个决定。真正要紧的是,究竟哪些变更才需要一个新版本,而大多数团队恰恰在这里判断失误,而且是往掉以轻心的方向失误。他们发布了自认为纯属新增的东西,然后某个客户端就坏了。

有用的思维模型是:你的 API 是一份承诺,规定了调用方可以依赖什么。如果一次变更让一个讲道理的调用方原本依赖的东西失效了,那它就是破坏性的。而调用方依赖的东西,远远多于你的文档明确允许他们依赖的范围。

让所有人都栽跟头的那种变更: 往响应里加一个字段。它只是新增,按理说不可能弄坏一个写得规范的客户端,可它偏偏经常弄坏真实的客户端,因为其中有些客户端会严格校验响应,并拒绝任何未知字段。到了对方的对接已经停摆、电话已经打过来的时候,这到底算你的错还是他们的错,已经没有任何意义了。


究竟什么才算破坏性变更

毫无疑问的破坏性变更: 删除或重命名字段,改变字段的类型,给请求增加必填参数,收紧校验规则,改变某个既有取值的含义,或者改动调用方用来分支判断的状态码。

看着安全、实际却会出事: 在客户端严格校验的情况下往响应里加字段。改变数组的顺序,虽然你从未承诺过顺序,但调用方已经默认了它。改动某条错误信息,而有人正把它当字符串来匹配。把一个同步操作改成异步。

真正安全的变更: 新增一个可选的请求参数,新增一个端点,放宽校验,以及往枚举里加一个新值。最后一条有前提:必须一开始就告诉过客户端会出现未知取值,而且你能验证他们确实处理了这种情况。

这里的规律是:安全与否取决于调用方实际做了什么,而不是规范允许做什么。如果每个客户端都在你手里,你可以逐一验证。如果不在,那就假定有人正依赖着你以为无关紧要的那一处,因为事实就是如此。

API 版本管理策略以及各自的代价

版本放在 URL 里。 最常见的做法,优点是一目了然:/v1/orders/v2/orders 一眼就是两个不同的资源。代价是它会助长整个 API 一起升版本,于是只影响一个端点的变更把其余部分全都拖下水,客户端也只能一次性全部迁移。

版本放在请求头里。 保持 URL 稳定,还能做更细的粒度,代价是它不可见。不专门去找,谁也不会在浏览器或某一行日志里看到版本号;而漏掉这个头的调用方,拿到的就是你设定的默认值,这个默认值必须由你有意识地决定。

按日期划分版本。 调用方钉住一个日期,就拿到 API 在那一天的行为。Stripe 就是这样记录这套做法的:每个账户有一个默认版本,单次请求还可以覆盖它。它给出的迁移步子尽可能小,代价是把兼容性的负担挪进了你自己的代码库,而这套代码库从此要长期维护版本之间的转换。

不做版本,只做新增。 如果你真能承诺永远不删除任何东西,这条路完全可行,而且被严重低估。它的代价是不断堆积你删不掉的字段,以及你再也纠正不了的行为,这是一笔慢慢渗出的税,而不是一张突然到期的账单。

没有标准答案,只有你的迁移负担和客户的迁移负担之间的一次交换。按日期划分版本对调用方最友好,运维起来也最贵;把版本放进 URL 则正好相反。

让破坏性变更变得可以承受

先扩展,再收缩。 把新字段加在旧字段旁边,两个都写入数据,给客户端留出迁移时间,再在之后的版本里删掉旧的。这样一次破坏性变更就变成了两次安全变更,多走这一步几乎每次都值得。

把谁在用什么记录下来。 不知道还有谁留在旧版本上,就没办法安全地下线它。请在每一次请求里都记录版本号和客户端身份,这样弃用就成了一场基于证据的对话,而不是朝着黑暗里喊一嗓子。

用机制来通知,而不只是发一封邮件。 DeprecationSunset 这两个响应头,能让客户端用程序发现下线日期,这比发到一个再也没人看的邮箱地址的消息更容易被注意到。

给出现实的窗口期。 维护对接的是另有优先事项的人,比他们发布周期还短的截止日期,只会被直接错过。公开 API 六个月是常见做法;如果只是少数几个你直接沟通过的已知合作方,短一些也说得过去。

下线一个旧版本

正是上面那套记录,才让这一步成为可能。公布日期,观察流量下降,然后点名联系那些还没迁走的调用方。

要预料到一条长尾。总会有一些对接,客户公司里已经没人记得那是自己的东西,而它们只会在你关掉版本的那一刻暴露出来。这里可以用短时熔断:在最终日期之前,挑几个提前公布好的时段把旧版本短暂关掉,让故障发生在有人正等着它的时候,而不是发生在业务高峰的正中间。

实话是:有些调用方只有等到旧版本彻底不工作了才会动。与其被这件事吓一跳,不如提前把它写进计划,并且确保失败时返回的是一条讲清楚发生了什么的明确错误,而不是一次超时。

你并不需要的那个版本

多数内部 API 根本不需要版本管理,因为每个调用方都在你手里,两边可以一起改。给两个自家服务之间的接口加上版本协商,是一套要花成本、却什么也保护不了的机器。

真正需要它的分界点,是你再也没法把所有消费方同时部署上线的时候,不管对方属于另一个团队、另一个发布周期,还是另一家公司。这才是真正的触发条件,跟这个 API 有多公开毫无关系。我们那篇无服务器 API 指南里的设计取舍,在边缘上同样适用。

Mecanik 在软件开发业务中会设计并长期维护这一类 API。版本方案本身很少是那个有意思的决定;而知道你的哪一个调用方还留在旧版本上,永远都是。



常见问题

什么样的改动算是破坏性的 API 变更? 删除或重命名字段、改变字段类型、增加必填参数、收紧校验、改变某个取值的含义,或者改动调用方用来分支判断的状态码。以下几种在实践中同样是破坏性的:客户端严格校验时往响应里加字段、改变调用方默认存在的数组顺序,以及修改有人正在匹配的错误信息文本。

API 版本应该放在 URL 里还是请求头里? 放在 URL 里可见又简单,但会助长整个 API 一起升版本,逼着客户端一次性迁移全部内容。放在请求头里能保持 URL 稳定、支持更细的粒度,却在日志和浏览器里都看不见,还需要你为漏带这个头的调用方有意识地设定默认值。两者都不算错,它们只是在你的迁移负担和客户的迁移负担之间做交换。

什么是按日期划分的 API 版本? 调用方钉住一个日期,就拿到 API 在那一天的行为,Stripe 的做法就是每个账户有一个默认版本,单次请求可以覆盖它。这种方式给客户端的迁移步子尽可能小,同时把兼容性负担挪进你的代码库,由它长期维护版本之间的转换。

API 弃用的窗口期应该留多长? 公开 API 六个月是常见做法,如果只是少数几个你直接沟通过的已知合作方,短一些也说得过去。维护对接的是另有优先事项的人,所以比他们发布周期还短的窗口期,无论通知得多清楚都会被错过。

内部 API 需要做版本管理吗? 通常不需要,因为每个调用方都在你手里,两边可以一起改。版本管理开始变得必要的时刻,是你再也没法把所有消费方同时部署上线,不管对方属于另一个团队、另一个发布周期还是另一家公司。触发条件是这个,而不是 API 是否公开。