API 版本管理策略 是做什么的?
TL;DR: 对于长寿命的公共 API,基于日期为每个 API 密钥固定版本的策略(Stripe 模型)优于
/v1/、/v2/,因为它允许进行小范围的破坏性变更,而无需强制客户端迁移到新的版本树。对于内部 API,增量演进加上Deprecation和Sunset标头(RFC 9745, RFC 8594)通常足够。始终进行通告,始终在指定日期废弃,永远不要直接移除。
根据你的问题跳转
| 症状 | 章节 |
|---|---|
| "需要破坏一个字段,但有 10 万个 API 密钥" | 基于日期固定的版本 |
"应该使用 /v1/ 还是 Accept: application/vnd.foo.v2+json?" | 策略比较 |
| "如何告知客户端一个端点即将被移除?" | Deprecation + Sunset |
| "在代码中维护 6 个版本会让我们崩溃" | 版本转换器 |
| "内部 API —— 我们甚至需要版本管理吗?" | 内部 vs 公共 |
决策图
flowchart TD
A[需要更改 API 行为] --> B{仅增量?<br/>新字段、新端点、新可选参数}
B -->|是| C[直接发布<br/>无需版本管理]
B -->|否,破坏性变更| D{API 是公共的<br/>+ 有许多不透明的客户端?}
D -->|否,内部/少量客户端| E[协调迁移<br/>+ Deprecation/Sunset 标头]
D -->|是,公共| F{能否为每个请求<br/>维护兼容层?}
F -->|是,有转换器| G[基于日期固定的版本<br/>Stripe 模型]
F -->|否,需要重写| H[主版本 URI 提升<br/>/v1 → /v2]
E --> I[设置 Deprecation: @timestamp<br/>+ Sunset: HTTP-date<br/>+ Link: rel=deprecation]
G --> I
H --> I
1. 基于日期固定的版本(Stripe 模型)
Stripe 在其文章 API versioning at Stripe 中发布了其架构:
当用户第一次发出 API 请求时,其账户会自动固定到最新可用的版本,此后,该用户的所有 API 调用都隐式地使用该版本。
[版本是] 以发布日期命名的滚动版本(例如
2017-05-24)。
关键特性:
| 特性 | 为什么有效 |
|---|---|
| 每个账户的默认版本(在首次调用时设置) | 新客户自动使用最新版本;现有客户在推送更改时不会中断 |
Stripe-Version 标头可覆盖每次请求的固定版本 | 允许客户端逐步迁移:SDK 可以在账户升级之前选择使用新版本 |
基于日期的名称(2024-04-10) | 传达时效性;无需争论 "v3" 的含义;允许任意多次的小破坏,而不是将它们累积到一次大的 v3 版本中 |
| 仪表盘升级路径 | 客户可以预览差异,然后升级其账户的默认版本 |
这是唯一一种可以扩展到真正长寿命公共 API 的方法(Stripe 的版本可以追溯到十年前)。它会增加你的内部复杂性(第 4 节中的转换器模式),但能免去你的客户无休止的 /v1/ → /v2/ 迁移周期。
2. 版本管理策略比较
| 策略 | 版本位置 | 优点 | 缺点 | 最佳适用场景 |
|---|---|---|---|---|
URI 路径(/v1/、/v2/) | 路径 | 缓存友好(不同 URL = 不同缓存条目);零客户端配置 | 无法进行小破坏;强制创建版本分支;URL 不再是 "稳定的资源标识符"(按 Fielding 观点) | 内部 API、很少破坏的公共 API |
自定义标头(Stripe-Version: 2024-04-10) | 请求标头 | 可轻松支持大量版本;每次请求的细粒度控制 | 无法在 URL 中直接体现版本;在浏览器地址栏中测试更困难 | 大型公共 API |
Accept 媒体类型(Accept: application/vnd.foo.v2+json) | 标准 Accept 标头 | 符合 HTTP 规范;重用内容协商机制 | 设置起来别扭;工具支持不一;通过 curl 调试时很冗长 | 超媒体 API、深度 RESTful 设计 |
查询参数(?version=2) | URL | 易于测试;在日志中可见 | 污染 URL;语义上不正确(版本不是资源属性) | 仅用于快速原型开发 |
| 无版本管理,仅增量 | 无 | 零开销;无需客户端协调 | 没有新端点就无法进行破坏性变更 | 共享部署的内部微服务 |
根据你的约束条件选择:
- "我们频繁进行小破坏" → 基于日期的标头(Stripe)
- "我们每 5 年进行一次大重写" → URI 路径(
/v1/、/v2/) - "我们从不破坏,只添加" → 无版本管理 + 增量演进
3. Deprecation/Sunset 标头对
两个 IETF RFC 涵盖了生命周期信号:
Sunset 标头 —— RFC 8594
Sunset 的值是一个 HTTP-date 时间戳,定义于 [RFC7231] 的 7.1.1.1 节,并且应该是一个未来的时间戳。
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
表示“资源预计将在未来的某个特定时间点变得不可用”。客户端应该将此时间戳视为一个提示,而非硬性承诺。
Deprecation 标头 —— RFC 9745
Deprecation: @1735689599
Sunset: Sun, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v1-to-v2>; rel="deprecation"
Deprecation 的值是一个 Unix 时间戳(秒,根据结构化字段规范以 @ 为前缀)。它可以是过去的时间(“已废弃”)或未来的时间(“将废弃”)。必须遵循的规则:
- 必须使用 RFC 9651 中定义的结构化字段日期格式
Sunset不能早于Deprecation—— 规范明确指出了这一时间顺序约束- 应该包含一个带有
rel="deprecation"的Link标头,指向迁移文档
发送 Deprecation 标头的行为 不会 改变资源的行为——它只是一个信号,而非降级。服务器应该保持资源在 Sunset 日期之前功能正常(紧急情况除外)。
推荐时间线
T+0 首次在生产环境发送 Deprecation 标头
T+30d 发布公开通告(更新日志、邮件、文档)
T+90d 添加响应日志/指标,监控已废弃路径的使用情况
T+180d 设置 Sunset 日期,设为 90 天后
T+270d Sunset 日期到来 —— 端点返回 410 Gone 并附带迁移链接
对于主版本固定的 API 中的次要破坏性变更:中位数为 6 个月。对于完整的 v1 → v2 废弃:12-24 个月。
4. 版本转换器模式
Stripe 的内部架构解决了“我们在生产环境中有 50 个版本”的问题:
[该系统使用] API 资源类来定义当前的 API 响应结构,并结合版本变更模块来封装不向后兼容的转换。处理响应时,系统反向遍历时间线,并应用沿途找到的每个版本变更模块,直到达到目标版本。
结构示例:
// resource: 规范(最新)表示
const charge = {
id: 'ch_123',
amount: 1000,
payment_method_details: { card: { brand: 'visa', last4: '4242' } },
}
// 每次破坏性变更是一个转换器模块:
const v_2024_03_01 = {
// 移除 payment_method_details 下的 'card' 嵌套,展平字段
apply(resource) {
return {
...resource,
card_brand: resource.payment_method_details?.card?.brand,
card_last4: resource.payment_method_details?.card?.last4,
}
},
}
const v_2023_10_15 = {
// 将 'amount' 重命名为 'amount_cents'
apply(resource) {
const { amount, ...rest } = resource
return { ...rest, amount_cents: amount }
},
}
// 管道按逆时间顺序应用转换器,直到达到请求的版本
function transform(resource, requestedVersion) {
const chain = transformers.filter(t => t.date > requestedVersion)
return chain.reduceRight((r, t) => t.apply(r), resource)
}
优点:
- 核心代码保持现代 —— 每个端点都针对最新模式编写;业务逻辑中无需散布
if (version < ...)语句。 - 每次变更都是一个文件 —— 易于审查、隔离测试,并且在最后一个用户跨越该版本后易于删除。
- 可观测性 —— 你可以统计每天触发哪些转换器,以便根据实际使用情况规划废弃。
代价:构建转换器框架。如果你每年有超过 3 次破坏性变更且超过 1 万个 API 消费者,那么值得投入;对于少于 50 个客户的内部 API 则显得大材小用。
5. 内部 vs 公共 API
这种不对称性很重要:
| 内部 | 公共 | |
|---|---|---|
| 是否可以协调部署? | 可以 —— 原子级替换 | 不可以 —— 客户端独立部署 |
| 模式演进 | 增量 + Slack 消息 | 严格版本管理 + 邮件 + 仪表盘 + 博文 |
| 废弃窗口 | 数天到数周 | 6-24 个月 |
| 版本管理需求 | 通常不需要 —— 仅需 Deprecation 标头 | 必须 |
| 最佳策略 | 增量演进;仅重写时使用 URI 版本 | 基于日期的标头(如果规模合适),URI 用于主要重写 |
陷阱:公司将所有 API 都当作“公共”API 来对待,为仅由 3 个内部服务使用的 API 构建了繁重的版本管理基础设施。反之亦然:一个泄露的内部 API 有了外部消费者,你实际上无法破坏它。
防御措施:为服务附加一个二进制的 "public" 标签。公共服务获得完整的版本管理流程;内部服务则不需要,但会受到更严格的调用方监控。
反模式
| 反模式 | 为什么有害 | 修复方法 |
|---|---|---|
为了一个破坏性变更将 /v1/ 提升到 /v2/ | 强制客户端为了一个字段迁移所有内容 | 基于日期的版本 OR 增量演进 |
移除端点而不发送 Sunset 标头 | 客户端无预警中断;支持工单激增 | 始终在移除前 90 天以上配合发送 Deprecation 标头 |
发送 Deprecation 标头但未提供 Link 标头 | 客户端知道已废弃但不知道该怎么办 | 始终包含 Link: <migration-doc>; rel="deprecation" |
| 为每个客户硬编码版本豁免 | 变得不可维护;无法推断行为 | 使用转换器模式;“豁免”变为版本固定 |
| 像管理公共 API 一样管理内部 API | 流程开销无回报 | 增量演进 + 原子部署 + Slack |
在 REST API 上使用语义化版本(v1.2.3) | 暗示了不适用(对于 JSON schema 什么是“补丁”?) | 基于日期的名称或仅主版本(v1) |
| 设置废弃日期但未强制执行 | 端点永远存在,代码腐烂 | 日历提醒;CI 测试在废弃日期前 7 天失败以强制行动 |
新手 / 专家 / 时间线
| 新手 | 专家 | |
|---|---|---|
| 第一次破坏性变更 | 将 /v1 提升到 /v2,复制粘贴整个代码树 | 考虑基于日期的固定版本,评估变更是否真的是破坏性的 |
| 告知客户端废弃 | 群发邮件 | Deprecation + Sunset 标头 + Link + 邮件 + 仪表盘横幅 |
| 维护 5 个版本 | 每个控制器中有 5 个 if/else 分支 | 转换器模块,最新的核心代码 |
| 废弃强制执行 | "我们回头再处理" | CI 中的废弃日期;端点在该日期变为 410 Gone |
| 内部 API 变更 | 和公共 API 一样的流程 —— 缓慢 | 增量演进 + 原子部署;保留繁重流程用于公共 API |
时间线测试:从“我们想移除字段 X”到“字段 X 在生产环境中消失”需要多长时间?一个专家团队有一个单一的可重复答案(例如,90 天)。一个新手团队因为没有流程而无法回答。
质量门禁
版本管理更改在满足以下条件时发布:
- 测试: 移除或重命名任何外部可见的字段必须建立在至少 N 天前(N 为文档化的废弃窗口)的废弃通告之上。
- 测试: 每个已废弃的端点都要发送
Deprecation: @<unix-ts>和Sunset: <HTTP-date>标头,并在集成测试中验证。 - 测试:
Sunset值绝不能早于Deprecation值(根据 RFC 9745 必须遵守的规则)。 - 测试: 每次废弃通告都包含一个
Link: <docs-url>; rel="deprecation"标头,并且在该 URL 上存在一份公开的迁移文档(通过 CI 的 HEAD 检查)。 - 测试: 每个版本都有使用遥测数据——在删除 v2 之前,你可以回答“昨天 v2 路径有多少请求?”这个问题。
- 测试(仅限基于日期固定): 新的 API 密钥默认为最新版本;现有密钥在部署期间保持其固定版本。
- 手动: 废弃强制执行计划:一个日历提醒、一个 CI 检查或一个自动化的 410 Gone 响应——当日期到来时,必须有东西触发。
本技能不涵盖的内容
- GraphQL schema 演进(请使用
graphql-schema-evolution) - gRPC / Protobuf 线上兼容性(请使用
protobuf-evolution-rules) - 数据库 schema 版本管理(请使用
zero-downtime-database-migration) - 库 / SDK 语义化版本(请使用
npm-package-versioning) - 事件流 schema 演进(请使用
kafka-schema-registry-design)
来源
- Stripe: API versioning at Stripe —— 基于日期的固定版本、转换器模式
- Stripe API: Idempotent requests / versioning policy
- IETF: RFC 8594 — The Sunset HTTP Header Field
- IETF: RFC 9745 — The Deprecation HTTP Header Field
- IETF: RFC 9651 — Structured Field Values for HTTP(Deprecation 标头的日期格式)
- Roy Fielding: REST APIs must be hypertext-driven —— 反对 URI 版本管理的权威论证