Skip to content
Go back

API 版本控制应该是你最后的手段

API 版本控制应该是你最后的手段

每个 API 团队迟早都会伸手去拿同一个逃生出口:

“这是破坏性变更,我们直接做 v2 吧。”

听起来负责任。但实际效果是:你现在要维护两套 API、两份文档、两种行为差异,以及一个客户端会无限期推迟的迁移项目。

Milan Jovanović 在这篇文章里说得很直接:

版本控制是兼容性工具,不是设计策略。

大多数 API 变更不需要新版本,它们需要的是更好的变更管理。这两件事的区别,决定了你的 API 是越来越难维护,还是能平稳演进很多年。

什么真正会破坏客户端

很多人以为,只要不改 URL 就没有破坏性变更。实际上,客户端在以下几种情况下会挂掉:

这个例子很能说明问题:

// 原来
{ "total": 100 }

// 之后
{ "total": { "amount": 100, "currency": "USD" } }

路径没变,端点名没变,但你把一个数字字段改成了对象。所有读取 total 的客户端直接挂了。

所以问题不是”这个变更要不要升版本”,而是”新旧契约能不能安全共存?“

四条兼容性规则

这四条规则直接来自 Z. Nemec 的 API 变更管理文章,是判断一个变更是否可以向后兼容的核心框架。

规则一:增加,而不是替换

最安全的变更是加法。如果要给字段增加更丰富的语义,不要把旧字段改成新形状——加一个新字段,让新旧客户端各取所需。

GET /orders/{id}

// 原来
{
  "id": "ord_123",
  "status": "paid",
  "total": 100
}

// 演进后
{
  "id": "ord_123",
  "status": "paid",
  "total": 100,
  "totalMoney": {
    "amount": 100,
    "currency": "USD"
  }
}

旧客户端继续用 total,新客户端迁移到 totalMoney,你把旧字段标记为 deprecated,等真正的迁移窗口结束后再删。

有时候这样做契约会变得不那么优雅。但兼容性的代价,有时候就是一个不那么好看的契约。

规则二:让客户端能容忍新字段

服务端加了一个字段,行为良好的客户端不应该崩溃。

如果响应从这个演变:

{ "id": "ord_123", "status": "paid" }

变成这个:

{ "id": "ord_123", "status": "paid", "estimatedDeliveryDate": "2026-05-29" }

旧客户端应该忽略 estimatedDeliveryDate 然后继续工作。

在 .NET 里,System.Text.Json 默认忽略未知字段,这个行为是对的。真正的风险在于过于严格的自动生成 SDK,或者断言 JSON 精确相等的契约测试。

Milan 把这个问题说得很准:

团队声称要向后兼容,却生成了一旦响应里出现意外字段就报错的客户端模型。那不是兼容策略,那是给自己挖坑。

服务端应该自由地添加可选数据,客户端应该有足够的弹性来忽略它不理解的内容。

规则三:不要改变已有操作的行为

这是最容易被低估、也最危险的一类变更。URL 没变,请求体没变,响应结构没变,但服务端对这个操作做的事情变了。

DELETE /orders/{id} 为例。

最初上线时,这个端点是软删除:订单移入归档状态,还在数据库里,还出现在审计报告里,支持团队可以恢复。

客户端建立的契约不只是 HTTP 动词和路径,而是完整的行为:

几个月后团队觉得软删除太麻烦,悄悄把 DELETE /orders/{id} 改成了硬删除:

Nemec 的规则说的就是这个:一旦一个操作上线,它的行为就是契约的一部分,哪怕从来没有写在任何文档里。

类似的隐蔽破坏还有很多:

每一种都保持了 URL、动词和 JSON 结构不变,但都会在 schema diff 里看不出来。

安全做法还是同一个:增加,不要修改。如果你需要硬删除,新开一个操作:

DELETE /orders/{id}            # 保持不变,软删除
DELETE /orders/{id}?purge=true # 新增,显式硬删除

或者引入新资源:DELETE /orders/{id}/purge,让破坏性操作有自己的名字和权限。

规则四:对校验改动保持谨慎

这里有两种常见错误:

  1. 把原本可选的字段变成必填
  2. 新加一个字段,直接要求必填

两种都会以同样的方式破坏旧客户端——端点路径不动,但原来能成功的请求开始在运行时报错。

POST /orders 为例:

// 昨天这个请求有效
{ "customerId": "cus_123", "currency": "USD" }

// 今天要求必须传 country 用于税务计算
{ "customerId": "cus_123", "currency": "USD", "country": "US" }

更安全的路径是:为旧客户端接受缺省值,尽可能从已有数据中推断默认值,或者为更严格的流程开一个新操作。

响应变更通常会经过仔细的设计评审,请求校验的变更也应该如此。

新端点往往比新版本便宜

有时候确实是场景变了,在一个老端点上堆 flag 和可选参数会越来越混乱。比如:

POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=true

这时候你有的不是一个干净的操作,而是多个业务流程藏在同一个端点后面。

这种情况下,新操作或新资源通常比升整个 API 版本便宜得多:

这样旧契约稳定,新行为有干净的落脚点,而不需要把整个 /v2/orders 以及 API 里其他所有东西都拖着一起升级。

真正的废弃是有运营动作的

大多数废弃是虚的——出现在文档里,但什么运营动作都没有。

一个真正的废弃流程应该包含四件事:

  1. 在 OpenAPI 描述里标记字段或端点为 deprecated

  2. 在运行时通过响应头发出信号

    Deprecation: true
    Sunset: Sat, 01 Jan 2027 00:00:00 GMT
    Link: <https://api.example.com/docs/migration>; rel="deprecation"
  3. 给调用方一个清晰的迁移路径

  4. 在真正收到足够少的流量之前,不删除任何东西

如果你不知道哪些客户端还在使用已废弃的字段或端点,你就不是在管理变更,你是在猜。

按 client ID、API key、租户或应用名追踪使用量,等到实际使用量接近零,再考虑删除。

什么时候升版本才是正确的

Milan 很直接地说:他不反对版本控制。

以下情况才应该升版本:

**升版本要刻意,而不是本能反应。**刻意意味着选择你能证明的最小破坏面——有时候是新的端点形状,有时候是表示变体,有时候(尤其对公共 API 来说)就是直接的 URL 版本号,因为它直观且好沟通。

关键不在于你选择哪种机制,而在于你是因为共存真的失败了才升的版,而不是因为这是第一个想到的办法。

升版本时,搭配一个真实的废弃流程:

真正的工作不是创建 v2,而是让用户从 v1 迁移出去。

一个决策规则

如果想要一个简单的判断依据:

老的和新的能不能在一段迁移期内安全共存?

如果是,大概不需要新版本。如果不是,新旧世界真的无法并存,那就刻意地升版本。

把契约设计成可以演进的,把调用方当成长期集成伙伴,把版本控制留给兼容性真正耗尽的时候。

参考


Tags


Next

用 AI Agent 把自然语言转成 SQL:三种方案的实验对比