← 返回博客首页

REST API 设计最佳实践:资源建模、幂等性与版本管理

API 是产品,不是数据库的镜像

一个常见的失败模式:把数据库的每张表直接暴露成一个 CRUD 端点,于是得到一堆 /getUsers/createOrder/updateUserStatus 这样的接口。它「能用」,但每接入一个客户端就要写一份适配逻辑,改一次表结构就要通知所有调用方。

REST 的价值不在「看起来规范」,而在于让接口在不用读文档的情况下被猜对。要达到这个目标,需要先从资源建模开始,而不是从 URL 字符串开始。

调试响应体和对照字段时,把返回的 JSON 丢进 JSON 格式化工具 是最快的一步;结构复杂时用 JSON 查看器 折叠展开比在终端里滚屏高效得多。

一、资源建模:URL 设计的第一性原则

用名词,不用动词

URL 描述的是资源,HTTP 方法描述的是动作。把动词写进 URL,等于放弃了 HTTP 已经提供好的语义层。

反例 正例 说明
GET /getUsers GET /users 动作已由 GET 表达
POST /createOrder POST /orders 创建由 POST 表达
POST /deleteUser/123 DELETE /users/123 删除必须用 DELETE,否则中间层无法判断安全性
GET /getUserOrders?uid=1 GET /users/1/orders 层级关系放进路径,而非查询参数

层级表达关系,但别超过两层

/users/1/orders/2/items/3 这种 URL 技术上合法,但它把客户端锁死在一条固定的遍历路径上,而且每一层都要做「父资源是否存在」的校验。

经验规则

  • 关系唯一且强归属时用层级:/users/1/orders
  • 超过两层就扁平化,用查询参数过滤:/orders?userId=1&status=paid
  • 资源如果有全局唯一 id,就允许它作为顶层资源直接访问:/orders/2,不要强制从父资源进入

命名一致性

规则 示例 原因
集合用复数名词 /orders/users 与「集合」语义一致,避免单复数混用
全小写 /order-items 而非 /orderItems URL 的部分区间大小写敏感,混用必然出错
多词用连字符 /order-items 而非 /order_items 连字符在 URL 中无需转义,可读性最好
不要在末尾加斜杠 /orders 而非 /orders/ 两者在部分框架里是两个不同的路由

非 CRUD 的动作怎么办

总有一些操作映射不到 CRUD(如「发布」「取消」「批量重算」)。三种方案,按优先级:

  1. 把它建模成一个资源POST /orders/1/cancellations ——「取消」变成了「创建一条取消记录」,语义干净且可追溯。
  2. 把它建模成资源上的一个状态字段PATCH /orders/1{ status: cancelled }
  3. 实在不行才用动词后缀POST /orders/1:publish。可以接受,但要在文档里明确标注它不是资源。

二、HTTP 方法:语义与幂等性

方法 语义 安全(无副作用) 幂等 典型响应
GET 读取资源 200 + 资源
HEAD 只要响应头 200 无 body
POST 创建或从属操作 201 + Location
PUT 完整替换(可创建) 200 / 201 / 204
PATCH 局部更新 默认否 200 / 204
DELETE 删除 204 / 200
OPTIONS 询问支持的方法 200 + Allow

完整的语义与副作用对照见本站的 HTTP 方法速查表

幂等性不是学术概念

幂等 = 同一个请求执行 N 次,服务端状态与执行 1 次相同。

它之所以关键,是因为重试一定会发生:移动端网络切换、网关超时重放、消息队列至少一次投递、用户双击按钮。任何非幂等的写接口,在没有额外保护时都会产生重复数据。

最常见的两个坑:

  • 用 POST 创建订单:用户点两次就是两笔订单。解法是客户端生成 Idempotency-Key 请求头(用 UUID 生成器 即可),服务端把它作为唯一键保存 24 小时,命中重复直接返回首次结果。
  • 用 PATCH 做相对增减{ balanceDelta: -100 } 重试两次就扣了两百。改成提交绝对值 { balance: 900 },幂等性自动成立。

POST 返回什么

创建成功返回 201 Created,并在 Location 头带上新资源的完整 URL:

HTTP/1.1 201 Created
Location: /orders/9f3c...

如果创建是异步的(进入队列处理),返回 202 Accepted 加一个状态查询地址,不要用 200 假装已经完成。

三、状态码:分层,而不是一切 200

「一切皆 200,错误码放 body」是最常见的反模式。它让监控告警失效(所有请求看起来都成功)、让网关缓存误判、让客户端被迫解析 body 才能知道成败。

类别 语义 常用码
2xx 成功 200 OK / 201 Created / 202 Accepted / 204 No Content
3xx 重定向与缓存 301 / 302 / 304 Not Modified
4xx 客户端错误,重试无意义 400 / 401 / 403 / 404 / 409 / 422 / 429
5xx 服务端错误,可重试 500 / 502 / 503 / 504

完整清单与常见误用见 HTTP 状态码参考

几个容易判错的边界:

  • 400 vs 422:请求体语法都合法时,语义校验失败(如「结束日期早于开始日期」)用 422 Unprocessable Entity,格式错误用 400。
  • 401 vs 403:401 是「不知道你是谁」(未认证),403 是「知道你是谁,但不行」(未授权)。已登录但权限不足必须是 403。
  • 404 vs 403:不想暴露资源是否存在时,用 404 掩盖 403,是合理的安全实践。
  • 409 Conflict:资源状态冲突(重复提交、版本不匹配、库存不足)。它明确告诉客户端「重试没用,先解决状态问题」。
  • 429 Too Many Requests:限流。必须Retry-After 响应头,否则客户端只能盲目重试,把过载放大成雪崩。

四、统一错误格式

推荐 RFC 7807application/problem+json 作为统一信封:

{
  "type": "https://api.example.com/errors/order-already-paid",
  "title": "Order already paid",
  "status": 409,
  "detail": "Order 9f3c has already been paid and cannot be modified.",
  "instance": "/orders/9f3c",
  "code": "ORDER_ALREADY_PAID",
  "errors": [
    { "field": "quantity", "reason": "must be greater than 0" }
  ]
}

要点:

  1. type 是稳定的契约,客户端按它做分支,绝不按 detail 文案做分支。
  2. code 是自定义扩展,用于国际化与监控聚合——同一个 code 在日志里可以直接 group by。
  3. errors 承载字段级校验明细,表单类接口缺了它基本没法用。
  4. 永远不要回显堆栈、SQL、内部主机名或用户输入原文。

团队协作时,用 JSON Schema 生成工具 从真实响应体反推一份 schema 固化下来,比手写文档更不容易漂移;TypeScript 客户端可以直接用 JSON 转 TypeScript 生成类型。

五、分页、过滤与排序

维度 offset 分页 cursor 分页
语法 ?offset=100&limit=20 ?cursor=eyJpZCI6MTAwMH0&limit=20
深翻页性能 随 offset 线性劣化 恒定
插入导致漂移 会重复/漏读 免疫
随机跳页 支持 不支持
返回总数 容易 需要额外计数
适用场景 后台管理、报表 信息流、时间线、导出

过滤与排序建议统一约定,避免每个端点各写一套:

  • 过滤:?status=paid&created_after=2026-01-01
  • 排序:?sort=-created_at,amount(减号表示降序)
  • 字段选择:?fields=id,status,total(省带宽,但要明确它对缓存键的影响)
  • 总数:不要每次都返回 total,它往往是最贵的一次 COUNT(*)。需要时单独提供 ?count=true

改版时对比两版响应差异,JSON Diff 比肉眼逐字段对更可靠。

六、版本管理

方案 示例 优点 缺点 适用
URL 前缀 /v1/orders 直观、可分享、日志可见 版本侵入资源标识 公开 API
请求头 Accept: application/vnd.app.v1+json URL 干净、符合内容协商 隐藏版本、缓存配置复杂 内部服务
查询参数 ?version=1 实现简单 污染查询空间、易被忽略 不推荐

更重要的一条:能不升版本就不升。 以下变更都是向后兼容的,不需要新版本号:

  • 新增可选请求字段
  • 新增响应字段(客户端应忽略未知字段)
  • 新增端点
  • 新增 Link 头或新的错误 type

以下属于破坏性变更,必须升版本或做灰度:

  • 删除或重命名字段
  • 收紧校验规则
  • 改变字段类型或单位
  • 改变错误语义

七、缓存与条件请求

缓存是性能收益最高、改动最小的一环,但常被忽略。

方向 作用
Cache-Control 响应 声明缓存策略(max-ageno-storeprivate
ETag 响应 资源版本指纹
If-None-Match 请求 带上上次 ETag,未变更则服务端返回 304
Last-Modified / If-Modified-Since 双向 基于时间的弱校验
Vary 响应 声明按哪些请求头区分缓存(如 Accept-Encoding
Retry-After 响应 429/503 时告知何时可重试

对照一份清单逐项核对最省事:HTTP 头速查表

实践要点:GET 请求默认可缓存,所以绝不要用 GET 做有副作用的操作(这是「用 GET 删除资源」这类设计的根本问题);写操作显式声明 no-store;认证后的个性化响应必须带 Cache-Control: private,否则可能被共享缓存泄露给其他用户。

八、认证与授权的最小共识

  • 强制 HTTPS,明文 HTTP 直接 301 跳转。证书与 TLS 配置细节见 HTTPS 证书完全指南
  • 不要自研认证协议。用 OAuth 2.1 / OIDC 或成熟的会话方案。Token 的设计、签名与常见漏洞见 JWT 安全最佳实践
  • 密码永远不明文存储,用 bcrypt / Argon2 / scrypt。完整取舍见 密码哈希指南 与对应工具 bcrypt 哈希
  • 授权检查放在服务端,前端隐藏按钮不是权限控制。
  • 限流按身份而非 IP,否则 NAT 后面的用户会互相误伤。

调试调用时,把浏览器里复制下来的请求交给 cURL 转代码 生成各语言的客户端片段,比手抄请求头可靠。

九、常见反模式清单

反模式 后果 改法
一切响应都是 200 监控失效、缓存误判 按语义分层返回状态码
URL 里写动词 语义重复、无法利用 HTTP 缓存 用名词 + HTTP 方法
POST 承担所有写操作 丢失幂等性,重试产生重复数据 区分 POST / PUT / PATCH / DELETE
非幂等接口无幂等键 用户双击产生两笔订单 引入 Idempotency-Key
错误体格式各端点不同 客户端无法统一处理 RFC 7807 统一信封
深层嵌套 URL 耦合遍历路径,校验复杂 扁平化 + 查询参数过滤
深分页用 offset 越翻越慢、数据漂移 换 cursor
破坏性变更不升版本 静默打挂所有调用方 语义化版本 + 灰度
GET 携带副作用 被爬虫/预取触发 改用 POST/PUT
错误体回显内部信息 信息泄露 只回显安全的客户端可读信息

上线检查清单

发布前逐项核对:

  1. 所有 URL 使用复数名词,小写 + 连字符,无动词
  2. HTTP 方法与语义、安全性、幂等性一一对应
  3. 每个写接口都明确回答「重试会发生什么」
  4. 非幂等写接口支持 Idempotency-Key
  5. 状态码按 2xx/4xx/5xx 分层,不存在「一切 200」
  6. 429 与 503 都带 Retry-After
  7. 错误响应统一为 application/problem+json,含稳定 type 与业务 code
  8. 校验错误带字段级明细
  9. 错误体不回显堆栈、SQL、内部主机名
  10. 分页超过两层深度时使用 cursor
  11. 过滤、排序、字段选择语法全局统一
  12. 向后兼容变更未升版本;破坏性变更已升版本并灰度
  13. GET 可安全缓存,写操作显式 no-store,个性化响应带 private
  14. 全站 HTTPS,HTTP 已 301
  15. 授权检查在服务端,限流按身份
广告

常见问题

PUT 和 PATCH 到底该用哪个?

**PUT 用完整替换,PATCH 用局部更新**。PUT 要求客户端提交资源的完整表示,缺失字段按删除处理,且**天然幂等**——同样的请求体发一百次,结果完全一致。PATCH 只提交要改的字段,语义更省带宽,但**默认不幂等**:`{ op: add, value: 1 }` 这种自增操作发两次结果就不同。实践建议:**优先用 PUT 表达「设置成这个状态」,用 PATCH 表达「改这几个字段」**;如果 PATCH 也要保证幂等,就只允许客户端提交绝对值而非相对增量。判断标准是重试安全性——只要这个接口可能被网关或客户端自动重试,就必须幂等。

API 版本号应该放在 URL 里还是请求头里?

**没有唯一正确答案,但有明确的决策依据。** **URL 前缀**(`/v1/orders`)最直观、可在浏览器直接访问、日志和监控里一眼可见、调试成本最低,代价是版本号侵入了资源标识。**请求头版本**(`Accept: application/vnd.myapp.v1+json`)保持 URL 干净、符合内容协商的 HTTP 语义,代价是隐藏了版本、分享链接会失效、网关缓存配置更麻烦。**务实建议**:面向外部开发者的公开 API 用 URL 前缀(可发现性优先),内部服务用请求头(演化优先)。更重要的原则是——**能不升版本就别升**。向后兼容的变更(新增可选字段、新增枚举值、新增端点)永远不需要新版本号,只有破坏性变更才升。把 v1 到 v2 当成一次昂贵的产品决策,而不是随手一个数字。

分页用 offset 还是 cursor?深翻页为什么越来越慢?

**浅分页用 offset,深分页或数据会变的场景用 cursor。** `?offset=100000&limit=20` 的问题是数据库必须先扫描并丢弃前十万行才能返回,页数越深越慢;更糟的是,如果翻页期间插入了新数据,offset 会**导致记录重复或漏读**。cursor(游标)方案基于上一页最后一条的排序键(通常是自增 id 或时间戳)取下一批:`?cursor=eyJpZCI6MTAwMH0&limit=20`,配合索引直接定位,**复杂度恒定且与深度无关**,同时天然免疫插入导致的漂移。**代价**是游标无法随机跳页(不能直接跳到第 500 页)。混合策略最常见:**后台管理系统用 offset**(需要跳页和总数),**面向 C 端的信息流用 cursor**(只需要「加载更多」)。

错误响应用什么格式?直接返回 HTTP 状态码不够吗?

**不够。状态码只表达了错误的「类别」,客户端还需要一个稳定的机器可读「原因」。** 只用 400 而不给细节,客户端只能把原文弹给用户看——而后端文案往往不是面向终端用户的。**推荐 RFC 7807(problem+json)**作为统一信封,它规定了 `type`、`title`、`status`、`detail`、`instance` 五个字段,且 `application/problem+json` 已被主流框架原生支持。实践要点:**① `type` 用稳定 URI 表达错误类别,客户端按它分支,不按文案分支;② 增加一个自定义扩展字段承载业务错误码**(如 `code: ORDER_ALREADY_PAID`),方便国际化与监控聚合;③ 校验类错误务必带上**字段级明细**,否则表单体验极差;④ 绝不要在错误体里回显堆栈、SQL 或内部主机名。

← 返回博客首页