API 变更

面向调用方设计的 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 版本控制

在实践中,一个版本就是一组有名字的行为集合,服务端会把每一个请求映射到其中一组上。无论哪种方案来承载这个名字,具体步骤都是一样的。

  1. 用日期或整数来命名版本,而不是用语义化版本号。 web API 不是一个软件包。调用方无法固定一个 URL 的次版本,所以 v2 或 2026-08-26 已经能说清调用方所需要的一切,而语义化版本控制的编号,则会暗示一种这套方案根本无法兑现的兼容性承诺。
  2. 让版本远离那些不需要关心它的代码路径。 版本应该只在边缘处选择一个转换层,而不应该分叉业务逻辑本身。整套代码库维护两份完整的拷贝,正是一个版本最终变得无人维护的方式。
  3. 给每个版本都设定一个默认值和一份文档。 没有指定版本的调用方,应该得到受支持的最旧版本,而不是最新版本,这样一个没有固定版本的客户端才不会在你发布的当天就出问题。每个版本都应该有一个页面,说明相比上一个版本发生了什么变化。
  4. 设定一个支持窗口期,并公开发布它。 Google 的版本控制指南 AIP-185 要求提供一个合理且沟通充分的过渡期,并建议即使是 beta 功能也留出 180 天。选定一个窗口期,把它写下来,然后不再按每个版本重新谈判地应用它。
  5. 像退役端点一样退役版本。 一个超过了窗口期的版本,应该得到和任何已停用的 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,带日期的标头能更好地扩展。

同一时间应该支持多少个版本? 支持的窗口期允许的越少越好,而且绝不能是无限个。同时存在两三个版本是正常的;超过这个数字,通常就意味着版本没有被及时退役。

没有指定版本的请求应该得到什么? 应该得到受支持的最旧版本,这样现有的、未固定版本的客户端才能继续正常工作,同时附带一个响应标头,告诉它们收到的是哪个版本。


本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。

changeloop 相关页面: 开发者文档, changelog 示例

changeloop
打造闭环 changelog 的团队。用户提出需求,你的团队交付,提出需求的人得知结果。