返回文章目录

设计一套清晰可演进的 REST API

从资源建模、HTTP 语义、幂等与错误契约出发,设计对调用方稳定、对服务端可演进的接口。

内容说明本文为个人学习与教学示例,不代表真实生产项目。涉及生产配置时请结合环境评估并完成审查。

API 不是控制器方法的外包装,而是服务与调用方共同依赖的长期契约。一个接口能返回正确 JSON 只是起点;资源命名、HTTP 语义、幂等、错误结构和兼容策略,决定它能否被稳定使用和逐步演进。

问题背景:API 是长期契约

接口发布后,服务端通常无法同时升级所有调用方。移动端可能数月不更新,脚本可能由其他人维护,合作服务也有自己的发布窗口。因此,设计应把“不破坏已有客户端”视为默认约束。

先写出调用方要完成的任务和资源生命周期,再决定端点。不要直接把内部函数暴露成 /createOrder、/updateOrderStatus。内部实现会重构,资源语义应更稳定。

从资源而不是动作建模

资源用名词表示,集合与单个资源保持一致:

text
01GET /v1/orders
02POST /v1/orders
03GET /v1/orders/{orderId}
04PATCH /v1/orders/{orderId}
05DELETE /v1/orders/{orderId}

复杂动作可以建模为子资源或状态变更。例如取消订单可以是 POST /v1/orders/{id}/cancellations。这比无限扩张动词端点更容易表达“取消记录本身也有状态和原因”。

资源标识应稳定,不暴露会变化的数据库自增顺序或敏感业务含义。请求与响应字段属于外部契约,不必与数据库列一一对应。

HTTP 方法、状态码与幂等

GET 应只读取;PUT 表示用完整表示替换资源,天然适合幂等;PATCH 表示部分修改;POST 常用于创建或触发非幂等处理。幂等不是“请求只执行一次”,而是重复相同请求后,对外可观察结果与执行一次一致。

创建成功返回 201 Created 并通过 Location 指向资源;异步受理可返回 202 Accepted;无响应体的成功操作返回 204 No Content。客户端错误与服务端错误要区分,不能把所有异常都包装成 HTTP 200。

分页、筛选和错误结构

数据量较小且稳定时可以使用页码;频繁新增的大集合更适合基于稳定排序键的游标分页。游标应被视为不透明字符串,调用方不应解析内部字段。

json
01{
02 "items": [
03 { "id": "ord_7f3", "status": "pending", "total": 12800 }
04 ],
05 "page": {
06 "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTI0In0",
07 "hasMore": true
08 }
09}

金额示例使用最小货币单位,避免浮点误差。错误响应要给机器稳定的 code,给人可读但不泄露内部细节的 message,并提供关联请求的 requestId。

json
01{
02 "error": {
03 "code": "ORDER_STATE_CONFLICT",
04 "message": "当前订单状态不允许该操作",
05 "requestId": "req_01J2..."
06 }
07}

字段校验错误可以增加 details,但不要返回 SQL、堆栈、文件路径或令牌。

实践:订单 API

下面的 Go 示例展示创建入口的关键边界。它只用于教学,省略了真实数据库和鉴权实现。

go
01type CreateOrderRequest struct {
02 ProductID string `json:"productId"`
03 Quantity int `json:"quantity"`
04}
05
06func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
07 key := r.Header.Get("Idempotency-Key")
08 if key == "" {
09 writeError(w, 400, "IDEMPOTENCY_KEY_REQUIRED")
10 return
11 }
12
13 var input CreateOrderRequest
14 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&input); err != nil {
15 writeError(w, 400, "INVALID_JSON")
16 return
17 }
18 if input.ProductID == "" || input.Quantity < 1 || input.Quantity > 99 {
19 writeError(w, 422, "VALIDATION_FAILED")
20 return
21 }
22
23 order, created, err := h.service.Create(r.Context(), key, input)
24 if err != nil {
25 writeServiceError(w, err)
26 return
27 }
28 if created {
29 w.Header().Set("Location", "/v1/orders/"+order.ID)
30 w.WriteHeader(http.StatusCreated)
31 }
32 json.NewEncoder(w).Encode(order)
33}

限制请求体可以避免无界内存占用;业务校验与 JSON 语法错误使用不同状态;真正的幂等保证必须在共享持久层和订单写入之间建立原子边界,不能只用单机内存 Map。

版本演进与兼容

优先做兼容性扩展:增加可选字段、增加新端点、让客户端忽略未知字段。删除字段、改变类型、修改枚举含义通常是破坏性变更。版本可以放在路径中,但 /v2 不是随意破坏兼容的许可证。

发布前应使用契约测试覆盖典型请求、边界输入和错误结构;记录弃用时间线;通过指标观察旧版本流量,而不是凭猜测下线。

安全、性能与总结

认证后仍要验证资源归属,列表端点要限制分页大小,写操作要设置超时并传播取消信号。日志记录请求 ID、结果和耗时,但不要记录密码、完整 Token 或敏感正文。

清晰的 REST API 建立在稳定资源、准确 HTTP 语义、可恢复错误和兼容演进之上。延伸学习可以进入认证授权、缓存一致性、服务分层与契约测试。

上一篇从输入 URL 到页面可交互:浏览器渲染全链路下一篇 B+Tree 索引为什么有效:从数据页到查询路径