RESTful API 设计规范与统一响应封装

bee2026-10-0866 分钟0 次阅读
什么才算 RESTful?URL 设计、状态码语义、幂等性与版本策略,再配一套企业级统一响应体与业务异常体系(完整可落地的代码)。
1 / 123
小节
一、REST 的六条约束,说人话
2 / 123

REST 被神化得太久了。它不是「用 JSON 传数据的 HTTP 接口」——那叫 HTTP API,不叫 REST。Roy Fielding 在论文里给出的六条约束,本质是在回答一个问题:如何让系统在规模变大时依然可演化。我们只须关心其中能在业务接口里落地的那几条。

3 / 123
对照表
约束白话解释我们实际能做到的部分
客户端-服务端分离前端只管展示,后端只管数据,互不依赖内部实现前后端通过 API 契约解耦
无状态每个请求自带全部信息,服务端不依赖上一次请求的上下文认证用 Token 而非服务端会话
可缓存响应要能标明自己能不能被缓存正确使用 Cache-Control、ETag
统一接口用统一的资源标识、表述、自描述消息与超媒体名词化 URL + 标准方法 + 标准状态码
分层系统中间可以有网关、代理、负载均衡网关统一鉴权与限流
按需代码(可选)服务端可下发可执行代码基本不用,忽略即可
4 / 123
要点

真正有实操价值的只有「无状态」和「统一接口」。前者决定了你能不能水平扩展,后者决定了别人能不能不看文档就猜出你的接口怎么调。剩下四条是架构级理想,业务项目做到七分就够。

5 / 123
小节
二、URL 设计规范
6 / 123

URL 是资源的名字,不是函数的调用命令。这两者的差别,账面上只是一两个词,体验上却是「看名字就懂」和「必须翻文档」的距离。

7 / 123
对照表
好例子反例为什么
GET /users/42GET /getUserById?id=42资源用名词,动作交给方法
GET /usersGET /userList集合用复数,语义统一
POST /usersPOST /createUser创建集合中的新成员
PUT /users/42POST /updateUser更新是方法语义,不必写进路径
DELETE /users/42GET /deleteUser?id=42删除绝不能用 GET(会被预取、缓存、爬虫误触发)
GET /users/42/ordersGET /getOrdersByUser?uid=42从属资源用层级表达
GET /users?status=ACTIVE&page=1GET /activeUsersPage1过滤、分页、排序走查询串
8 / 123

三条铁律:用名词复数、用层级表达归属、把过滤/分页/排序统统塞进查询串。路径里出现动词(get / create / update / delete)就是没理解资源导向。

9 / 123
坑

把敏感操作做成 GET /users/42/delete 是最危险的一种。GET 是「安全方法」,浏览器、代理、爬虫都认为它可以被随意重复——预取会删数据,这不是假设,是真实事故。

10 / 123

规范里那条「统一前缀」在代码里其实有两个写法:把 /api/v1 写死在每个 @RequestMapping 上,或者交给 server.servlet.context-path。这两条路选错,网关路由和健康检查会一起踩坑。把对外 API 真正要配的几行勾出来——server 出前缀与端口、logging 出访问日志与慢请求、actuator 出探活端点、profile 让 dev 与 prod 用不同前缀:

11 / 123
生成器
生成器对外 API 要配的那几行application.yml2 / 4
只勾 server 生成一次,看清 context-path 与 URL 规范里 /api/v1 的关系:前缀写在注解里还是配在服务器上,网关的路由规则和探活路径会跟着一起变。再勾 actuator,注意健康端点绝不能和业务流程共用一个路径空间;logging 那一组决定你能不能事后还原一次 4xx 的现场
产物
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

management:
  server:
    port: 9090                            # 管理端口与业务端口隔离
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 白名单,绝不写 *
  endpoint:
    health:
      show-details: when_authorized
      group:
        liveness: { include: ping }
        readiness: { include: db,redis,diskSpace }
勾了这些,代价与理由在这里
serverserver.port 被命令行 --server.port=8081 覆盖,也吃 SERVER_PORT 环境变量。
management白名单 + 独立管理端口是 Actuator 的安全底线,* 是泄露事故第一名。
12 / 123
小节
三、HTTP 方法与幂等性
13 / 123

方法不是装饰,它自带语义契约:

14 / 123
对照表
方法语义幂等安全典型状态码
GET读取资源是是200 / 404
POST创建资源 / 触发处理否否201 / 400
PUT整体替换资源是否200 / 204
PATCH局部更新资源通常视实现否200 / 204
DELETE删除资源是否204 / 404
15 / 123

幂等的含义是「同样的请求执行一次和执行 N 次,对资源的影响相同」。这条性质直接决定了重试策略:GET / PUT / DELETE 可以放心重试,POST 不能——重试一次就可能多下一单、多扣一次款。

16 / 123
原理动画
动图 · 一次标准 REST 交互
动图 · 一次标准 REST 交互
17 / 123
小节
四、状态码的正确使用
18 / 123

「一切皆 200,错误码藏在 body 里」是很多团队的历史包袱。它能让前端只判断一个字段,却让监控、网关、缓存、日志全部失去判断依据。

19 / 123

状态码这一层要背的东西不多,但挨得很近的那几个一定会用错——401 与 403、404 与 409、400 与 422。表格读三遍不如点一局:先点状态码,再点它对应的场景,配错当场解释差在哪。

20 / 123
配对闯关
闯关状态码配场景:把四个近义码分清已配对 0/8 · 配错 0
右列故意写得像「一句话契约」,先想清楚这句话说的是谁的错,再点
先点左边一个
21 / 123
架构图
图 1 · RESTful 设计四要素
图 1 · RESTful 设计四要素
22 / 123

对比两派做法:

23 / 123
  • 「一切皆 200」:{"code":50001,"message":"用户不存在","data":null}。前端好写,但 CDN 会缓存 200 的错误响应,网关无法按状态码统计错误率,运维看监控全是绿的
  • 正确用状态码:404 + {"code":50001,"message":"用户不存在"}。既让基础设施看懂,也用 code 保留业务细分
24 / 123
小节
五、统一响应体设计:两派观点与推荐方案
25 / 123
  • 纯 HTTP 状态码派:成功就 200 加裸数据,失败就是 4xx/5xx 加问题详情。最「标准」,但对前端不友好——判定点分散在状态码和错误结构两处
  • 信封派(Envelope):所有响应都套一个 { code, message, data }。前端判定集中、业务错误可细分,代价是与纯 REST 的「表示即资源」理念有出入
26 / 123

我的推荐是融合:HTTP 状态码表达「请求处理结果」这一层,信封里的 code 表达「业务细分结果」这一层。两者各司其职,谁也不替代谁。

27 / 123
java
package com.example.common.api;import com.fasterxml.jackson.annotation.JsonInclude;import lombok.Getter;import java.io.Serializable;/** * 统一响应体 * code 与 HTTP 状态码解耦:HTTP 说明「传输/处理」层面,code 说明「业务」层面 */@Getter@JsonInclude(JsonInclude.Include.NON_NULL)public class Result<T> implements Serializable {    /** 业务码:0 表示成功,其余见 ErrorCode 枚举 */    private final int code;    private final String message;    private final T data;    private Result(int code, String message, T data) {        this.code = code;        this.message = message;        this.data = data;    }    public static <T> Result<T> ok(T data) {        return new Result<>(0, "success", data);    }    public static <T> Result<T> ok() {        return new Result<>(0, "success", null);    }    public static <T> Result<T> fail(int code, String message) {        return new Result<>(code, message, null);    }    public static <T> Result<T> fail(ErrorCode errorCode) {        return new Result<>(errorCode.getCode(), errorCode.getMessage(), null);    }}
28 / 123
小节
六、全局响应封装:ResponseBodyAdvice 自动套壳
29 / 123

手写 Result.ok(...) 有个隐患:总有人忘记包,接口格式就不一致。用 ResponseBodyAdvice 可以在序列化之前自动包装,同时排除文件下载等特殊返回。

30 / 123
java
package com.example.common.api;import org.springframework.core.MethodParameter;import org.springframework.http.MediaType;import org.springframework.http.converter.HttpMessageConverter;import org.springframework.http.server.ServerHttpRequest;import org.springframework.http.server.ServerHttpResponse;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;/** * 自动把控制器返回值包装进 Result。 * 只处理标注了 @WrapResult 的接口,避免误伤文件下载 / 第三方回调。 */@RestControllerAdvicepublic class ResultWrapAdvice implements ResponseBodyAdvice<Object> {    @Override    public boolean supports(MethodParameter returnType,                            Class<? extends HttpMessageConverter<?>> converterType) {        // 已经是 Result 就不再包;标注了 @SkipWrap 的一律跳过        return returnType.getMethodAnnotation(SkipWrap.class) == null                && !Result.class.isAssignableFrom(returnType.getParameterType());    }    @Override    public Object beforeBodyWrite(Object body, MethodParameter returnType,                                  MediaType selectedContentType,                                  Class<? extends HttpMessageConverter<?>> converterType,                                  ServerHttpRequest request, ServerHttpResponse response) {        // 字符串返回值要单独处理,否则与 StringHttpMessageConverter 冲突        if (body instanceof String) {            return body; // 交由调用方自行返回 Result        }        return Result.ok(body);    }}
31 / 123

配套一个标记注解:

32 / 123
代码对照
代码java
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface SkipWrap {}
解读

警告:全局包装一旦启用,所有 String 返回值都会踩坑——Spring 用 StringHttpMessageConverter 处理字符串,包成 Result 后再序列化会与它冲突,轻则类型转换异常,重则返回一坨转义 JSON。要么在 beforeBodyWrite 里对 String 特判,要么干脆让控制器返回对象而非字符串。

33 / 123

这条链上每一格都有明确的接手人,点一遍比读代码快。第 ⑤ 格就是上面那条警告的案发现场:

34 / 123
交互图解
流程一次返回值被套上壳的完整路径1 / 6
从 ① 点到 ⑥,重点在第 ③ 格(转换器已经选定)与第 ⑤ 格(String 在这里翻车)
→
→
→
→
→
① 控制器返回裸对象
你只 return userService.find(id),没有手工 new Result。这一步的产物是「一个业务对象 + 它的声明类型」,还没有任何 JSON 出现过——把壳套在它身上才是接下来的事。
全部看懂了Advice 插在「转换器已定、正文未写」之间:它改的是 body,改不了状态码,也绕不开转换器的类型约束。
35 / 123

那个 String 分岔可以在内核里点出来。同一个 return,注解一换,出口就换了——missing 那一格还是白标 404 的现场:

36 / 123
内核实验
TeaVM@Controller 还是 @RestController:一句 return 的四种结局未启动
先点 json 看对象怎么被序列化,再点 string 看裸字符串为什么变成 text/plain 而不是 JSON;view 与 missing 两格解释「接口怎么返回了一整页 HTML」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
37 / 123
小节
七、业务异常体系
38 / 123

异常不是错误日志的附属品,而是业务错误码的载体。一套清晰的体系应该有两层:一个错误码枚举,一个业务异常。

39 / 123
java
package com.example.common.exception;import lombok.Getter;@Getterpublic enum ErrorCode {    SUCCESS(0, "success"),    INVALID_PARAM(40000, "参数不合法"),    USER_NOT_FOUND(40401, "用户不存在"),    USERNAME_TAKEN(40901, "用户名已被占用"),    ORDER_STATE_CONFLICT(40902, "订单状态不允许该操作"),    PERMISSION_DENIED(40300, "没有操作权限"),    UNAUTHORIZED(40100, "登录已失效,请重新登录"),    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");    private final int code;    private final String message;    ErrorCode(int code, String message) {        this.code = code;        this.message = message;    }}
40 / 123
java
package com.example.common.exception;import lombok.Getter;/** 业务异常:可安全展示给用户的错误,禁止把 5xx 堆栈暴露出去 */@Getterpublic class BizException extends RuntimeException {    private final ErrorCode errorCode;    public BizException(ErrorCode errorCode) {        super(errorCode.getMessage());        this.errorCode = errorCode;    }    public BizException(ErrorCode errorCode, String message) {        super(message);        this.errorCode = errorCode;    }    // 省略堆栈,业务异常不需要栈信息,省下大量开销    @Override    public synchronized Throwable fillInStackTrace() {        return this;    }}
41 / 123

业务异常与 HTTP 状态码的映射关系,是这套体系的黏合剂:

42 / 123
对照表
业务异常业务码HTTP 状态码
BizException(INVALID_PARAM)40000400
BizException(UNAUTHORIZED)40100401
BizException(PERMISSION_DENIED)40300403
BizException(USER_NOT_FOUND)40401404
BizException(USERNAME_TAKEN)40901409
BizException(ORDER_STATE_CONFLICT)40902409 / 422
未捕获的 Exception50000500
43 / 123

这张映射表就是整套体系的黏合剂:一次业务失败从抛出到落地要走七步,其中第 ④⑤ 步分别决定 body 里的 code 和状态线上的结论。动图里那两条读者(网关与页面)各读各的字段,谁也不能替谁:

44 / 123
原理动画
动图 · 一次业务失败是怎么落地的
动图 · 一次业务失败是怎么落地的
45 / 123
提示

fillInStackTrace() 被覆写这件事值得单独讲一句。业务异常是控制流而不是事故,一条「用户名已占用」的堆栈除了证明你 new 过它之外没有任何信息量,而采集栈帧的成本在热点接口上是实打实的 CPU。把它省掉,日志里改用 code 与关键业务 ID 定位——这才是把异常当返回值用的正确姿势。

46 / 123
小节
八、接口版本策略
47 / 123

接口一旦对外开放,就要考虑「旧客户端还在用」的现实。三种主流策略各有取舍:

48 / 123
对照表
策略形式优点缺点
URL 版本/api/v1/users直观、易调试、网关好路由URL 会变,不够「纯粹」
Header 版本X-API-Version: 1URL 稳定不便调试,浏览器地址栏看不出来
媒体类型版本Accept: application/vnd.demo.v1+json最「RESTful」冗长、工具链支持差
49 / 123
说明

绝大多数团队应该选 URL 版本。它直观、可分享、可在浏览器直接打开、网关按路径前缀就能路由。媒体类型版本理论最优,实操体验最差——除非你在维护一个必须极致遵守 REST 规范的公共 API。

50 / 123
小节
九、分页与排序的规范设计
51 / 123

分页参数要有统一约定,否则每个接口各写一套,前端苦不堪言。推荐:

52 / 123
  • 请求:?page=1&size=20&sort=createdAt,desc
  • 响应:返回总数与当前页数据
53 / 123
java
package com.example.common.api;import java.util.List;/** 分页结果:total 供前端算页数,list 是当前页数据 */public record PageResult<T>(long total, int page, int size, List<T> list) {    public static <T> PageResult<T> of(long total, int page, int size, List<T> list) {        return new PageResult<>(total, page, size, list);    }}
54 / 123
java
@GetMapping("/users")public PageResult<UserVO> page(        @RequestParam(defaultValue = "1") int page,        @RequestParam(defaultValue = "20") int size,        @RequestParam(defaultValue = "createdAt,desc") String sort) {    return userService.page(page, size, sort);}
55 / 123

参数名固定为 page / size / sort,sort 用 字段,方向 的形式,全站一致——前端只写一次分页组件就能复用到所有列表。

56 / 123

约定管住了参数名,管不住参数值。size 能写多大,是一条真实的容量红线:它由 spring.data.web.pageable.max-page-size 兜着,而且超限的后果是「静默裁剪」,不是报错。把这个数拖一遍,看契约在多大的调用量上会开始变形:

57 / 123
参数调节台
调节台每页最多多少条:一条 URL 参数就决定的容量上限
从 0 拖到 100 万,注意第 ② 档那句「裁剪是静默的」——调用方要 5000 拿到 2000,只有对总数时才会发现
spring.data.web.pageable.max-page-size
2000条当前 0 – 1000000
2000 条:Boot 的默认上限
  • Boot 给 Pageable 的默认上限正是 2000,超了会被裁剪而不是抛异常
  • 调用方要 5000 实得 2000,只有比对 total 与 list 长度时才会发现
  • 两千行实体加上 ORM 的一级缓存,一次请求吃掉几十 MB 堆并不罕见
  • 内部系统够用,公网 API 建议再降一档,别把默认值当契约
单次查询行数40%
堆占用45%
上限不是给老实人设的门槛,是给意外留的保险丝:导出走异步、翻页给游标、size 必须有顶。
58 / 123
小节
十、三个「约定优于正确」的坑
59 / 123
  • PUT 与 PATCH 混用:PUT 是整体替换(不传的字段会被置空),PATCH 是局部更新。团队只用一个时必须写进规约,否则「更新用户」到底是清空还是保留,全凭开发者心情
  • DELETE 返回 200 还是 204:两者都对。204 更符合语义(无内容),200 更方便前端统一解析。关键是全站统一,别一个接口 204 一个接口 200
  • 分页从 0 还是 1 开始:Spring Data 的 Pageable 默认从 0 开始,前端组件大多从 1 开始。这是个纯「团队约定」问题,没有对错,但必须在项目规范里写死
60 / 123
内核实验
TeaVM从请求到响应:分派链路实测未启动
对比 /users/42 与 /users 两个接口的完整处理链路
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
61 / 123
决策
决策公司新项目要对外提供 API,产品经理说「以后可能有第三方接入」。现在要不要引入 HATEOAS(在响应里附带 `_links` 让客户端跟着链接走)?
62 / 123
小节
十一、30 秒看懂:REST 就是「门牌号 + 动作」
63 / 123

前十节是规范条文,这一节把它们压成一个画面。

64 / 123
架构图
图 4 · API 契约的六个要素
图 4 · API 契约的六个要素
65 / 123
类比

REST 的核心只有一句话——你喊的是「谁」,你要做的是什么。找小张,正确说法是「幸福路 42 号」(门牌号 = URL)+「拜访」(动作 = HTTP 方法);而不是发明一个词叫「拜访幸福路42号的小张」。前者的好处是:所有「幸福路 42 号」的动作共用同一个地址(GET/PUT/DELETE /users/42),快递员(网关)、门卫(缓存)、物业(监控)都能凭地址和动作判断该不该放行、要不要重投、这次算成功还是失败。而「拜访幸福路42号的小张」这种说法,每个新动作都要造一个新地名,别人只能翻你的地图(文档)才看得懂。URL 说「操作什么」,方法说「怎么操作」,状态码说「结果如何」——这就是全部。

66 / 123
架构图
图 3 · URL 里带动词 vs 资源化 URL
图 3 · URL 里带动词 vs 资源化 URL
67 / 123
类比

统一返回体像医院给每张单据用的同一套抬头。不管化验单、收费票据还是出院小结,纸张右上角永远印着同三样东西:科室编号(code)、一句话说明(message)、正文数据(data)。前台(前端)只要认这个抬头,就能用一套代码处理所有单据;而信封上贴的那枚邮票(HTTP 状态码)是给邮递员(网关、CDN、监控)看的——两套信息给两拨读者看,所以不能互相省略。这也是第五节推荐「状态码 + code 融合派」的真正理由。

68 / 123

学完这一篇你要能回答:

69 / 123
  • 「POST /createUser 错在哪?」(动词进了门牌号,且集合创建本该是 POST /users)
  • 「为什么删除绝不能用 GET?」(GET 被默认为可重复的安全方法,预取和爬虫会替你删数据)
  • 「状态码 404 和 body 里的 code 40401 是什么关系?」(前者给基础设施看,后者给业务分支看)
70 / 123
小节
十二、上手实验:一次 API 交互的四个断面
71 / 123

REST 不是背出来的,是「看一眼请求长什么样、再看一眼响应长什么样」。下面四个演示分别对应四个断面,建议按顺序点。

72 / 123

断面一:内容协商——同一段代码,为什么有时 JSON 有时 406。

73 / 123
内核实验
TeaVM内容协商与消息转换器现场未启动
依次切 json / accept / string / fail,看 Accept 头怎么选转换器、406 从哪一步冒出来
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
74 / 123

fail 那一格值得多看两遍:它给出的是 HttpMediaTypeNotAcceptableException,也就是第十三节速查表的第一行。注意 406 是客户端的 Accept 造成的,不是你返回值写错了——很多新手第一反应去改控制器,方向就反了。

75 / 123

断面二:错误怎么变成规范的错误响应。

76 / 123
内核实验
TeaVM异常解析器链:业务异常怎么落地成 4xx未启动
对比 handler / status / none 三格:有 @ExceptionHandler、有 @ControllerAdvice、什么都没有,输出完全不同
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
77 / 123

none 那一格就是第十节说的「一切皆 200 派」最怕看到的形态:没人接管 → 500 + Whitelabel 页,网关统计到的错误率突然翻倍,而你只想知道「用户名已存在」。

78 / 123

断面三:资源化 URL 到底是怎么被匹配的。

79 / 123
内核实验
TeaVM资源化 URL 的分派实测未启动
/users/42 与 /users 都命中同一个 Controller,差别只在路径模板;/nope 演示 URL 写错时死在哪一站
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 123

把 /users/42(单个资源)和 /users(集合)放在一条链上看,你会更直观地理解第二节那条铁律:层级表达归属,查询串表达筛选。

81 / 123

断面四:整条 API 链路——包括失败与慢。

82 / 123

前面三个断面都是局部。这一个把过滤器、分派、参数校验、业务层、数据库全串起来,五个场景刚好是本篇五节的合订本:

83 / 123
内核实验
TeaVM一个请求穿全站:成功 / 校验失败 / 业务异常 / DB 故障 / 慢请求未启动
先跑 happy,再依次切 valid、biz、db、slow,观察同一份契约在四种故障下的表现
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
84 / 123

最后这张动图解释第五、六节那套壳子到底在流程的哪一步被套上——理解了这一步,第六节那个 String 警告就不是死记硬背的规则了:

85 / 123
原理动画
动图 · 统一返回体是怎么套上去的
动图 · 统一返回体是怎么套上去的
86 / 123

四个断面点完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,每一行回显都是算出来的——先 boot,再用 lab 把本篇的五种结局挨个敲出来:

87 / 123
内核控制台
88 / 123
说明

lab conv fail 与 lab err handler 是两种「都返回 JSON 的错误」,但一个是调用方的 Accept 造成的 406,一个是你的 @RestControllerAdvice 产出的 4xx。第四节那句「状态码给基础设施看,code 给业务看」,在这两条命令的回显里最能看出区别。

89 / 123
小节
十三、常见报错速查
90 / 123
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
406 Not Acceptable + org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation客户端 Accept 里要求的类型,没有任何转换器能产出(例如只接受 application/xml 而项目没引 Jackson XML)先用 curl -H "Accept: application/json" 验证接口本身是否活着;确认是调用方的头太挑#22 第七节
415 Unsupported Media Type + HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported@RequestBody 收到的媒体类型没人能读补 -H "Content-Type: application/json";或去掉 @RequestBody 改用 @ModelAttribute#23 第六节
JSON parse error: Cannot deserialize value of type java.util.Date from String "2024-05-01 10:00:00"Jackson 默认只认 ISO-8601(2024-05-01T10:00:00.000+08:00)或时间戳字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或全局配 spring.jackson.date-format本节末
java.time.format.DateTimeParseException(序列化 LocalDateTime 时报 InvalidDefinitionException: Java 8 date/time type ... not supported by default)没有注册 jackson-datatype-jsr310 模块Boot 的 spring-boot-starter-web 已自带;手工 new ObjectMapper() 时才会漏本节末
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable (3 known properties: ...)前端字段名与 DTO 不一致,且严格模式被打开了对齐命名或用 @JsonProperty("user_name");放宽见下一段#23 第十一节
Required request body is missing(HttpMessageNotReadableException)声明了 @RequestBody 却没发体;也常见于拦截器/过滤器先把流读过了一遍用 ContentCachingRequestWrapper 包一层让请求体可重复读#23 第八节
加了 ResponseBodyAdvice 之后,返回 String 的接口报 ClassCastException: Result cannot be cast to java.lang.String壳子被交给 StringHttpMessageConverter 处理,而它只吃字符串在 beforeBodyWrite 里对 body instanceof String 特判,或让该接口返回对象本篇第六节
文件下载接口拿到一堆转义 JSON全局套壳误伤了二进制/文本响应加 @SkipWrap 之类的标记注解并在 supports 里排除本篇第六节
NoResourceFoundException / 静态资源 404,接口却正常版本升级后资源处理器行为变化(Boot 3.2+ 不再默认把未知路径交给静态资源处理)显式配置 spring.web.resources.add-mappings=true 并核对路径#22 第十五节
CORS 报 Response to preflight request doesn't pass access control checkOPTIONS 预检被登录拦截器挡在门外用 CorsFilter / addCorsMappings 放行 OPTIONS,别用 MVC 拦截器管跨域#26
91 / 123

表格里第一行的 406 是新手最容易查错方向的:它长得像服务端的错,其实是调用方的请求头。下面是真堆栈,先别看解析——点出你认为的凶手行:

92 / 123
报错急救
报错急救HttpMediaTypeNotAcceptableException: No acceptable representation
接口全 406,浏览器打开却一切正常

联调时前端说所有接口都返回 406。你把同一个 URL 粘到地址栏,页面正常出数据,一度以为是网关在中间动了什么手脚。

org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation
at org.springframework.web.servlet.mvc.method.AbstractResponseBodyMethodProcessor.writeWithMessageConverters(AbstractResponseBodyMethodProcessor.java:272)
at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:195)
at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleValue(HandlerMethodReturnValueHandlerComposite.java:78)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:155)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1165)
requestedMediaTypes = [application/xml]
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
93 / 123
坑

Jackson 的日期格式一旦全局放开,就变成了「隐式契约」。推荐做法是把格式写进 DTO:@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或者干脆在 API 层统一用 ISO-8601 字符串(前端 new Date() 直接能解析)。spring.jackson.serialization.write-dates-as-timestamps=false 是最省事的开关,但它只对 JSR-310 类型生效,老的 java.util.Date 仍走 spring.jackson.date-format。

94 / 123
小节
十四、随堂自测
95 / 123
随堂自测
随堂自测产品要求做一个「批量取消订单」的操作。下面哪种接口设计最符合 REST?
先自己选一个,选中立刻告诉你对不对
96 / 123
随堂自测
随堂自测一个对外 API 返回 `200 OK` + `{"code":40401,"message":"用户不存在","data":null}`。最直接的后果是什么?
先自己选一个,选中立刻告诉你对不对
97 / 123
小节
十五、沙盘:API 契约四要素,选一组看代价
98 / 123

「统一返回体 / 状态码 / 分页 / 版本」这四件事各自都有取舍。选两组组合,输出告诉你这套契约在真实运维里的样子。

99 / 123
沙盘
沙盘API 契约四要素沙盘
运行结果
GET /api/v1/users?page=1&size=20 → 200
{"code":0,"data":{"total":137,"list":[...]}}
网关错误率面板:真实
#推荐给大多数内部/中小规模对外 API
状态码讲传输结论、code 讲业务结论、URL 版本可分享可路由——三项都是最省心的选择
100 / 123
小节
十六、动手练习
101 / 123
小节
第一档 · 照做
102 / 123

目标:搭出最小可用的「资源化 API + 统一返回体 + 全局异常」三件套,并用 curl 验证四种状态码。

103 / 123
java
package com.example.lab.rest;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.List;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;@RestController@RequestMapping("/api/v1/books")              // 资源化 URL + URL 版本public class BookLabController {    public record Book(Long id, String title, LocalDate published) {}    public record CreateForm(@jakarta.validation.constraints.NotBlank String title, LocalDate published) {}    public record PageResult<T>(long total, int page, int size, List<T> list) {}    private static final Map<Long, Book> STORE = new ConcurrentHashMap<>();    private static final AtomicLong SEQ = new AtomicLong();    static {                                        // 造一点种子数据        SEQ.set(2);        STORE.put(1L, new Book(1L, "Domain-Driven Design", LocalDate.of(2003, 8, 20)));        STORE.put(2L, new Book(2L, "Spring in Action", LocalDate.of(2020, 5, 1)));    }    // 集合:分页 + 过滤统统走查询串 → 200    @GetMapping    public Map<String, Object> list(@RequestParam(defaultValue = "1") int page,                                   @RequestParam(defaultValue = "20") int size) {        var list = STORE.values().stream().sorted((a, b) -> a.id().compareTo(b.id()))                .skip((long) (page - 1) * size).limit(size).toList();        return Map.of("code", 0, "message", "success",                "data", new PageResult<>(STORE.size(), page, size, list));    }    // 单个资源:存在 → 200,不存在 → 真 404(不是 200 + code)    @GetMapping("/{id}")    public ResponseEntity<Map<String, Object>> get(@PathVariable Long id) {        Book book = STORE.get(id);        if (book == null) {            return ResponseEntity.status(HttpStatus.NOT_FOUND)                    .body(Map.of("code", 40401, "message", "图书不存在"));        }        return ResponseEntity.ok(Map.of("code", 0, "message", "success", "data", book));    }    // 创建 → 201 + Location    @PostMapping    public ResponseEntity<Map<String, Object>> create(@jakarta.validation.Valid @RequestBody CreateForm form) {        Long id = SEQ.incrementAndGet();        Book saved = new Book(id, form.title(), form.published() == null ? LocalDate.now() : form.published());        STORE.put(id, saved);        return ResponseEntity.created(java.net.URI.create("/api/v1/books/" + id))                .body(Map.of("code", 0, "message", "success", "data", saved));    }    // 删除 → 204,无响应体    @DeleteMapping("/{id}")    public ResponseEntity<Void> delete(@PathVariable Long id) {        return STORE.remove(id) == null                ? ResponseEntity.status(HttpStatus.NOT_FOUND).build()                : ResponseEntity.noContent().build();    }}
104 / 123

五条 curl 与预期响应:

105 / 123
bash
# 1) 列表 → 200curl -i "http://localhost:8080/api/v1/books?page=1&size=20"# 2) 单个资源 → 200curl -i http://localhost:8080/api/v1/books/1# 3) 资源不存在 → 404(关键:状态码真的是 404)curl -i http://localhost:8080/api/v1/books/999# 4) 创建 → 201 + Location 头curl -i -X POST http://localhost:8080/api/v1/books \     -H "Content-Type: application/json" -d '{"title":"Refactoring","published":"2018-11-19"}'# 5) 缺少必填字段 → 400(@Valid 触发 MethodArgumentNotValidException)curl -i -X POST http://localhost:8080/api/v1/books \     -H "Content-Type: application/json" -d '{"title":""}'
106 / 123

第 2 条预期响应:

107 / 123
json
{  "code": 0,  "message": "success",  "data": { "id": 1, "title": "Domain-Driven Design", "published": "2003-08-20" }}
108 / 123

第 3 条预期:状态行 HTTP/1.1 404,body 为 {"code":40401,"message":"图书不存在"}。第 4 条预期:状态行 HTTP/1.1 201,并且响应头里出现 Location: /api/v1/books/3。第 5 条预期:HTTP/1.1 400——如果你在这里拿到了 200,说明忘了在第 4 个参数前写 @Valid。

109 / 123
小节
第二档 · 变体
110 / 123
  1. 把第 3 条的状态码改成 HttpStatus.OK(body 不动)→ 你观察到 curl -i 显示 200,然后加一个 curl -sI -H "Cache-Control: no-cache" .../books/999 也看不出错。这就是第十四节第二题:现在你能用一条命令复现「监控面板全绿」
  2. 把 list 方法的返回改为裸 PageResult<Book>(PageResult 见第九节),并配一个 ResponseBodyAdvice 自动套壳 → 你观察到响应结构完全不变,但控制器里已经没有 Map.of("code", 0, ...)。再试 @GetMapping("/ping") public String ping() → 你观察到 ClassCastException 或一坨转义 JSON,正好复现第六节那条警告
  3. 把 Book 的 published 换成 java.util.Date 并保持第 1 条请求 → 你观察到响应里变成毫秒时间戳(例如 "published": 1061308800000)。改成 @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") 后重新请求 → 你观察到回到 "2003-08-20"。两种写法都合法,但必须全站一致,否则前端要写两套解析
  4. 把 URL 版本 /api/v1 去掉,改成 X-API-Version: 1 请求头匹配(@RequestMapping(value="/books", headers="X-API-Version=1"))→ 你观察到浏览器直接打开 localhost:8080/books 立刻 404,因为地址栏没法带头。这就是第八节那句「实操体验最差」的具体形状
111 / 123
小节
第三档 · 造一个
112 / 123

做一个「可对外的图书 API」,把本篇所有规范落成一份可交付的契约。

113 / 123

要求:

114 / 123
  • 资源化 URL:/api/v1/books、/api/v1/books/{id}、/api/v1/books/{id}/reviews(从属资源用层级)
  • 完整方法语义:GET(200)、POST(201 + Location)、PUT(整体替换,200)、PATCH(局部更新,200)、DELETE(204)
  • 统一返回体 Result<T> + ErrorCode + BizException + @RestControllerAdvice,业务码与 HTTP 状态码按第七节的映射表走
  • 分页固定 page/size/sort 三参数,返回 total;size 超过 100 时返回 400 而不是慢慢查
  • 幂等保护:PUT/DELETE 对同一 id 重复调用两次,第二次必须是可预期的(200/204 或 404),绝不能 500
115 / 123

验收清单:

116 / 123
  • [ ] curl -i -X DELETE .../books/1 连打两次:第一次 204、第二次 404,两次都不是 500
  • [ ] curl -i -X POST .../books -H "Content-Type: application/json" -d '{"title":"x"}' 得到 201 且有 Location 头
  • [ ] 制造一个 BizException(USERNAME_TAKEN) 式的冲突,确认返回 409 且 body 里有 code
  • [ ] curl -i -H "Accept: application/xml" .../books 得到 406,且日志里的异常全名是 HttpMediaTypeNotAcceptableException
  • [ ] 所有列表响应的 data 结构完全一致(同一套 PageResult),前端只写一次分页组件即可复用
  • [ ] 你能用一句话说清「为什么状态码和 code 不能合并成一个」
117 / 123
小节
十七、要点自查
118 / 123
自检

不看上文,能否说出 GET/POST/PUT/PATCH/DELETE 各自的幂等性与典型状态码?(GET 安全幂等 200/404;POST 非幂等 201/400;PUT 幂等 200/204;PATCH 视实现;DELETE 幂等 204/404)

119 / 123
自检

404、406、415 分别由谁负责?(404 = 压根没匹配上映射或资源不存在;406 = 客户端 Accept 太挑;415 = 客户端 Content-Type 没人能读)

120 / 123
自检

Result<T> 里的 code 与 HTTP 状态码为什么要同时存在?(读者不同:状态码给网关/CDN/监控,code 给业务分支)

121 / 123
自检

全局套壳为什么必须对 String 返回值特判?(StringHttpMessageConverter 只接受字符串,塞给它一个 Result 会类型冲突)

122 / 123
口诀

门牌号说「是谁」,方法说「干什么」,状态码说「成没成」,code 说「为什么」;动词永远不进 URL。

123 / 123
总结

RESTful 不是玄学,它只要求你把三件事做对——URL 用名词表达资源、方法表达操作、状态码表达结果。在此之上,一套 Result<T> 统一响应体、一个 ErrorCode + BizException 异常体系、一个版本策略,就构成了能长期演进的企业级 API 骨架。