第 2 课
如何设计一个让人用得舒服的 API
API 就是一个程序提供给另一个程序的“菜单”。好的菜单一看就懂,出错时说得清楚,长列表也能处理好,而且不会突然变样。在这一课里,你会动手修复设计糟糕的请求,并看到好的版本为什么更好用。
API 设计关乎一份你要维护很多年的契约:资源建模、错误语义、在并发写入下依然可靠的分页,以及在不破坏已部署客户端的前提下演进契约。下面每个单元都需要动手操作。
第 1 单元
好好起名:API 诊所
下面每位“病人”都是一个设计糟糕的 API 请求,红色波浪线标出了问题所在。选出更健康的版本,看它被修好。
每个案例都是真实世界里常见的设计坏味道,选出更好的契约。进阶模式额外增加两个难题,在这些情况下“纯 REST”并不是正确答案。
修复前
哪个版本更好?
规则汇总
- URL 是名词:某个东西的地址(
/users/42)。方法才是动词(GET、POST、DELETE…)。 - 列表用复数名:
/orders、/orders/17。 - GET 只能读取,绝不能修改任何东西。
- 用路径表示“属于”(
/users/42/orders),用查询参数表示筛选(?sort=price)。 - 选定一种命名风格(小写),处处坚持。
设计检查清单
- 围绕资源和集合建模,让 HTTP 方法承载语义(安全、幂等、可缓存)。
- 每个拥有唯一 ID 的资源都应有一个规范的顶层 URL。嵌套只用于列表(
/teams/12/members)。 - 带副作用的状态转换可以做成显式动作:
POST /orders/42/cancel,非法转换时返回 409。 - 可组合的查询参数:
filter、sort=-created_at、limit、cursor。对未知参数返回 400。 - 契约先行(OpenAPI),并在 CI 里做 lint(例如 Spectral),让约定被强制执行,而不是靠记忆。
第 2 单元
说清楚:状态码与错误
每个响应都以一个三位数的状态码开头。程序最先读取的就是它,所以它必须正确。光看第一位数字就能知道很多:
客户端、重试、缓存、监控和告警都会根据状态码做判断。选择最具体、最正确的状态码,并返回机器可读的错误响应体(RFC 9457 问题详情)。
2xx 成功了
4xx 客户端出错了
5xx 服务器出故障了
客户端
服务器
响应
(请选择一个状态码)
糟糕的错误 vs 好的错误
✗ 难以使用
HTTP/1.1 200 OK
{ "success": false, "msg": "Error" }
- 写着 200 OK,所以程序以为成功了。
- “Error” 既没说哪里错了,也没说怎么改。
- 失败时返回 2xx,会让重试、缓存、指标和告警全部失效。
- 没有稳定的机器可读类型,客户端只能去解析字符串。
- 没有字段级别的细节,也没有供客服排查的关联 ID。
✓ 好用
HTTP/1.1 400 Bad Request
{
"error": "email is required",
"field": "email"
}
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Validation failed",
"status": 422,
"detail": "1 field is invalid",
"errors": [{ "pointer": "/email", "detail": "is required" }],
"request_id": "req_7Hk2"
}
- 光看状态码,程序就知道失败了以及失败的原因。
- 错误信息准确指出要修改哪个字段。
- 稳定的
typeURI 让客户端可以按错误类型分支处理。 - JSON pointer 把错误对应到表单字段;
request_id关联到你的日志。 - 绝不要在
detail里泄露堆栈信息或 SQL。
第 3 单元
长列表:分页
一个信息流可能有几百万条帖子,所以 API 每次只发一小部分(一“页”)。请求下一页有两种常见方式。看看在你滚动浏览时来了一条新帖子会发生什么。
在按最新优先排序、且两次翻页之间有写入的信息流上,对比 offset 分页和游标(keyset)分页。点击播放经典 bug,或者自己动手操作。
页码(offset)的意思是“跳过前 6 条,给我接下来的 3 条”。游标(cursor)就像书签:“给我 #7 之后的 3 条”。服务器数据上的彩色箭头标出了每种方式的下一页从哪里开始。
Offset:
游标 / keyset:
LIMIT 3 OFFSET 6。数据库要扫描并丢弃 6 行(O(offset)),而且只要上方有行被插入或删除,窗口就会移位。游标 / keyset:
WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 3。它利用索引,在写入时依然稳定。游标对外应保持不透明,例如用 base64 编码。
服务器数据 · 最新优先
页码(offset)Offset 分页
你已收到
书签(cursor)游标分页
你已收到
第 4 单元
安全地变更:是否破坏兼容?
一旦有应用在用你的 API,你就不能随便改了,因为它们的代码依赖旧的结构。判断每个变更是可以放心上线,还是会弄坏已有的应用。
假设客户端已经部署,而且你无法强制它们升级。基于这一前提给每个变更分类。进阶模式会加入那些真正引发线上事故的微妙情况。
✓ 可以放心上线
⚠ 破坏性变更
黄金法则:你可以新增东西,但不要改名、删除或修改它们。如果确实必须修改,让旧版本继续工作一段时间,提前通知大家,之后再删除。
演进契约
- 先扩展,再收缩:新增新字段,迁移客户端,在发出
Deprecation头并给出Sunset日期(RFC 8594)之后,再删除旧字段。 - 版本管理:URL 前缀(
/v2)简单直观;基于请求头或日期的版本(API-Version: 2026-10-01)允许按端点做更细粒度的变更。 - 宽容读取者:要求客户端忽略未知字段、处理未知枚举值。这样“新增”才真正安全。
- 在 CI 中运行契约测试(消费者驱动,例如 Pact)或 OpenAPI diff,在发布前发现破坏性变更。
第 5 单元