02 · API 设计:REST / gRPC / GraphQL 与版本兼容
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 前端天天在调 API,本篇从服务端一侧讲清楚"为什么要这样设计" |
| 核心内容 | 三种风格的适用边界、资源与状态码约定、破坏性变更怎么处理、分页与错误体设计 |
| 读完能回答 | 什么时候该用 gRPC?接口要改字段了怎么不炸客户端? |
| 相关板块 | 01 · 运行时选型 | 03 · 认证与授权 | 04 · 浏览器与网络 |
一、三种风格:按"谁在调用"来选
不要按流行度选,按调用方选。
| 风格 | 适用 | 不适用 | 传输 |
|---|---|---|---|
| REST | 对外开放 API、第三方集成、前后端分离 | 内部高频调用、强类型诉求 | HTTP + JSON |
| gRPC | 服务间内部调用、高性能、强契约 | 浏览器直连(需 grpc-web)、对外开放 | HTTP/2 + Protobuf |
| GraphQL | 客户端形态多、字段需求差异大(多端) | 简单 CRUD、缓存与监控体系薄弱时 | HTTP + JSON |
最常见的正确组合:对外 REST,对内 gRPC。 这不是折中,而是两类调用方的诉求本来就不一样—— 外部要的是"好懂、好调试、稳定";内部要的是"快、强类型、可演进"。
GraphQL 的诚实评价:它解决的是"多个客户端要不同字段"的问题。 如果你只有一个 Web 端,它带来的复杂度(缓存、N+1、权限、监控)通常大于收益。
二、REST 设计:把约定写死
2.1 URL:名词复数 + 层级表达关系
GET /users # 列表
POST /users # 创建
GET /users/{id} # 详情
PATCH /users/{id} # 部分更新
DELETE /users/{id} # 删除
GET /users/{id}/orders # 子资源
POST /orders/{id}/cancel # 非 CRUD 动作:动词放末尾| 规则 | 说明 |
|---|---|
| 用名词复数 | /users 而非 /getUser |
| 层级不超过两层 | 更深就用查询参数过滤 |
| 非 CRUD 动作用动词结尾 | /orders/{id}/cancel |
| 不用动词做资源名 | ❌ /createUser |
2.2 状态码:只用常见的那几个
| 码 | 含义 | 典型场景 |
|---|---|---|
| 200 | 成功 | 查询、更新成功 |
| 201 | 已创建 | POST 成功,响应应带 Location |
| 204 | 无内容 | 删除成功 |
| 400 | 请求错误 | 参数校验失败(客户端的错) |
| 401 | 未认证 | 没带 token 或 token 失效 |
| 403 | 无权限 | 认证了但不够格 |
| 404 | 不存在 | 也可能是故意不告诉你存在(权限场景) |
| 409 | 冲突 | 资源状态冲突、唯一键重复 |
| 422 | 语义错误 | 格式对但业务规则不满足 |
| 429 | 限流 | 必须带 Retry-After |
| 500 | 服务端错误 | 兜底 |
⚠️ 别用 200 包一切。把错误塞进
200 + {code: 1}会让: HTTP 层的重试、缓存、监控、网关限流全部失效。
2.3 错误体:要能被程序处理
json
{
"error": {
"code": "ORDER_ALREADY_PAID",
"message": "该订单已支付,无法取消",
"details": [
{ "field": "orderId", "reason": "状态不允许" }
],
"requestId": "req_abc123"
}
}| 字段 | 为什么要有 |
|---|---|
code | 稳定的机器可读标识,客户端按它分支,不要按 message |
message | 给人看的,不要用于逻辑判断 |
details | 表单校验错误的字段级信息 |
requestId | 排障的关键,前后端日志靠它对齐 |
2.4 分页:游标优先于页码
| 方式 | 问题 |
|---|---|
?page=1&size=20 | 数据变动时会重复或漏掉记录;深翻页慢 |
?cursor=xxx&size=20 | 需要排序键,但稳定 |
列表会增长、会按时间排序 → 用游标。 后台管理这种数据量小且稳定的场景,页码也可以接受。
三、版本兼容:最重要的一节
API 一旦发布,就有了客户端。改它是成本最高的操作。
3.1 先分清"兼容"与"不兼容"
| 改动 | 兼容性 |
|---|---|
| 新增可选字段 | ✅ 兼容 |
| 新增接口 | ✅ 兼容 |
| 新增错误码 | ⚠️ 客户端可能不认识 |
| 删除字段 | ❌ 破坏性 |
| 改字段类型/含义 | ❌ 破坏性 |
| 把可选改必填 | ❌ 破坏性 |
| 改枚举值语义 | ❌ 破坏性 |
💡 一条硬规则:响应里的字段只增不删。 废弃字段标
@deprecated并继续返回,等确认无调用方后再下线。
3.2 三种版本策略
| 策略 | 做法 | 适用 |
|---|---|---|
| URL 版本 | /v1/users | 对外开放 API,最常见也最清晰 |
| Header 版本 | Accept: application/vnd.api.v2+json | 不想污染 URL;但调试不便 |
| 不版本,靠演进 | 只做兼容改动 | 内部服务、客户端可控 |
判断标准:
- 客户端你控制不了(第三方、App 老版本)→ 必须版本化
- 前后端一起发版、可强制升级 → 可以只做兼容演进
3.3 破坏性变更的落地流程
1. 新老并存:/v1 保持,/v2 上线
2. 双写过渡期:监控 /v1 的调用量
3. 通知调用方,给明确的迁移期限
4. /v1 调用量归零后再下线⚠️ 千万不要"发个公告就删"。App 老版本的存活周期远超你的预期。
四、幂等:写接口必须设计
网络会重试、用户会连点、消息会重投。写操作必须能重复执行而不出错。
| 手段 | 适用 |
|---|---|
幂等键(客户端生成 Idempotency-Key) | 创建类接口,防重复下单 |
| 唯一索引 | 数据库层兜底 |
| 状态机校验 | 已支付的订单不允许再支付 |
| 天然幂等(PUT 覆盖、DELETE) | 直接用 |
判断标准:如果这个接口被调用两次会产生两笔数据,它就需要幂等设计。
五、自检清单
- [ ] URL 用名词复数,非 CRUD 动作用动词结尾
- [ ] 状态码语义正确,不用 200 包错误
- [ ] 错误体有
code(机器读)、requestId(排障) - [ ] 列表分页用游标(会增长的列表)
- [ ] 响应字段只增不删
- [ ] 有版本策略,且知道什么情况下必须版本化
- [ ] 所有写接口都考虑过幂等
- [ ] 限流返回 429 且带
Retry-After - [ ] 对外 API 有文档(OpenAPI 自动生成也算)
六、常见误区
| 误区 | 事实 |
|---|---|
| "REST 就是 CRUD" | REST 是资源建模,非 CRUD 动作要用动词子资源表达 |
| "gRPC 更先进,该全面替换 REST" | 浏览器直连麻烦、调试不便。对外场景 REST 更合适 |
| "GraphQL 能解决过度获取,就该上" | 它解决的是多端差异;单端场景带来的复杂度常常不划算 |
| "接口先上线,版本以后再说" | 一旦有客户端,加版本就是破坏性变更 |
| "错误码用数字就行" | 用语义化字符串,数字码会失控且不自解释 |
| "字段不用了就删掉" | 删除即破坏性变更。标废弃,保留返回 |
| "重试是客户端的事" | 服务端不做幂等,重试必然产生脏数据 |
七、动手练习
练习 A:找出破坏性变更
给出一组接口改动,判断哪些是破坏性的、哪些兼容: 新增可选字段 / 删除响应字段 / 把 amount 从字符串改数字 / 新增必填请求参数 / 新增错误码。
练习 B:设计一个会重试的下单接口
要求:用户连点两次只生成一笔订单。 写出你需要的东西(幂等键放哪、存在哪、重复请求返回什么)。
相关文档
本区
- 01 · 运行时选型 —— 选型之后才是设计
- 03 · 认证与授权 —— 401 与 403 的背后
- C · 后端计划
其他域
- 04 · 浏览器与网络 —— HTTP 与缓存的基础
- S · 安全域计划 —— 认证授权的完整安全视角(该域待建,先看计划)