19.1 HTTP API:资源、语义、并发与可恢复请求
一个稳定 HTTP API 不是把内部方法映射成 URL,而是为调用方提供长期可理解的资源标识、方法语义、失败模型和并发规则。
URI 标识资源,方法表达意图
GET /tournaments/{id}
POST /tournaments
PUT /tournaments/{id}
PATCH /tournaments/{id}
DELETE /tournaments/{id}本课 HTTP 块只展示相关方法、路径和头部,省略协议版本、Host、认证和请求体,不是完整的网络报文。URI 使用业务名词,不暴露数据库表或控制器类。集合和单个资源的层级应保持一致。
不是所有业务动作都适合伪装成 CRUD。对“结算比赛”这种有独立规则、权限和审计的命令,可以建动作资源:
POST /matches/8472/settlements
Idempotency-Key: "7c62bb24-0658-4fa7-9ec1-e532f6e7e921"返回创建出的结算资源,比 POST /matches/8472?action=settle 更容易查询状态、去重和审计。
Safe 与 Idempotent 不同
- safe:客户端不请求改变服务器状态,典型是 GET、HEAD;
- idempotent:相同请求执行多次的预期效果与执行一次相同,典型是 PUT、DELETE 和 safe 方法;
- POST 并非按方法语义天然幂等,但具体 API 可以借助幂等键实现业务幂等。
幂等不表示响应每次完全相同,也不表示服务器不能记录日志。它约束调用方请求的预期效果。
GET 不应产生扣费、删除等业务副作用,因为预取器、爬虫和缓存可能自动执行 GET。
POST 幂等键要绑定请求身份
服务端处理幂等键时应保存:
- 租户、调用者和业务操作范围;
- 幂等键;
- 规范化请求摘要;
- 处理状态和最终响应;
- 过期时间。
本例 API 约定:同一租户、调用者与操作范围内,相同键配不同请求摘要返回 409,不复用第一次响应或执行第二次操作。具体状态码和保留期属于接口契约,不能仅凭请求头名称推断。摘要应覆盖方法、目标资源和规范化 payload;每次返回已保存结果前仍要授权。
(tenant, caller, operation, key) 首次出现 → 原子登记执行中,再执行并保存结果
相同键 + 相同请求摘要 → 返回已保存结果
相同键 + 不同请求摘要 → 409 Conflict登记执行权必须依靠唯一约束或条件写入,不能用“先查不存在,再插入”抵挡并发。对于本地数据库操作,去重状态与业务变更应同事务提交;外部副作用则需要稳定操作 ID 和结果查询。第一次请求仍在执行或结果未知时,返回可查询状态,不能并发执行第二份副作用。保留期到期后再用同一键,可能被当作新请求,客户端重试窗口必须与它一致。
状态码表达客户端下一步
| 情况 | 常用状态 |
|---|---|
| 创建完成 | 201 + Location |
| 异步接受处理 | 202 + 状态资源 |
| 成功且无响应体 | 204 |
| 请求语法/结构错误 | 400 |
| 缺少有效认证凭据 | 401 + WWW-Authenticate |
| 已认证但无权限 | 403 |
| 资源不存在 | 404 |
| 当前状态冲突 | 409 |
| 条件请求失败 | 412 |
| 接口要求条件请求但未提供 | 428 |
| 语义校验失败 | 422 |
| 速率限制 | 429 + 重试提示 |
同一个错误在不同上下文可能映射不同状态。重点是语义稳定、文档清楚,客户端不必解析自然语言消息判断下一步。
202 只表示已接受,不保证最终成功,状态资源要继续报告完成或失败;429 可以通过 Retry-After 给出等待提示,但仍须遵守客户端总预算。若不希望暴露受保护资源是否存在,也可按统一策略返回 404。
错误响应可以遵循 Problem Details;下例 HTTP 状态为 409,媒体类型为 application/problem+json:
{
"type": "https://api.example/problems/tournament-full",
"title": "Tournament is full",
"status": 409,
"detail": "No confirmed slots remain.",
"instance": "/requests/req-123",
"code": "TOURNAMENT_FULL"
}type 标识问题类型,instance 标识本次问题实例,code 是本 API 的扩展字段。正文中的 status 应与实际 HTTP 状态一致;客户端不能靠解析 detail 来分支。不要在 detail 中泄露 SQL、堆栈、内部主机或密钥。
用条件请求防止丢失更新
读取资源时返回版本验证器:
ETag: "tournament-8472-v9"更新时要求调用者提交自己看到的版本:
PATCH /tournaments/8472
If-Match: "tournament-8472-v9"本 API 要求缺少条件时返回 428,与当前 v10 不匹配时返回 412。If-Match 使用强比较,W/"..." 弱 ETag 不能满足这里的版本匹配。比较与写入必须原子完成,例如数据库条件更新;先在应用里比较,再无条件写入,仍会丢失更新。
示例标签假设表示内容变化都会推进版本;若语言、媒体类型或关联数据变化也改变表示,就必须计入验证器,不能把所有字节不同的结果都标成同一个强 ETag。业务版本也可通过显式字段与 409 表达,但整套 API 要一致。
分页需要稳定顺序
大集合不要默认一次返回全部。偏移分页易理解,但深页成本高,且并发插入会造成重复或遗漏。游标分页把稳定排序键编码为不透明游标:
GET /matches?limit=50&after=opaque-server-issued-cursor排序必须唯一,例如 (settled_at DESC, id DESC)。游标应绑定过滤条件、排序版本和必要的租户范围,并进行签名或完整性保护,不能让客户端随意篡改内部位置。
例如上一页最后一条的键为 (10:00, 8472),降序下一页只取键严格小于它的记录;同一时间下 ID 为 8471 的记录仍能进入下一页。排序键需要非空且稳定,并结合匹配的索引,否则游标并不自动带来低成本。
响应返回 next 链接或 nextCursor,并说明快照一致性。单纯 keyset 分页不是快照:新增记录可能位于游标之前而不再出现,修改排序键也可能导致遗漏或重见。若要求固定结果集,需要额外的快照或版本边界。游标即使已签名,每次请求仍须重新执行资源授权。
API 边界检查
- URI 是否表达业务资源,而不是内部实现?
- 方法是否遵守 HTTP safe/idempotent 语义?
- 写请求超时后,客户端如何查询和安全重试?
- 错误是否机器可判定且不泄露内部信息?
- 并发更新是否有条件请求或业务版本控制?
- 分页是否有稳定唯一排序和清晰一致性?
下一课把这些语义写成机器可读契约,并讨论 HTTP、gRPC 和消息 API 的兼容演进。