为什么 REST API 需要设计规范
很多团队的后端接口,是随着业务一路“长”出来的:有人写 /getUserInfo,有人写 /user/get,还有人干脆用 /api/doSomething;成功时返回 {"success":true},失败时又换成 {"code":500,"msg":"..."};更有甚者,把所有响应的 HTTP 状态码都设成 200,把真正的错误塞进响应体里。接口一多,前端要维护十几种解析逻辑,文档永远落后于实现,联调成本高得离谱。
REST(Representational State Transfer,表述性状态转移)不是某家公司颁布的标准,而是一种架构风格。它由 Roy Fielding 在博士论文中提出,核心思想是把业务数据抽象成“资源”,再用一套统一的接口去操作这些资源。落地时我们要做的,是遵循它的一组约束,而不是背诵它的定义。
REST 的六条约束中,对日常接口设计影响最大的是这几条:
- 客户端-服务器分离:前后端各自独立演进,接口就是双方共同遵守的契约。
- 无状态:每个请求必须自带完整上下文,服务端不保存会话状态,因此可以随意水平扩展。
- 可缓存:GET 响应应当能够被缓存,用
Cache-Control、ETag明确表达缓存策略。 - 统一接口:用同一套 HTTP 方法与同一套 URL 约定操作所有资源,降低认知负担。
- 资源导向URL 用名词复数表示资源,动作用 HTTP 方法表达,避免在路径里塞动词。
- 状态码语义用 201、204、400、404、409、422 等表达结果,不要一律返回 200。
- 统一契约请求与响应的字段命名、时间格式、错误结构全站保持一致。
- 幂等设计用 Idempotency-Key 让重试安全,让弱网与超时不再产生重复订单。
资源与 URL 设计:以名词为中心
REST 的第一条直觉是:URL 用来定位资源,方法用来表达动作。资源要用名词,并且统一用复数;动作交给 HTTP 方法,不要出现在路径里。集合与单个成员用同一套命名,靠路径层级区分。
| 场景 | 推荐写法 | 不推荐写法 |
|---|---|---|
| 查询用户列表 | GET /v1/users | GET /v1/getUserList |
| 查询单个用户 | GET /v1/users/42 | GET /v1/user?id=42 |
| 创建用户 | POST /v1/users | GET /v1/users/create |
| 更新用户 | PATCH /v1/users/42 | POST /v1/users/update |
| 删除用户 | DELETE /v1/users/42 | POST /v1/users/delete |
层级关系用路径表达,但嵌套最多两层。例如“用户的订单”可以写成 /v1/users/42/orders,再往下就应换成由订单侧主导的资源,而不是继续堆叠。过滤、搜索、排序这类不属于资源标识的条件,统一放进查询字符串:/v1/orders?status=paid&sort=-createdAt。路径中出现大写字母、下划线或动词,几乎都是设计走偏的信号。
HTTP 方法与状态码的正确用法
HTTP 方法自带语义,其中“安全”指不改变服务端状态,“幂等”指重复执行与执行一次的效果相同。理解这两个属性,能帮你判断一个接口能不能安全重试、能不能被缓存。
| 方法 | 语义 | 安全 | 幂等 |
|---|---|---|---|
| GET | 读取资源,不产生副作用 | 是 | 是 |
| POST | 创建资源或触发处理 | 否 | 否 |
| PUT | 整体替换资源 | 否 | 是 |
| PATCH | 局部更新资源 | 否 | 否 |
| DELETE | 删除资源 | 否 | 是 |
状态码是接口最重要的“语义出口”。一个常见误区是“只要服务没崩就返回 200”,结果把业务失败、权限不足、参数错误都藏进响应体。正确做法是用状态码表达结果层级,让网关、监控和客户端都能做统一处理:
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 201 Created | 资源创建成功 | POST 创建后返回,并带上 Location 头 |
| 204 No Content | 成功但无响应体 | DELETE 成功、PUT 无返回内容 |
| 400 Bad Request | 请求格式错误 | JSON 解析失败、必需字段缺失 |
| 401 / 403 | 未认证 / 无权限 | Token 缺失或已过期、角色不允许 |
| 404 Not Found | 资源不存在 | id 无效,或无权知晓其存在 |
| 409 Conflict | 状态冲突 | 重复创建、乐观锁版本不一致 |
| 422 Unprocessable | 语义校验失败 | 格式合法但字段取值不满足业务规则 |
请求与响应格式:统一 JSON 契约
格式统一比格式“优雅”更重要。字段命名要么全用 camelCase,要么全用 snake_case,不要在同一个项目里混用;时间统一用 ISO 8601 的 UTC 字符串;金额用整数分或字符串,避免浮点误差;布尔值不要用 0/1 或 "true"/"false" 这类字符串。下面是一个创建订单的请求与响应示例:
POST /v1/orders
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"skuId": "SKU-10086",
"quantity": 2,
"remark": "工作日白天送达"
}
HTTP/1.1 201 Created
Location: /v1/orders/90210
{
"id": "90210",
"skuId": "SKU-10086",
"quantity": 2,
"amountCent": 13900,
"currency": "CNY",
"status": "created",
"createdAt": "2026-10-14T03:20:11Z"
}
字段约定上,还有几条经验值得固化进团队规范:
- 资源用扁平结构,不要把无关字段层层套娃;确实需要时用固定命名空间。
- 列表响应外层包一层
data与分页字段,而不是让数组裸奔,便于日后扩展。 - 可空字段给出 null 而不是省略键,避免前端判断
undefined与null两套逻辑。 - 枚举值用小写英文,并在文档里列出全部取值,客户端不要写死未知值。
版本管理:URL、Header 还是内容协商
接口一旦对外发布,就必然面临演进。破坏性变更(删字段、改类型、改语义)必须通过版本隔离,非破坏性变更(新增可选字段)则可以直接上线。常见的版本方案有三种:
- URL 版本:
/v1/users。最直观,路由、日志、缓存都容易区分,缺点是版本号“污染”了资源标识。 - 自定义请求头:
X-Api-Version: 2026-10-14。URL 干净,但调试时不直观,缓存和网关需要额外配置。 - 内容协商:
Accept: application/vnd.api.v2+json。最符合 REST 精神,但实现与排障成本最高。
对绝大多数团队,URL 主版本号是最实用的选择:简单、可见、易于回滚。真正重要的是把“什么算破坏性变更”写成白纸黑字,并在 CI 里加一道响应结构快照对比,防止有人在 /v1 上悄悄删掉一个字段。
分页、过滤与排序的约定
列表接口是复用率最高的接口,也是最容易设计失控的接口。分页参数、过滤参数、排序参数的命名一旦统一,前端就能写出通用的列表组件。
| 参数 | 说明 | 示例 |
|---|---|---|
| page / pageSize | 页码从 1 开始,pageSize 设上限 | ?page=1&pageSize=20 |
| cursor | 游标分页,适合大数据量与实时流 | ?cursor=eyJpZCI6OTAyfQ |
| filter | 按字段精确过滤 | ?status=paid&channel=app |
| sort | 字段名前加 - 表示倒序 | ?sort=-createdAt,name |
| q | 关键词模糊搜索 | ?q=键盘 |
偏移分页(offset/limit)实现简单,但页码越深、数据库扫描越多,且在数据频繁写入时会出现“重复或漏项”。订单、日志、消息这类高频写入的列表,建议直接采用游标分页:把上一页最后一条的排序键编码成不透明游标,下一页从该位置继续取。响应里同时给出总数(可选)、是否还有下一页以及下一页游标,客户端无需关心服务端如何实现。
错误处理与统一响应体
错误响应要回答三个问题:是什么错了、错在哪里、该怎么办。推荐把错误信息收进统一的信封结构,让客户端可以稳定地取出机器可读的 code 和人类可读的 message,并在 details 里给出字段级原因。下面是一个参数校验失败的 422 响应示例:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "请求参数校验未通过",
"requestId": "9f2c4a1e-8b77-4d0a-b6f4-3ae21d9c7a55",
"details": [
{ "field": "quantity", "issue": "must be greater than 0" },
{ "field": "skuId", "issue": "sku not found" }
]
}
}
这里有两个容易忽略的点。第一,业务错误码与 HTTP 状态码分工不同:状态码告诉基础设施“哪一类问题”,业务码告诉业务系统“具体是哪一种问题”,两者都要给。第二,requestId 必须返回,并写进服务端日志,这样用户报障时一句话就能定位到那一次调用。敏感信息不要进错误体,数据库报错、堆栈、表结构都只应留在服务端日志里。
安全、幂等与文档自动化
安全层面,接口至少要守住:认证用 Authorization: Bearer <token> 并设置合理过期时间;对写接口做限流与防重放;所有输入都在服务端再校验一遍;对外暴露的 id 尽量用不可枚举的格式,避免用户通过自增 id 猜出数据规模。
幂等是支付、下单、退款这类场景的生命线。客户端在重试时带上唯一的 Idempotency-Key,服务端首次处理时把结果缓存起来,再次收到相同键就直接返回上次结果,而不是重复执行。下面用 FastAPI 演示这一机制:
# FastAPI:用幂等键防止重复下单
from fastapi import FastAPI, Header
app = FastAPI()
@app.post("/v1/orders", status_code=201)
def create_order(order: OrderIn, idempotency_key: str = Header(...)):
cached = cache.get(idempotency_key) # 先查幂等缓存
if cached is not None:
return cached # 命中则原样返回,不重复落库
saved = save_order(order) # 首次请求才真正创建
cache.set(idempotency_key, saved, ttl=86400) # 缓存 24 小时
return saved
最后是文档。手工维护的接口文档一定会过期,正确姿势是用代码生成文档:在 FastAPI、SpringDoc、tRPC 等框架里,请求与响应的类型就是文档的来源,OpenAPI(Swagger)文件由代码自动产出,再接入接口测试平台做契约回归。文档即代码,接口变更即文档变更,联调时才不会各说各话。
接口规范的价值,不在于它多么“标准”,而在于它足够一致:只要有新同学照着现有接口写,就能自然写对,这份规范才算真正落地。