第 55 章 / 共 57 章 · 约 5 分钟
API 设计 3:版本演进与错误设计
API 一旦对外发布,就有你看不到的客户端依赖它:别人的手机 App、合作方的服务器脚本、三年前写完就没人维护的集成。这一章讲两件事:怎么修改 API 而不把这些客户端弄坏,以及出错时怎么告诉调用方发生了什么。案例是一个电商的订单 API。
什么样的改动会破坏兼容性
通常安全的改动:
- 新增接口;
- 请求里新增可选参数;
- 响应里新增字段(前提是客户端忽略不认识的字段,这一点要写进文档)。
破坏性改动:
- 删除或重命名字段;
- 改变字段类型或格式(比如金额从字符串改成数字,时间从秒改成毫秒);
- 把可选参数改成必填,或者收紧校验规则;
- 改变默认值或语义(比如列表默认排序从旧到新改成从新到旧);
- 改变错误码或状态码。
还有一类容易被忽略:给枚举加新值。服务端看来只是“新增”,但如果客户端用穷举的 switch 处理订单状态,遇到不认识的 partially_refunded 可能直接崩溃。文档里应该提前说明“枚举可能增加新值,请做好默认处理”。
版本策略
| 方式 | 例子 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径 | /v1/orders | 一眼可见,路由和缓存都简单 | 粒度粗,大版本升级成本高 |
| 请求头 | Accept: application/vnd.shop.v2+json | URL 保持干净 | 不直观,调试时容易忽略 |
| 日期版本 | 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"}
这是破坏性改动。可行的过渡方式:
- 发布新版本,新版本里
total是对象;旧版本的转换器把对象转回小数。 - 通过监控统计每个客户端还在用哪个版本。
- 对旧版本的响应加上
Deprecation头和Sunset头(RFC 8594,写明停止服务的日期),并通知开发者。 - 到期后,旧版本先返回明确的错误一段时间,再彻底下线。
另一种更温和的做法是新增字段而不改旧字段:保留 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 |
| 服务端 bug | 500 |
| 依赖的服务不可用、暂时过载 | 502 / 503 / 504 |
4xx 表示调用方需要改请求,原样重试没有用(429 除外,等一会儿可以重试);5xx 表示服务端的问题,可以带退避重试。
扩展讨论
- SDK:官方 SDK 固定一个 API 版本,SDK 升级时一起升级,减少开发者手动处理版本的负担。
- 契约测试:用 OpenAPI 描述接口,在 CI 里比对新旧描述,自动发现删除字段、改类型这类破坏性改动。
- 乐观并发:响应带上
ETag,修改时客户端带If-Match,版本不一致就返回 412,避免两个人同时修改时后者覆盖前者。