4261 字
约 14 分钟
14
S18-X16 · RESTful 接口设计:资源、动词与状态码

接口评审上的争执很少落在实现,几乎都落在约定:这个操作该用 PUT 还是 POST,这个失败该返回 400 还是 422,分页该给 offset 还是 cursor。约定定错一次,后面每个调用方都要替它付代价——前端为 200 + body.code 写一层判断,运维在日志里分不清"业务失败"和"服务挂了",DBA 在深页 offset 上扛全表扫描。

这篇把接口设计拆成八个决策点:资源建模 → 动词语义 → 状态码 → 查询参数 → 批量操作 → 错误结构 → 幂等键 → 版本兼容。每个点给判据,不给口号。


1. 资源建模:判据是"它有没有身份"

REST 的资源是名词,是能被单独寻址的东西。判断一个候选资源是否成立,问三个问题:

判据 成立 不成立
能否被单独寻址并用 ID 取回 /orders/1001 /getOrderById
是否有独立生命周期(创建、变更、删除) Order、Invoice OrderSummaryOfToday
客户端是否需要对它做多种操作 一个资源多个动词化路径 每加一种操作就加一个 URL

按这三条,/api/v1/orders/{id}/items 成立,/api/v1/getOrderItems 不成立。/api/v1/search 这类动名词混用的路径,如果它本质是"在订单集合上按条件检索",更应该是 GET /api/v1/orders?keyword=...;只有当检索横跨多个资源类型、无法归属单一集合时,才单独开一个查询资源。

# 好的形态
GET    /api/v1/tenants/{tenantId}/orders?status=PAID&pageSize=20
GET    /api/v1/orders/{orderId}
POST   /api/v1/orders
PATCH  /api/v1/orders/{orderId}

# 需要重新考虑的形态
GET    /api/v1/queryOrderList
POST   /api/v1/order/delete
GET    /api/v1/tenant/order/detail

聚合边界:资源粒度按聚合根划,不按数据库表划。订单和订单行是一张主表和一张子表,但对外只暴露 Order 一个聚合根,行项目通过 /orders/{id}/items 访问。如果每张子表都开一套 CRUD,调用方就要自己维护一致性——而这本该是服务端的责任。


2. 动词的语义与幂等性

动词 安全(不改状态) 幂等 典型语义 用错的后果
GET 是 是 读取 浏览器/网关/爬虫预取会重复触发副作用
HEAD 是 是 取元数据 少见,但适合探活
POST 否 否 创建、触发动作 重试即重复下单
PUT 否 是 整体替换 并发写互相覆盖;未提供的字段被清空
PATCH 否 取决于实现 部分更新 用 +1 语义做 PATCH 时重试会多加
DELETE 否 是 删除 重试返回 404 不等于失败

判断幂等的标准很朴素:同一个请求发两次,服务端状态是否相同。PUT /orders/1001 带完整表示发两次,最终状态一致,所以幂等;PATCH 带 {"status":"PAID"} 也是幂等的,但带 {"amountDelta": 10} 就不是。

// Spring Boot 3.3.x:用语义化注解表达动词,不要用 method 参数硬塞
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @GetMapping("/{id}")                      // 安全 + 幂等
    public OrderView get(@PathVariable Long id) { ... }

    @PostMapping                              // 非幂等:需要 Idempotency-Key 兜底
    @ResponseStatus(HttpStatus.CREATED)
    public OrderView create(@Valid @RequestBody CreateOrderCommand cmd) { ... }

    @PutMapping("/{id}")                      // 幂等:整体替换
    public OrderView replace(@PathVariable Long id,
                             @Valid @RequestBody ReplaceOrderCommand cmd) { ... }

    @PatchMapping("/{id}")                    // 部分更新:语义由字段决定
    public OrderView patch(@PathVariable Long id,
                           @RequestBody JsonPatch patch) { ... }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)    // 幂等:删两次结果一样
    public void delete(@PathVariable Long id) { ... }
}

PATCH 的两种媒体类型要分清:application/json-patch+json(RFC 6902,一组 op/path/value 操作)和 application/merge-patch+json(RFC 7386,直接给要改的字段)。前者能做数组插入删除,后者更贴近多数业务代码的直觉。选一个,别同时支持两套再让调用方猜。


3. 状态码:4xx 是调用方的问题,5xx 是服务端的问题

这是唯一需要背下来的分界线。业务失败不是 5xx:库存不足、余额不够、状态机不允许,都是"请求合法但当前不被接受",属于 4xx。

码 何时用 何时不该用
200 OK 同步读、同步改并返回结果 创建资源时
201 Created 创建成功,配 Location 头 不返回新资源时可有可无
202 Accepted 已受理、异步处理 实际同步做完了
204 No Content 成功但无响应体(删除、无返回更新) 有响应体却硬返回 204
301 / 302 域名迁移、路径永久/临时改 API 版本内部跳转
304 Not Modified 配合 ETag / If-None-Match 没做缓存协商时
400 Bad Request 语法错、参数类型错、缺必填 语义校验失败(用 422)
401 Unauthorized 未认证:没带或带了无效凭证 已登录但没权限
403 Forbidden 已认证但无权限 未登录场景
404 Not Found 资源不存在,或调用方无权知道它存在 用它表达业务失败
409 Conflict 状态冲突:重复创建、乐观锁版本不符 参数不合法
422 Unprocessable Content 语法对、语义不合法:字段校验失败 单纯语法错误
429 Too Many Requests 限流,配 Retry-After 服务过载(那是 503)
500 / 502 / 503 / 504 未捕获异常 / 上游坏响应 / 过载或维护 / 上游超时 任何可预期的业务拒绝

401 与 403 混用是最常见的错。客户端的处理逻辑依赖这个区分:401 应跳登录并刷新 token,403 应提示"没有权限"并停止重试。全用 403 的结果是前端在 token 过期时反复弹"无权限"。

资源存在但对当前调用方不可见时返回 404 而非 403,是一种有意为之的信息隐藏,避免通过状态码探测资源是否存在。这是取舍,不是错误,但要在接口文档里统一,不能一半 403 一半 404。

// Spring Boot 3.3.x:ProblemDetail 是内置的 RFC 9457 载体,不必自己造错误体
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail onNotFound(OrderNotFoundException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    pd.setType(URI.create("https://errors.example.com/order-not-found"));
    pd.setTitle("订单不存在");
    pd.setProperty("bizCode", "ORDER_NOT_FOUND");
    return pd;
}

@ExceptionHandler(OptimisticLockException.class)
ProblemDetail onConflict(OptimisticLockException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "资源已被他人修改,请重新获取");
}

4. 查询参数:过滤、排序、分页分开管

三组独立参数,不要塞进一个 query 字符串让服务端去解析。

GET /api/v1/orders
    ?status=PAID,SHIPPED          # 过滤:集合用逗号
    &createdFrom=2026-01-01T00:00:00Z
    &createdTo=2026-02-01T00:00:00Z
    &sort=-createdAt,id          # 排序:- 前缀表示降序,id 做稳定排序键
    &pageSize=20                 # 分页
    &cursor=eyJpZCI6MTAwMSwi...  # 游标式

分页的三种形态,差别在数据变动时的表现:

形态 参数 优点 数据变动下的问题
offset page=3&pageSize=20 简单、可跳页 深页要 LIMIT 20 OFFSET 40000,数据库得扫过前 4 万行;期间有插入则元素重复或漏
cursor cursor=<不透明令牌> 稳定、深页也快 不能跳页;必须基于唯一有序键
search_after search_after=[ts,id]&size=20 ES 中深分页的标准解 只适用于能提供唯一排序键的引擎;同样不能跳页

search_after 在 Elasticsearch 里替代 from/size,原因和 SQL 里的深 OFFSET 一样:from+size 需要每个分片各自取前 from+size 条再归并,from 越大,协调节点要处理的数据越多。按 Elasticsearch 8.x 的默认设置,index.max_result_window 是 10000,超过直接报错——这个上限不是为难人,是在提示该换 search_after 或 PIT + search_after。

游标要不透明:客户端拿到 base64 编码的服务端排序键,不是明文 ID。这样排序键改了不用改协议,客户端也无法构造非法游标。

-- offset:深页代价随 page 增大
SELECT * FROM orders
 WHERE tenant_id = ? AND status = ?
 ORDER BY created_at DESC, id DESC
 LIMIT 20 OFFSET 40000;

-- cursor:代价与页码无关,只依赖排序键上的索引
SELECT * FROM orders
 WHERE tenant_id = ? AND (created_at, id) < (?, ?)
 ORDER BY created_at DESC, id DESC
 LIMIT 20;

排序一定要带唯一键兜底。只按 created_at DESC 排,同一毫秒内创建的多条记录在两次查询间顺序可能不同,翻页就会重复或漏。sort=-createdAt,id 里的 id 不是可选装饰。


5. 批量操作:接口形状取决于语义

形态 路径 原子性 适用
批量创建 POST /api/v1/orders/batch 通常部分成功 导入、批量下单
批量改状态 PATCH /api/v1/orders/batch-status 通常部分成功 批量发货、批量关闭
循环单条 客户端循环调用 由客户端决定 条数少且需要逐条反馈

判据是条数与一致性需求:几十条以内、每条结果都要独立反馈,循环单条更简单,还省掉一个要长期维护的批量接口;上千条以上,网络往返本身成了主要成本,必须做批量。但批量接口必须明确回答:中间某条失败,其余算成功还是全回滚?

推荐默认"部分成功 + 逐条结果",它对调用方更有用,也避免长事务:

POST /api/v1/orders/batch
{
  "items": [
    { "clientKey": "a1", "sku": "S-1", "qty": 2 },
    { "clientKey": "a2", "sku": "S-2", "qty": 1 }
  ]
}
→ 207 Multi-Status(或 200,二选一并在文档里固定)
{
  "succeeded": [{ "clientKey": "a1", "id": 1001 }],
  "failed": [{ "clientKey": "a2", "code": "OUT_OF_STOCK", "message": "库存不足" }]
}

业务确实要求全或无时,就用 422/409 整体失败并配事务;不要既说"批量"又让人猜原子性。批量接口还要有上限:items.size() <= 100,在 @Valid 里用 @Size(max = 100) 卡住,超限返回 422。没有上限的批量接口等价于一次可被外部控制的雪崩入口。


6. 错误响应:业务码是 HTTP 码的补充,不是替代

  • HTTP 状态码给通用客户端(网关、监控、SDK、浏览器)用,决定重试、告警、缓存行为;
  • 响应体里的业务码给业务代码用,决定提示什么文案、走哪条分支。
// RFC 9457 ProblemDetail + 扩展字段
{
  "type": "https://errors.example.com/validation-failed",
  "title": "参数校验失败",
  "status": 422,
  "detail": "请求中有 2 个字段不合法",
  "instance": "/api/v1/orders",
  "bizCode": "VALIDATION_FAILED",
  "traceId": "3f6a1c9e8b2d4a10",
  "errors": [
    { "field": "items[0].qty", "code": "MIN", "message": "数量必须大于 0" },
    { "field": "receiver.phone", "code": "PATTERN", "message": "手机号格式不正确" }
  ]
}

字段级错误的 field 用路径表达式(items[0].qty)而不是字段名,前端才能定位到具体那一行输入框。traceId 是必需的:没有它,日志和用户截图对不上。

@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail onValidation(MethodArgumentNotValidException ex) {
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of(
                "field", fe.getField(),
                "code", fe.getCode() == null ? "INVALID" : fe.getCode(),
                "message", String.valueOf(fe.getDefaultMessage())))
            .toList();
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
            HttpStatus.UNPROCESSABLE_ENTITY, "请求中有 " + errors.size() + " 个字段不合法");
        pd.setProperty("errors", errors);
        return pd;
    }
}

7. 幂等键:给非幂等动词补一层保护

POST 天生不幂等,但客户端超时后无法区分"没到"和"到了但响应丢了"。Idempotency-Key 解决的就是这个不确定窗口。

要点 做法
谁生成 客户端生成 UUID,同一次业务重试复用同一个键
存多久 至少覆盖客户端最大重试窗口,典型量级 24 小时
存什么 键 → 状态(处理中/已完成)+ 首次响应体
并发同键 用 SET key value NX 抢占,第二个请求返回 409 或短暂等待
请求体不一致 同键不同体应拒绝(422),防止键被复用去改语义
键作用域 键 + 租户 + 路径 组成实际 key,避免跨租户撞键
@Component
class IdempotencyFilter extends OncePerRequestFilter {

    private final StringRedisTemplate redis;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String key = req.getHeader("Idempotency-Key");
        if (key == null || !"POST".equals(req.getMethod())) { chain.doFilter(req, res); return; }

        String tenant = String.valueOf(req.getAttribute("tenantId"));
        String storeKey = "idem:" + tenant + ":" + req.getRequestURI() + ":" + key;
        Boolean first = redis.opsForValue()
            .setIfAbsent(storeKey, "IN_PROGRESS", Duration.ofHours(24));
        if (Boolean.FALSE.equals(first)) {
            res.setStatus(409);
            res.setContentType("application/problem+json");
            res.getWriter().write(
                "{\"status\":409,\"title\":\"重复请求\","
              + "\"detail\":\"该 Idempotency-Key 已在处理或已完成\"}");
            return;
        }
        chain.doFilter(req, res);
    }
}

注意 setIfAbsent 之后的动作:真正要"返回首次响应体",需要在业务完成后把响应写回同一个 key,并让后到请求读它。如果只做"去重"不做"重放原响应",客户端拿到的 409 里没有业务结果,仍要再查一次——这已经是可接受的降级,但要在文档里写清楚。


8. 版本与兼容(回顾式)

只记三点结论:URL 主版本(/api/v1/)用于不兼容变更;新增可选字段、放宽校验、新增枚举值属于向后兼容;删除字段、收紧校验、改字段语义属于破坏性变更。响应里多返回字段永远比少返回安全,客户端必须忽略未知字段——这一条要写进对接规范,否则每次加字段都要全员沟通。


9. 常见坑

坑 现象 改法
用 GET 做有副作用的操作 预取/重试/缓存把数据改了;爬虫触发批量删除 改 POST,需要幂等就配幂等键
所有错误都 200 + body 里的 code 网关、监控、SDK 的重试与告警全部失效 HTTP 码与业务码分层,见第 6 节
用 404 表示业务不存在 资源明明存在(订单在,只是已关闭),客户端以为 URL 写错 状态冲突用 409,业务规则拒绝用 422
offset 深页扫全表 第 2000 页响应时间随页码线性上涨 换 cursor / search_after
排序没有唯一键 翻页出现重复或缺记录 sort=-createdAt,id
批量接口没有条数上限 单个请求能拖垮服务 @Size(max=...) 卡住
PUT 当 PATCH 用 没传的字段被置空 整体替换用 PUT,部分更新用 PATCH
错误体里没有 traceId 用户报障时无法定位日志 全局过滤器注入 traceId
批量接口直接循环单条 SQL 上千条时整体超时 分批 batch 提交或合并语句

10. 排错

现象 排查方向
客户端说传了参数但服务端收到空 检查 Content-Type;@RequestBody 缺失时 Spring 按表单解析
创建接口返回了两个资源 客户端超时重试;查是否有 Idempotency-Key,查网关是否自动重试了 POST
401 后前端死循环跳登录 刷新 token 的接口本身也返回 401,形成回路;把它排除在拦截器外
PATCH 后字段被清空 merge-patch+json 里 null 的语义是"删除该字段",不是"不改"
分页 total 与实际条数不符 count 与 list 的 where 条件不一致
时间区间过滤少数据 时间戳带不带时区;服务端统一 UTC 存储并显式带 Z
并发更新后数据丢失 用 PUT 整体替换导致覆盖;加版本号或改 PATCH

11. 边界

场景 更适合的做法
内部服务间高频调用、字段高度一致 可放宽 REST 严格性,甚至用 gRPC,路径语义不那么重要
纯动作型接口(发短信、触发对账) 动名词路径 + POST 是合理的,不必强行找名词
文件上传/下载 POST /files + 返回 ID;不要用 PUT 传二进制
实时推送 SSE / WebSocket,不属于请求-响应模型
图查询、复杂检索 POST /search 带复杂 body 比把条件全塞进 query string 更现实
超大结果集导出 异步任务 + 任务资源轮询,不要同步返回几十万行

12. 自测

  1. 一个"取消订单"的操作,资源该怎么建模,用哪个动词,返回什么状态码?
  2. 401 和 403 混用会让客户端哪段逻辑失效?
  3. 为什么 sort=-createdAt 单独作为排序键会导致翻页漏数据?
  4. 用 PUT 实现"只改地址"的接口会有什么后果?
  5. Idempotency-Key 命中时返回 409 和返回首次响应体,各自对客户端意味着什么?
  6. 批量接口在什么条件下应该做成"部分成功",什么条件下必须全或无?
S18-X16 · RESTful 接口设计:资源、动词与状态码
https://www.clxhxhhr.top/posts/5926/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。