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(如「发布」「取消」「批量重算」)。三种方案,按优先级:
- 把它建模成一个资源:
POST /orders/1/cancellations——「取消」变成了「创建一条取消记录」,语义干净且可追溯。 - 把它建模成资源上的一个状态字段:
PATCH /orders/1带{ status: cancelled }。 - 实在不行才用动词后缀:
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 7807 的 application/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" }
]
}
要点:
type是稳定的契约,客户端按它做分支,绝不按detail文案做分支。code是自定义扩展,用于国际化与监控聚合——同一个code在日志里可以直接 group by。errors承载字段级校验明细,表单类接口缺了它基本没法用。- 永远不要回显堆栈、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-age、no-store、private) |
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 |
| 错误体回显内部信息 | 信息泄露 | 只回显安全的客户端可读信息 |
上线检查清单
发布前逐项核对:
- 所有 URL 使用复数名词,小写 + 连字符,无动词
- HTTP 方法与语义、安全性、幂等性一一对应
- 每个写接口都明确回答「重试会发生什么」
- 非幂等写接口支持
Idempotency-Key - 状态码按 2xx/4xx/5xx 分层,不存在「一切 200」
- 429 与 503 都带
Retry-After - 错误响应统一为
application/problem+json,含稳定type与业务code - 校验错误带字段级明细
- 错误体不回显堆栈、SQL、内部主机名
- 分页超过两层深度时使用 cursor
- 过滤、排序、字段选择语法全局统一
- 向后兼容变更未升版本;破坏性变更已升版本并灰度
- GET 可安全缓存,写操作显式
no-store,个性化响应带private - 全站 HTTPS,HTTP 已 301
- 授权检查在服务端,限流按身份