cswork / 系统设计

第 55 章 / 共 57 章 · 约 5 分钟

API 设计 3:版本演进与错误设计

本页目录
  1. 什么样的改动会破坏兼容性
  2. 版本策略
  3. 案例:金额从小数改成整数
  4. 错误设计
  5. 扩展讨论
  6. 章节测验

API 一旦对外发布,就有你看不到的客户端依赖它:别人的手机 App、合作方的服务器脚本、三年前写完就没人维护的集成。这一章讲两件事:怎么修改 API 而不把这些客户端弄坏,以及出错时怎么告诉调用方发生了什么。案例是一个电商的订单 API。

什么样的改动会破坏兼容性

通常安全的改动:

  • 新增接口;
  • 请求里新增可选参数;
  • 响应里新增字段(前提是客户端忽略不认识的字段,这一点要写进文档)。

破坏性改动:

  • 删除或重命名字段;
  • 改变字段类型或格式(比如金额从字符串改成数字,时间从秒改成毫秒);
  • 把可选参数改成必填,或者收紧校验规则;
  • 改变默认值或语义(比如列表默认排序从旧到新改成从新到旧);
  • 改变错误码或状态码。

还有一类容易被忽略:给枚举加新值。服务端看来只是“新增”,但如果客户端用穷举的 switch 处理订单状态,遇到不认识的 partially_refunded 可能直接崩溃。文档里应该提前说明“枚举可能增加新值,请做好默认处理”。

版本策略

方式例子优点缺点
URL 路径/v1/orders一眼可见,路由和缓存都简单粒度粗,大版本升级成本高
请求头Accept: application/vnd.shop.v2+jsonURL 保持干净不直观,调试时容易忽略
日期版本Api-Version: 2026-06-01小改动可以频繁发布,客户端按需升级服务端要维护一串转换逻辑

日期版本值得多说几句。每个账户在首次调用时固定在当时的版本;之后每次不兼容的改动,都发布一个带日期的新版本,同时写一个“把新格式转回旧格式”的转换器。服务端核心逻辑只处理最新格式,响应在返回前依次经过转换器,降级到该账户固定的版本:

按版本转换响应:核心逻辑只写最新版,旧版本由一串转换器降级
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart LR
  accTitle: 按版本转换响应:核心逻辑只写最新版,旧版本由一串转换器降级
  req["请求,账户固定在 2025-03-01 版"] --> core["订单处理逻辑,只产出最新格式"]
  core --> t2["转换器:2026-06-01 改回上一版"]
  t2 --> t1["转换器:2025-09-15 改回上一版"]
  t1 --> res["响应:2025-03-01 格式"]

这样核心代码里没有一堆 if version < ...,每次改动的兼容逻辑都集中在一个转换器里。

案例:金额从小数改成整数

订单 API 的 v1 用小数表示金额,后来发现浮点数有精度问题,也没有币种:

// v1
{"id": "ord_51", "total": 19.99, "status": "shipped"}

// v2: money becomes an integer in minor units plus a currency
{"id": "ord_51", "total": {"amount": 1999, "currency": "USD"}, "status": "shipped"}

这是破坏性改动。可行的过渡方式:

  1. 发布新版本,新版本里 total 是对象;旧版本的转换器把对象转回小数。
  2. 通过监控统计每个客户端还在用哪个版本。
  3. 对旧版本的响应加上 Deprecation 头和 Sunset 头(RFC 8594,写明停止服务的日期),并通知开发者。
  4. 到期后,旧版本先返回明确的错误一段时间,再彻底下线。

另一种更温和的做法是新增字段而不改旧字段:保留 total,新增 total_money。代价是响应里会长期留着两个意思相同的字段。

错误设计

错误响应要让程序能处理,人能看懂。推荐的格式是 RFC 9457 定义的 Problem Details(application/problem+json),再加上自己的扩展字段:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "The request has invalid fields",
  "status": 422,
  "detail": "2 fields failed validation.",
  "code": "validation_failed",
  "request_id": "req_7Hq2",
  "errors": [
    {"field": "items[0].quantity", "code": "too_small", "message": "Must be at least 1."},
    {"field": "shipping.postcode", "code": "invalid_format", "message": "Not a valid postcode."}
  ]
}
  • code 是给程序判断的稳定标识,一经发布就不改;title、detail 和 message 是给人看的,可以改措辞,也可以翻译。客户端绝不应该匹配错误文案。
  • errors 列出每个字段的问题,表单可以把错误显示在对应的输入框旁边。
  • request_id 方便用户联系客服时定位日志。

状态码的选择:

情况状态码
JSON 格式错误、缺少必要参数400
格式正确但内容不合法(数量为 0)422
没有登录或令牌无效401
已登录但无权访问403
资源不存在(或者不想暴露它存在)404
和当前状态冲突(取消已发货的订单)409
条件请求失败(If-Match 的版本已过期)412
请求太频繁429
服务端 bug500
依赖的服务不可用、暂时过载502 / 503 / 504

4xx 表示调用方需要改请求,原样重试没有用(429 除外,等一会儿可以重试);5xx 表示服务端的问题,可以带退避重试。

扩展讨论

  • SDK:官方 SDK 固定一个 API 版本,SDK 升级时一起升级,减少开发者手动处理版本的负担。
  • 契约测试:用 OpenAPI 描述接口,在 CI 里比对新旧描述,自动发现删除字段、改类型这类破坏性改动。
  • 乐观并发:响应带上 ETag,修改时客户端带 If-Match,版本不一致就返回 412,避免两个人同时修改时后者覆盖前者。

章节测验

3 道选择题
  1. 下面哪个改动对已经上线的客户端是破坏性的?
  2. 用户想取消一个已经发货的订单,接口应该返回什么状态码?
  3. 为什么错误响应里要有稳定的 code 字段,而不是让客户端去匹配错误文案?

章节讨论

正在加载讨论…