RESTful API 设计指南:资源建模、状态码与版本演进
本文目录
API 是服务端与消费方之间的契约:URL 怎么拼、状态码怎么用、错误怎么返回,直接决定消费方要写多少防御代码、网关能不能正确重试、监控能不能正确报警。这篇文章把 RESTful API 设计拆成可执行的规则:资源建模、方法语义、状态码、错误结构、分页、版本与幂等。每一条都可以在代码评审时直接对照检查。
资源建模:URL 用名词,不用动词
REST 的核心是"资源 + 表述":URL 标识资源,HTTP 方法表达动作。把动作写进 URL,等于把 RPC 接口套上 HTTP 的壳——方法语义、幂等性、缓存机制会同时失效。
| 反例 | 问题 | 正例 |
|---|---|---|
| POST /api/createOrder | 动词混入路径,与 POST /orders 重复 | POST /orders |
| GET /api/getUserList | get 与 GET 方法重复 | GET /users |
| POST /users/123/activate | 状态变更可由 PATCH 表达 | PATCH /users/123(status: active) |
| GET /orders/delete?id=1 | 删除走 GET,会被预取和缓存误触发 | DELETE /orders/1 |
层级建模遵循从属关系:/users/123/orders/456 表示用户 123 名下的订单 456。但嵌套超过两层就要警惕——多数查询并不需要先知道父资源 ID:
# 过度嵌套:消费方被迫先查 userId 才能看订单条目
GET /users/123/orders/456/items/7
# 订单与条目本身是全局资源,直接走顶层路径
GET /orders/456
GET /order-items/7?order_id=456
并非所有"动作"都要硬凑成 CRUD。搜索、导出、取消订单这类过程性操作,可以建模为过程资源:POST /search、POST /exports、POST /orders/456/cancellation。规则是:能映射到增删改查的用标准方法;确实不是增删改查的,显式建模为子资源并用 POST 触发,同时返回可查询的结果(如 cancellation 资源可 GET 复查)。
集合用复数名词(/orders),资源用单数语义的 ID(/orders/1)。命名统一小写加连字符(/purchase-orders),禁止驼峰和下划线混用——风格不统一本身就是契约漂移。
HTTP 方法语义与幂等性
| 方法 | 语义 | 安全 | 幂等 | 请求体 | 典型用途 |
|---|---|---|---|---|---|
| GET | 读取资源 | 是 | 是 | 无 | 查询列表、详情 |
| POST | 创建 / 触发处理 | 否 | 否 | 有 | 创建资源、非幂等动作 |
| PUT | 全量替换 | 否 | 是 | 有 | 覆盖式更新 |
| PATCH | 部分更新 | 否 | 视实现 | 有 | 修改单个字段 |
| DELETE | 删除 | 否 | 是 | 通常无 | 删除资源 |
"幂等"的准确含义:同一个请求被客户端重试 N 次,服务器状态与只执行一次相同。GET/PUT/DELETE 的幂等来自协议语义;PATCH 默认不幂等——如果实现只做字段赋值,可以设计成幂等,但必须自己保证并在文档里写明,不能让消费方去猜。
幂等性直接决定重试策略:客户端对超时的 PUT/DELETE 可以直接重试,对 POST 必须走幂等键(见后文)。把每个端点的幂等性写进 API 文档,是给消费方最实用的信息之一。
状态码:语义优先于 200
状态码是 API 的第一层错误通道。全部返回 200 再在 body 里塞 code,会让网关重试、监控告警、缓存控制全部失去依据。常用状态码与边界:
| 状态码 | 语义 | 使用场景 |
|---|---|---|
| 200 | 成功 | GET / PUT / PATCH 成功 |
| 201 | 已创建 | POST 创建成功,Location 头指向新资源 |
| 204 | 无内容 | DELETE 成功、无返回体 |
| 301 / 302 | 永久 / 临时重定向 | 资源迁移、登录跳转 |
| 400 | 请求格式错误 | 参数缺失、JSON 解析失败 |
| 401 | 未认证 | 未带凭证或凭证无效 |
| 403 | 已认证但无权限 | 越权访问他人资源 |
| 404 | 资源不存在 | 也用于隐藏"存在但不属于你"的资源 |
| 409 | 状态冲突 | 唯一约束冲突、乐观锁版本冲突 |
| 422 | 语义错误 | 格式正确但业务校验不过(金额为负) |
| 429 | 请求过多 | 触发限流,必须带 Retry-After |
| 500 | 服务器内部错误 | 未捕获异常的兜底 |
| 503 | 服务不可用 | 主动降级、维护窗口 |
几条易错规则:
- 401 与 403 的边界:401 是"不知道你是谁",403 是"知道你是谁,但你不能做这件事"。混用两者,客户端就无法区分"该重新登录"还是"该提示无权限"。
- 429 必须带
Retry-After头(秒数或 HTTP 日期),否则消费方只能盲猜退避时间,限流就变成了互相伤害。 - 503 与 500 的区别:500 是意外,503 是自知。计划内维护、依赖连接池耗尽时返回 503,负载均衡与健康检查会把流量切走,而不是让每个请求都死等超时。
错误响应结构标准化
状态码表达类别,响应体表达细节。一个可运维的错误结构至少包含三件事:机器可读的 code、人可读的 message、可追溯到日志的 trace_id:
{
"code": "ORDER_STATUS_CONFLICT",
"message": "订单已完成,无法取消",
"trace_id": "req_a1b2c3d4e5",
"details": [
{ "field": "order_id", "issue": "current_status is COMPLETED" }
]
}
在 Node.js 服务里把这套结构做成统一的错误基类和处理器,业务代码只抛类型化的错误:
class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
}
}
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
if (!(err instanceof ApiError)) err = new ApiError(500, "INTERNAL_ERROR", "服务器内部错误");
const e = err as ApiError;
if (e.status >= 500) logger.error({ err: e, trace_id: res.locals.traceId }); // 5xx 记日志
res.status(e.status).json({
code: e.code,
message: e.message,
trace_id: res.locals.traceId,
...(e.details ? { details: e.details } : {}),
});
next();
});
两个边界:code 字段用稳定枚举而非自由文本(改文案不算破坏性变更,改 code 才算);message 面向开发者而非最终用户,永远不要把 message 直接渲染到界面上——它是运维排查用的,不是产品文案。
分页:offset、cursor 与 seek 各自的问题
列表接口必须分页,且三种方案都有明确的失效边界:
| 方案 | 参数形态 | 优点 | 问题 |
|---|---|---|---|
| offset | ?page=2&size=20 | 支持跳页、易实现 | 深翻页慢(扫描丢弃)、数据变动时重复/漏行 |
| cursor | ?cursor=eyJpZCI6MTAyfQ&size=20 | 性能稳定、结果不漂移 | 不能跳页、cursor 要编码排序键 |
| seek(keyset) | ?after_id=102&size=20 | 数据库层面最优 | 排序键必须唯一且可索引,换排序字段成本高 |
规则:后台管理类、页数浅的用 offset;面向 App 信息流的用 cursor;数据量大且排序维度固定的列表(时间线、日志)用 seek。offset 的经典事故:消费方停在"第 3 页",新数据不断插入头部,翻到第 4 页时出现重复行。cursor 把排序键(如 created_at + id)编码进 token,服务端直接定位到"上一批最后一条",天然规避漂移:
// cursor 内部结构要带版本与去重主键,Base64 后对消费方不透明
function buildCursor(items: Item[]): string {
const last = items[items.length - 1];
return Buffer.from(JSON.stringify({ v: 1, created_at: last.created_at, id: last.id }))
.toString("base64url");
}
// 查询用复合条件定位起点:先按时间比较,时间相同再按 id 比较,保证排序键唯一
// WHERE (created_at, id) < (:cursorCreatedAt, :cursorId) ORDER BY created_at DESC, id DESC LIMIT :size
无论哪种方案,size 都要有服务端上限(如 100),并且默认值与上限写入文档——"不传 size 返回全量"是拖垮服务的常见起点。
过滤与排序参数设计
过滤参数用扁平的查询串,字段名与资源模型对齐:GET /orders?status=pending&min_amount=100。复杂运算符约定统一前缀,避免每个端点各自发明:
GET /orders?status=pending # 精确匹配
GET /orders?created_at.gte=2026-01-01 # 范围:gte/lte/gt/lt
GET /orders?status.in=pending,paid # 集合:in
GET /orders?q=keyword # 服务端决定搜索哪些字段
GET /orders?sort=-created_at,amount # 前缀 - 表示降序,多字段逗号分隔
排序白名单是硬要求:sort 的每个字段必须在服务端允许列表里,否则直接 400——把任意字段拼进 SQL 的 ORDER BY,等于开放一次全表扫描入口。同理,过滤参数必须走参数化查询,status 的值校验枚举,min_amount 校验数值范围。过滤与排序参数都应该出现在 OpenAPI 的 query 参数定义中,而不是藏在口头约定里。
批量操作
N+1 个单条请求对消费方和服务端都是灾难,但要区分两种批量语义:
- 批量读:
GET /orders?id=1,2,3(集合过滤形态),返回保持输入顺序,缺失的 ID 用 null 占位而不是悄悄丢弃,让消费方能对账。 - 批量写:
POST /orders/batch,请求体是数组,响应必须逐项给结果:
{
"items": [
{ "index": 0, "status": 201, "order_id": 9001 },
{ "index": 1, "status": 409, "code": "SKU_SOLD_OUT" }
],
"summary": { "succeeded": 1, "failed": 1 }
}
关键决策是全成或全败由业务定,但逐项状态必须如实返回。批量写同样要支持幂等键,并且批量上限(如 50 条)要写进文档。超过上限的"同步大批量"应改走异步任务模型:POST /exports 返回 202 + 任务资源 URL,消费方轮询任务状态。
版本策略:URL、头部还是网关协商
只要 API 有外部消费方,破坏性变更就不可避免。三种主流策略:
| 策略 | 形态 | 优点 | 代价 |
|---|---|---|---|
| URL 版本 | /v1/orders | 显式、可调试、网关好路由 | 版本泛滥,两套代码并存期长 |
| 头部版本 | Accept: application/vnd.blog.v2+json | URL 干净 | 抓包不可见、调试成本高 |
| 网关协商 | 网关按消费方路由到不同版本 | 后端可保持一套代码 | 网关配置即契约,配置错误影响面大 |
实践标准:对外 API 用 URL 版本 + 明确的弃用时间表。简单、可观测,且 curl 一眼可见。头部版本适合同域多形态资源(同一资源返回不同 media type)。无论哪种,添加字段不算破坏性变更;删除字段、改语义、改必填项才是——这些必须开新版本,并给旧版本一个明确的下线日期,通过响应头和邮件双通道通知消费方。
幂等键:Idempotency-Key
POST 不幂等,但支付、下单这类 POST 恰恰最怕重复。标准解法是消费方为每个逻辑请求生成唯一键,服务端首次处理后把响应存下来,重试时直接返回缓存结果:
const idempotency = new Map<string, { status: number; body: unknown }>();
app.post("/orders", async (req: Request, res: Response) => {
const key = req.header("Idempotency-Key");
if (!key) {
return res.status(400).json({ code: "IDEMPOTENCY_KEY_REQUIRED", message: "缺少 Idempotency-Key" });
}
const cached = idempotency.get(key);
if (cached) return res.status(cached.status).json(cached.body); // 原样返回首次结果
const order = await createOrder(req.body, key); // key 传下去做存储层唯一约束
idempotency.set(key, { status: 201, body: order });
res.status(201).json(order);
});
生产实现要注意四点:键要绑定"操作类型 + 请求体哈希",同键不同 body 应返回 422 而不是静默复用;缓存要持久化(Redis + TTL 24 小时级别)而不是进程内存,否则重启后重试会重复扣款;并发重试要靠存储层的唯一约束兜底(INSERT ... ON CONFLICT 或分布式锁);TTL 过期后的重试无法防御,所以客户端重试窗口必须远小于 TTL。这一机制在 Stripe 等支付 API 中是标准配置,IETF 也有对应的草案规范。
OpenAPI 文档化
以上所有约定最终要落到机器可读的契约上。OpenAPI 把路径、参数、状态码、错误结构全部声明化,作为单一事实来源:CI 校验实现与文档一致、生成客户端 SDK、模拟服务供前端并行开发:
openapi: 3.1.0
paths:
/orders:
post:
summary: 创建订单
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, format: uuid }
responses:
"201":
description: 创建成功
headers:
Location: { schema: { type: string } }
"409":
$ref: "#/components/responses/Conflict"
"422":
$ref: "#/components/responses/ValidationError"
NestJS 用户可以用 @nestjs/swagger 从装饰器直接生成这份契约;Express 用户用 swagger-jsdoc + swagger-ui-express。文档不是写完代码后的补丁,而是评审的输入:先改 OpenAPI,评审通过后再写实现,契约漂移在这一步就被拦住。
注意事项与边界
- 不要在 URL 里承载凭证或敏感查询参数——它们会进访问日志、浏览器历史和 Referer 头,token 只能放头部或 body。
- REST 不是教条:内部高吞吐服务间调用,gRPC 或消息队列可能更合适,不要为了"统一风格"牺牲适用性。
- 429 与 503 都要可区分地打点上报:限流是保护,故障是事故,两者的告警阈值完全不同。
- 分页 cursor 是服务端排序实现的内部细节,不要承诺"永远兼容"——排序规则变了,cursor 就该失效,消费方要从第一页重新拉取。
- 版本化救不了语义偷改:v1 里
status是数字、v2 里改成字符串还叫 v2,这种变更没有任何版本策略能兜住,只能靠契约测试。
总结
可靠的 API 设计有三条主线:资源建模统一(名词、层级克制、动词不进 URL)、协议语义用满(方法对应动作、状态码表达类别、错误体补充细节与 trace_id)、消费方防御前置(幂等键挡住重试、cursor 挡住漂移、OpenAPI 让契约机器可校验)。这些规则单看都简单,难的是在一支团队里长期不破例——所以把规则写进评审清单,比写进文章更重要。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。