REST API 设计
接口设计得好不好,调用方最有发言权。设计不好的接口,会让前端、移动端、第三方对接的同学反复踩坑:
- 一个“删除”接口用的是 GET,某天聊天软件生成链接预览时,顺手把数据删了;
- 用 PUT 只更新了昵称,结果用户的头像和简介被清空了;
- 所有接口都返回 HTTP 200,监控系统一片绿,用户却在大量报错;
- 列表接口不分页,数据量涨上来后,一次返回好几 MB,接口频繁超时。
REST 的核心思路很简单:URL 表示资源,HTTP 方法表示动作,状态码表示结果。遵守这套约定,调用方就不用为每个接口学一套新规则。
读完这篇你会
- 用名词设计资源 URL,用 HTTP 方法表达意图
- 理解“安全”和“幂等”,知道它们为什么影响重试和缓存
- 设计一致的状态码、错误体和分页
1. 原理:资源 + 统一的操作方式
可以把 REST 想成一座图书馆:每本书都有一个固定的编号(URL),借、还、查询都是统一的手续(HTTP 方法)。你不需要为每本书单独学一套借阅流程。
text
/articles 文章集合
/articles/18 编号 18 的文章
/users/42/articles 用户 42 的文章URL 用名词表示“是什么”,不要用动词表示“做什么”:
text
✅ GET /articles/18
❌ GET /getArticle?id=18
❌ POST /deleteArticle筛选、排序、分页放在查询参数里:
text
GET /articles?authorId=42&status=published&sort=-createdAt&page=2&pageSize=202. HTTP 方法表达意图
| 动作 | 方法 | 例子 | 成功时 |
|---|---|---|---|
| 查询列表 | GET | GET /articles | 200 |
| 查询单个 | GET | GET /articles/18 | 200,找不到返回 404 |
| 创建 | POST | POST /articles | 201,并返回新资源 |
| 整体替换 | PUT | PUT /articles/18 | 200 或 204 |
| 部分修改 | PATCH | PATCH /articles/18 | 200 |
| 删除 | DELETE | DELETE /articles/18 | 204 |
安全和幂等:两个决定“能不能放心重试”的性质
| 方法 | 安全(不改变服务器数据) | 幂等(执行一次和执行多次,结果一样) |
|---|---|---|
GET / HEAD | 是 | 是 |
PUT | 否 | 是:把文章设成某个内容,设几次都一样 |
DELETE | 否 | 是:删一次和删三次,结果都是“已删除” |
POST | 否 | 否:提交三次就可能创建三个订单 |
PATCH | 否 | 不一定,取决于具体操作(“设为 5”幂等,“加 1”不幂等) |
这两个性质不是理论,它们直接决定了基础设施的行为:浏览器、代理、爬虫会认为 GET 是安全的,随时可能主动发起;网络超时后,客户端和网关会认为幂等的请求可以放心重试。
3. 坑一:用 GET 做删除,数据被“自动”删了
现象:管理后台有一个链接 GET /admin/posts/18/delete。某天有人把后台页面链接发到了聊天群里,几篇文章莫名其妙被删除了,操作日志里的访问者是一个链接预览服务。
原因:GET 被约定为“安全”的,于是很多程序会主动访问 GET 链接:聊天软件生成链接预览、浏览器预加载、搜索引擎爬虫、安全扫描工具。它们都不会想到“访问一下就会删数据”。
修复:所有修改数据的操作都用 POST、PUT、PATCH 或 DELETE。
text
DELETE /posts/184. 坑二:用 PUT 只传了昵称,头像和简介被清空了
现象:前端只想修改昵称,发送 PUT /users/42 { "nickname": "小林" }。请求成功了,但用户的头像、简介都变成了空的。
原因:PUT 的语义是用请求体整体替换资源。请求体里没有的字段,按语义就应该被清空或设为默认值,后端也确实这么实现了。
修复:
- 只修改部分字段,用
PATCH,后端只更新请求里出现的字段; - 坚持用 PUT 的话,前端必须提交完整的资源。
http
PATCH /users/42
Content-Type: application/json
{ "nickname": "小林" }实现 PATCH 时注意区分“没传这个字段”(不修改)和“传了 null”(清空),两者含义不同。
5. 坑三:所有接口都返回 200
现象:系统大面积报错时,监控面板上 HTTP 成功率依然是 100%;网关的自动重试、CDN 的缓存规则也都“认为一切正常”。
原因:所有响应都是 HTTP 200,真正的结果藏在响应体的 code 字段里。所有只看 HTTP 状态码的工具——监控、日志分析、网关、缓存、浏览器——都无法分辨成功和失败。
修复:用 HTTP 状态码表达结果的大类,响应体里的 code 表达具体原因。如果历史原因必须保留“统一包装 + 业务码”的格式,至少让失败请求返回对应的 4xx、5xx 状态码。
| 状态码 | 含义 | 客户端通常怎么做 |
|---|---|---|
200 / 201 / 204 | 成功 / 已创建 / 成功但无内容 | 正常处理 |
400 | 请求格式错误 | 提示检查输入 |
401 | 未登录或登录过期 | 刷新登录态或跳转登录 |
403 | 已登录,但没有权限 | 提示无权限 |
404 | 资源不存在 | 提示已删除或链接无效 |
409 | 与当前状态冲突(如重复创建、版本冲突) | 提示刷新后重试 |
422 | 内容不符合业务规则 | 把错误显示在对应字段上 |
429 | 请求太频繁 | 稍后重试 |
500 / 502 / 503 | 服务端出错 / 网关错误 / 暂时不可用 | 提示稍后重试,记录请求 ID |
错误响应体保持统一的结构:
json
{
"code": "VALIDATION_ERROR",
"message": "请检查提交内容",
"fields": { "title": "标题不能为空" },
"requestId": "req_7f3a9c"
}code 给程序判断,message 给用户看,fields 用来定位表单字段,requestId 用来查日志。
6. 坑四:列表不分页,越来越慢
现象:上线时只有几十条数据,接口很快。一年后数据涨到几万条,列表接口每次返回好几 MB,经常超时,数据库也被拖慢。
修复:所有列表接口从第一天起就分页,并限制每页的最大数量:
json
{
"items": [],
"page": 2,
"pageSize": 20,
"total": 1234
}翻页很深(比如第 5000 页)时,OFFSET 分页会越来越慢,因为数据库要先跳过前面所有的行。数据量大、只需要“下一页”的场景(如信息流),改用游标分页:
text
GET /articles?limit=20&after=eyJpZCI6MTgwfQ ← after 是上一页最后一条的位置标记7. 坑五:403 还是 404?
用户请求一个存在但无权访问的资源时,一般返回 403。但对于“连它是否存在都不应该让对方知道”的私密资源(比如别人的私有仓库、未公开的订单),返回 403 等于告诉对方“这个编号的东西确实存在”,攻击者可以借此枚举数据。这类场景返回 404 更安全。
团队里约定好规则,同一类资源保持一致即可。
8. 不是所有操作都适合 CRUD
“发布文章”“取消订单”“重置密码”这类业务动作,有明确的副作用和规则,硬塞进 PATCH status 会让权限和校验变得混乱。可以把动作建模为子资源:
text
POST /articles/18/publish
POST /orders/9527/cancellation
POST /password-reset-requests形式可以讨论,关键是团队内保持一致,并且让 URL、方法、权限、幂等性共同清楚地表达业务含义。
排查思路
- 用
curl -i看完整响应:状态码、响应头和响应体一起看,而不是只看响应体。 - 数据被意外修改或删除:检查访问日志里的请求方法和来源,看是否有 GET 请求触发了写操作。
- 字段被清空:确认调用方用的是 PUT 还是 PATCH,以及请求体里缺了哪些字段。
- 接口变慢:看返回数据的大小和数量,以及数据库里对应查询的执行计划。
- 维护一份 OpenAPI 文档,接口的路径、参数、状态码和错误格式都写清楚,前后端按文档联调。
检查清单
- [ ] URL 用名词表示资源,筛选分页放在查询参数中
- [ ] 所有修改数据的操作都不使用 GET
- [ ] 部分修改用 PATCH,PUT 要求完整资源
- [ ] 失败返回对应的 4xx / 5xx 状态码,错误体结构统一
- [ ] 列表接口有分页和最大页大小,大数据量考虑游标分页
- [ ] POST 等非幂等接口考虑重复提交的防护
- [ ] 有维护中的接口文档
延伸阅读
- API 版本管理:接口上线后怎么修改
- 错误处理与请求追踪:错误体和请求 ID
- API 契约与前后端联调:用文档和测试守住约定