跳到内容

第 6 卷 / 第 19 章 / 第 1 课

19.1 HTTP API:资源、语义、并发与可恢复请求 ​

一个稳定 HTTP API 不是把内部方法映射成 URL,而是为调用方提供长期可理解的资源标识、方法语义、失败模型和并发规则。

URI 标识资源,方法表达意图 ​

text
GET    /tournaments/{id}
POST   /tournaments
PUT    /tournaments/{id}
PATCH  /tournaments/{id}
DELETE /tournaments/{id}

本课 HTTP 块只展示相关方法、路径和头部,省略协议版本、Host、认证和请求体,不是完整的网络报文。URI 使用业务名词,不暴露数据库表或控制器类。集合和单个资源的层级应保持一致。

不是所有业务动作都适合伪装成 CRUD。对“结算比赛”这种有独立规则、权限和审计的命令,可以建动作资源:

http
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;每次返回已保存结果前仍要授权。

text
(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:

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、堆栈、内部主机或密钥。

用条件请求防止丢失更新 ​

读取资源时返回版本验证器:

http
ETag: "tournament-8472-v9"

更新时要求调用者提交自己看到的版本:

http
PATCH /tournaments/8472
If-Match: "tournament-8472-v9"

本 API 要求缺少条件时返回 428,与当前 v10 不匹配时返回 412。If-Match 使用强比较,W/"..." 弱 ETag 不能满足这里的版本匹配。比较与写入必须原子完成,例如数据库条件更新;先在应用里比较,再无条件写入,仍会丢失更新。

示例标签假设表示内容变化都会推进版本;若语言、媒体类型或关联数据变化也改变表示,就必须计入验证器,不能把所有字节不同的结果都标成同一个强 ETag。业务版本也可通过显式字段与 409 表达,但整套 API 要一致。

分页需要稳定顺序 ​

大集合不要默认一次返回全部。偏移分页易理解,但深页成本高,且并发插入会造成重复或遗漏。游标分页把稳定排序键编码为不透明游标:

http
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 的兼容演进。

参考资料 ​

Built with VitePress | Software Systems Atlas