面向调用方设计的 API 版本控制最佳实践
阅读约 1 分钟
API 版本控制,就是在你改动了一份契约之后,依然让旧的契约继续正常工作的做法,这样调用方就可以按照自己的节奏去迁移,而不是被迫按照你的节奏。这句话里包含了两个真正重要的决定:什么样的改动才算是改变了契约,以及旧的契约应该继续工作多久。至于版本号存放在哪里——这正是大多数版本控制争论所围绕的话题——反而是三者中最不重要、也最容易做对的一个。
什么时候应该给 API 做版本控制
只有当一个变化会破坏一个正确的调用方时,才需要对 API 做版本控制。增量式的变化——新字段、新端点、新的可选参数——并不需要版本,因为按照旧契约编写的调用方仍然可以正常工作,新能力只是多出来的一部分。破坏性变更则确实需要版本,因为不这样做的代价是让调用方通过一个错误才发现问题。给每一次发布都加上版本号,包括那些增量式的发布,只会教会调用方把版本当成噪音,他们就会不再阅读那些真正重要的通知。
实用的测试标准和破坏性变更那篇文章里的一样:如果一个只依赖了文档说明行为的调用方,必须改动点什么才能继续正常工作,这个变化就需要一个版本。如果不是,就在当前版本下发布它,并写一条体验日志条目。
应该使用哪种 API 版本控制方案
使用调用方最容易看到、也最容易设置的那种方案,对大多数公开 API 来说,那就是 URL 路径中的版本号,或者带日期的版本标头。这四种常见方案之间的差别,与其说在于能力,不如说在于它们分别对调用方提出了什么要求,而这才是选择时正确的依据。
| 方案 | 示例 | 调用方必须做什么 | 谁在用 |
|---|---|---|---|
| URL 路径 | /v2/invoices | 迁移时修改 URL | 大多数公开的 REST API |
| 版本标头 | X-GitHub-Api-Version: 2022-11-28 | 发送一个标头,或接受默认值 | GitHub |
| 带日期的账户版本 | Stripe-Version: 2026-08-26 | 按请求或按账户固定一个日期 | Stripe |
| 查询参数 | /invoices?version=2 | 附加一个参数 | 较老的 API;如今很少被选用 |
| 媒体类型 | Accept: application/vnd.example.v2+json | 协商内容类型 | 追求纯粹的人;能真正驾驭它的调用方很少 |
URL 路径的可见度最高,灵活性最低。每个调用方只需要看一行日志就能知道自己在用哪个版本,升级版本也只是一次查找替换。代价是整个表面会一起移动:你不可能只改变一个端点的契约,而不为所有端点都发布一个新版本,所以路径版本往往既少见、又规模庞大。
版本标头能保持 URL 稳定,并且能让服务端为什么都不发送的调用方选择一个默认值,这正是 GitHub 的 REST API 版本的工作方式:X-GitHub-Api-Version 里是一个以日期命名的版本,以支持的最旧版本作为默认值,这样不指定版本的调用方也不会被破坏。代价是版本在 URL 里是不可见的,在一个新客户端里很容易被遗忘。
带日期的账户版本是在标头方案的基础上多加了一样东西:版本被存储在账户上,因此不需要发送任何东西,每个请求都会自动携带这个版本。Stripe 的版本控制把每个账户固定在它创建时的那个版本上,而一次请求可以用 Stripe-Version 来覆盖它。这是对调用方最友好的方案,但也是运行成本最高的方案,因为服务端必须在每一个受支持的版本和当前版本之间做转换。
查询参数和媒体类型都能行得通,但各自都以不同的方式在可见性测试上失败:查询参数在拼接 URL 时很容易被漏掉,而媒体类型版本几乎在调用方用来调试的所有工具里都是不可见的。Stripe 的带日期方案是日期方式中最知名的例子,Stripe 如何为其 API 做版本控制对它做了详细讲解。
实际中该怎样做 API 版本控制
在实践中,一个版本就是一组有名字的行为集合,服务端会把每一个请求映射到其中一组上。无论哪种方案来承载这个名字,具体步骤都是一样的。
- 用日期或整数来命名版本,而不是用语义化版本号。 web API 不是一个软件包。调用方无法固定一个 URL 的次版本,所以
v2或2026-08-26已经能说清调用方所需要的一切,而语义化版本控制的编号,则会暗示一种这套方案根本无法兑现的兼容性承诺。 - 让版本远离那些不需要关心它的代码路径。 版本应该只在边缘处选择一个转换层,而不应该分叉业务逻辑本身。整套代码库维护两份完整的拷贝,正是一个版本最终变得无人维护的方式。
- 给每个版本都设定一个默认值和一份文档。 没有指定版本的调用方,应该得到受支持的最旧版本,而不是最新版本,这样一个没有固定版本的客户端才不会在你发布的当天就出问题。每个版本都应该有一个页面,说明相比上一个版本发生了什么变化。
- 设定一个支持窗口期,并公开发布它。 Google 的版本控制指南 AIP-185 要求提供一个合理且沟通充分的过渡期,并建议即使是 beta 功能也留出 180 天。选定一个窗口期,把它写下来,然后不再按每个版本重新谈判地应用它。
- 像退役端点一样退役版本。 一个超过了窗口期的版本,应该得到和任何已停用的 API一样的对待:一份公告,每个响应上的一个
Sunset标头(RFC 8594),给仍在使用它的调用方的一次中间点提醒,以及一个真正会被执行的移除日期。
REST API 中的 v1 和 v2 是什么
v1 和 v2 是同一个服务端同时支持的两份契约的名字。v2 之所以存在,是因为 v1 里的某些东西如果不破坏它的调用方就无法改变,于是这个变化进入了一份新的契约,而旧的那份继续工作。这些数字本身并不意味着 v2 已经完成,或者 v1 已经死了;只有当文档这样说时,这两件事才是真的。如果每个季度都冒出一个 v3,那就是一个信号,说明增量式的变化正在被当作版本来处理,或者这份契约从一开始就没有被设计成能够吸收变化。
这是一种 URL 路径版本控制的模型,版本号是调用方拨号时用的那个路径段。gRPC 服务通常用另一种
方式解决同一个问题:版本存在于 .proto 文件本身内部的包名里。gRPC 与 Protobuf
讨论了这个区别,以及为什么在那里线上兼容性是由字段编号而不是 URL 的形状来定义的。
一次版本变更应该宣布什么
一次版本变更应该宣布什么会破坏、谁会受影响、如何迁移,以及上一个版本还会继续工作多久。这条条目的形式,和任何其他破坏性变更条目一样,只是多加了一行说明支持窗口期的话。以下是一个带标头版本控制的 API 的示例:
API 版本 2026-11-01 现已可用。版本 2025-06-15 支持至 2027 年 11 月 1 日。 2026-11-01 中的新变化:
GET /invoices现在会把amount以最小单位的整数形式返回,而不再是一个小数字符串,并且已停用的customer_name字段已被移除,改用customer对象。影响所有把amount当作字符串解析的 2025-06-15 调用方,这是 2025 年 6 月之前创建的、未固定版本的客户端的默认行为。迁移方法:把amount当作整数解析,并从customer.name中读取名字。准备好后请固定X-Api-Version: 2026-11-01。对于未固定版本的调用方,不会有任何变化。
最后这句话,正是让大多数读者可以在此停下阅读的那句话,它应该出现在每一次版本公告里。体验日志范例页面收录了以这种方式做版本控制的 API 的条目,而好的条目和其他条目之间的差别,往往就在于这最后一行。
版本变更时,谁会被通知到
旧版本上的每一个人,都会被单独通知,其他所有人则通过体验日志。版本变更正是”我们发过公告了”这句话必定会漏掉重要调用方的那种情况:那些两年前固定了一个版本、此后再也没读过发布说明的人。使用数据能回答他们是谁;而通知必须触达他们的代码所在的地方,也就是响应标头,以及给账户所有者的一条消息。
在我们运行的这套循环里,宣布一次版本变更的条目,是从发布它的那个 pull request 起草而来的,由人来审核,然后发布到信息流和小组件,一个带版本的客户端可以把它当作 JSON 来读取。任何人,只要其小组件反馈要求过这项改动、或报告过它所修复的那个 bug,并且变成了一个由该 pull request 关闭的 GitHub issue,都会在条目上线时,在那个 issue 上收到通知。这套机制和任何条目都一样;一次版本升级只是风险最高的那一条条目而已。
FAQ
每一次 API 变更都应该获得一个新版本吗? 不需要。只有破坏性变更才需要。增量式的变化在当前版本下发布,并配一条体验日志条目。给增量式变化加上版本,只会训练调用方去忽略版本。
URL 版本控制和标头版本控制,哪个更好? URL 版本控制对调用方来说更容易看到,对你来说却更难逐步演进;标头版本控制正好相反。对于拥有大量小型客户端的公开 API,URL 版本控制失败得更少。对于带有转换层的大型 API,带日期的标头能更好地扩展。
同一时间应该支持多少个版本? 支持的窗口期允许的越少越好,而且绝不能是无限个。同时存在两三个版本是正常的;超过这个数字,通常就意味着版本没有被及时退役。
没有指定版本的请求应该得到什么? 应该得到受支持的最旧版本,这样现有的、未固定版本的客户端才能继续正常工作,同时附带一个响应标头,告诉它们收到的是哪个版本。
本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。