SpringBoot4系列01 - 实现RESTful接口
前两天在群里看到有人贴了一段接口返回,长这样。
出错了也是这个结构,只是 code 变成 1,msg 变成错误信息,HTTP 状态码永远是 200。贴出来的人说前端已经按这个格式对接了,改不动了。
说实话我盯着这段 JSON 看了好一会儿。HTTP 本来就把「成功了」和「失败了」分得清清楚楚,状态码就是干这个的,用 code: 0 再造一遍,等于把一套全世界都认识的通用语言,换成一套只有公司里五个人认识的方言。监控系统看不懂,网关、APM、抓包工具也看不懂,Nginx 日志里一水儿的 200,出了事想按状态码告警都没处下手。
RFC 9457 那套标准错误格式,SpringBoot 4 把它做成了框架的一等公民,一行配置就能打开。
这篇讲两件事:怎么把一组 REST 风格的接口写扎实,以及 SpringBoot 4 在这个主题上我踩过的关键约定。代码全部可跑,工程在 springboot4-restful 目录,不需要数据库和 Docker,git clone 下来两条命令就能起来。文末那些响应都是我实际跑出来逐字抄的,一个字没改。
先说清楚 REST 到底在约束什么
很多号称「RESTful 接口」的服务,拆开看只是「返回 JSON 的接口」。约束就那么几条,但每一条都有人违反,我评审接口文档时圈出来最多的也是这几处。

最容易被违反的一条:URL 里只放名词,动词交给 HTTP 方法。/api/orders 是资源,POST 是动作。不要写 /api/orders/create、/api/getOrderList、/api/orders/deleteById。这条看着简单,落在代码里就是「方法名即路径」的惯性,写的时候手比脑子快。
然后是状态码,这一条是重灾区。创建成功是 201,不是 200。删除成功是 204,不是 200。资源不存在是 404,不是自定义的 code: 40401。参数校验没过是 400,业务规则不允许是 409 或者 422。我一开始也觉得这只是体面问题,后来想明白了:这些状态码是写给网关、负载均衡、监控告警、SDK、重试逻辑看的,人只是顺便看看。
再说幂等。GET、PUT、DELETE 是幂等的,调一次和调十次,服务端的状态一样。POST 不幂等,调两次就下两单。这条约束直接影响接口设计:「修改订单金额」应该用 PUT,「订单状态流转」更适合 PATCH,「提交订单」只能是 POST。
还有一条最容易被忘掉:接口契约要稳定,别把内部结构泄漏出去。这条我放到工程骨架那一节细说,那里正好有两个现成的例子。
六个接口的最终形态如下。
| 方法 | 路径 | 动作 | 成功状态码 |
|---|---|---|---|
| POST | /api/orders | 创建订单 | 201 + Location |
| GET | /api/orders/{id} | 按主键查询 | 200 |
| GET | /api/orders | 分页 + 条件查询 | 200 |
| PUT | /api/orders/{id} | 整体替换业务字段 | 200 |
| PATCH | /api/orders/{id}/status | 状态流转 | 200 |
| DELETE | /api/orders/{id} | 删除订单 | 204 |
路径里没有一个动词,全是名词。状态流转那条用的是子资源的形式,status 本身就是订单的一个维度。
SpringBoot 4 的关键约定
坑基本都集中在这一块,我按「最容易踩」到「容易忘」的顺序写。

先说最容易忽略的一个:Web 场景的 starter 是 spring-boot-starter-webmvc。
| 场景 | 当前 starter |
|---|---|
| Web MVC | spring-boot-starter-webmvc |
| AOP | spring-boot-starter-aspectj |
| OAuth2 资源服务器 | spring-boot-starter-security-oauth2-resource-server |
| Web Services | spring-boot-starter-webservices |
写成 spring-boot-starter-web 能编译能运行,但该坐标已标记 deprecated,未来某个版本会移除,等于在依赖树里留一颗不报错的雷。我第一次新建工程时就顺手写错了,编译通过、接口能通,还觉得自己挺顺,两天后翻依赖树才看见那行 deprecated 标记。工程里现在还躺着这个坐标的,九成是这个原因。
测试 starter 按特性拆分,每个主 starter 都有对应的测试 starter,Web 工程用 spring-boot-starter-webmvc-test。
这个包带进 Spring Boot 的测试设施,还包括 spring-boot-resttestclient,即 4.x 新增的统一 REST 测试客户端。测试写法后面贴实际代码。
官方另提供 spring-boot-starter-classic 和 spring-boot-starter-test-classic 两个聚合包,定位是让既有工程按原有依赖结构先跑起来。新工程直接用拆分后的 starter,没必要背着一个聚合包过日子。
Jackson 3 的包名前缀是 tools.jackson。例外是 jackson-annotations 模块,groupId 和包名都还在 com.fasterxml.jackson 下。
拉出来的依赖树是这样的。
| |
整个依赖树里没有 jackson-datatype-jsr310,序列化 LocalDateTime 这类 Java 8 时间类型不需要额外加模块。这个细节挺有意思,工程里的 Instant 字段直接输出了 2026-09-17T14:43:09.616177200Z,我没加过任何时间模块,它就这么工作了。以前那种「时间序列化报错,搜一圈加个 jsr310」的流程,在这代里不用走了。
最后是错误响应。
SpringBoot 4 里配一行 spring.mvc.problemdetails.enabled=true,全局错误响应就统一成 RFC 9457 定义的 application/problem+json 格式:
五个字段各自语义明确。type 是稳定的错误类型标识,给程序做分支用。title 是给人看的短描述。status 就是 HTTP 状态码。detail 是这次具体错在哪。instance 是出错的请求路径,排障时特别好使。
ProblemDetail 的属性默认按字母序输出,实际响应里 detail 在前、type 在后,跟 RFC 文档展示的顺序不一样。解析响应不要依赖字段顺序,这是我拿真实响应和文档对了一遍才敢写下的结论。
有现成标准且框架直接支持,不必自己再造 {code, msg, data}。
依赖和工程骨架
pom 就三个依赖,很朴素。
| |
application.yml 关键就两处。
| |
第二行让空值不出现在响应体里,是契约选择。第一行才是重点,ProblemDetail 全靠它。
这个骨架后面几篇会直接复用,MyBatis、JPA、MongoDB 那几篇只换仓储实现。所以包结构我多花了一点心思,六个包各管一摊:
| |

请求从 controller 进来一路向下,响应和异常往上冒。两个刻意的设计放在这里说。
一个是领域模型用 record 做不可变。订单对象一旦创建就不能改,要改只能生成一个新的。
| |
这么写的好处是,仓储层的「更新」变成整对象替换,读操作天然拿到某个一致快照,不会出现「读到一半的订单」。存储用 ConcurrentHashMap 存整对象,连锁都不用加。刚下笔时我嫌这么写拘谨,真写下来发现它把并发问题直接消掉了,值。
另一个是响应体绝不复用领域模型。这是接口契约稳定性的问题。
OrderResponse 里做了两件领域模型不该关心的事:手机号脱敏,以及把状态枚举翻译成中文描述。
| |
文章开头说的「别把内部结构泄漏出去」,还有个更常见的坑:直接返回 Spring Data 的 Page。
Page 序列化出来会带着十几个字段,pageable、sort、first、last、numberOfElements、offset,全是 Spring Data 的内部实现细节。这些字段一旦随框架版本变动,前端就得跟着改。工程里用了自己的 PageResponse,只保留 list、page、size、total、totalPages 五个字段。
核心代码
状态机得写在枚举里
订单状态不能随便改,CREATED 不能直接跳到 COMPLETED。这类规则最怕散落在各个 if 里,过两个月谁也不知道完整规则是什么,我在别人的老项目里吃过这个亏,所以这次换了写法。
做法是把流转规则收进枚举本身。
| |
switch 表达式在 Java 21 上写出来干净,新增状态时编译器会直接报错提醒补规则。这比散落的 if 强太多。
流转关系画出来是这样。
| |

COMPLETED 和 CANCELLED 是终态,不光不接受状态流转,也不允许通过 PUT 改任何业务字段。这条规则放在服务层的 ensureModifiable 里,实测返回 422。
入参用独立 DTO,客户端就改不了它不该改的东西
请求体如果用领域模型来接,客户端就能多传一个 status 字段,直接把订单改成已完成。这是很经典的越权漏洞,防它的成本极低,就是别拿领域模型接请求体。
工程里用独立的 OrderCreateRequest,字段是白名单,并且带上校验注解。
| |
注意这里的 message 全是中文。很多项目把校验提示写成英文,然后在 @ExceptionHandler 里再翻译一遍,直接写中文就完了,校验框架会把 message 原样带进响应。
另外,totalAmount 用 BigDecimal。金额不要用 double,原因直接看 0.1 + 0.2 != 0.3,真金白银出错的案例并不少见。
Controller 里的三个关键点
第一个是创建订单的 Location 头。
| |
ResponseEntity.created(location) 一次把状态码 201 和 Location 头都给了。为什么要在头里给出新资源的地址?因为客户端拿到响应后不需要自己拼 URL 就能直接访问这个订单。这就是 REST 里 HATEOAS 最朴素的那个意思。
实测回来的头是这样的。
| |
第二个是状态流转,用 PATCH 子资源,路径是 /api/orders/{id}/status。PATCH 比 PUT 更准确,因为只改一个字段,不算整体替换。

第三个是分页查询的排序白名单,这是有实际教训的。
| |
不做白名单的后果:直接拿客户端传来的字段名去 Comparator.comparing,传一个 sort=abc,desc,程序就抛异常;传一个 sort=password,desc,在有些实现里甚至能变成一次信息探测。紧凑构造器里 Math.clamp 把 size 夹进合法区间,page 兜底到 0,凑合传进来的参数就不至于把接口打挂。
另外注意 comparator 里补了一个次排序键。
| |
如果只按 createdAt 排序,两条记录的时间戳恰好相同时,相对顺序在不同的请求里可能不稳定,翻页时会看到同一条数据出现两次、另一条漏掉。加上主键做次排序键,顺序就稳定了。这个问题本地数据量小的时候看不出来,上线了才暴露,属于最烦的那类。
全局异常处理为什么继承而不是从零写
这是最常见的写法错误,我单独拎出来讲。
| |
常见的写法是一个空的 @RestControllerAdvice 加一个 @ExceptionHandler(Exception.class)。这么写的话,Spring MVC 内部抛出来的 405、415、400 这些异常,全都掉进 Exception.class 那个分支里,变成 500 返回。
ResponseEntityExceptionHandler 这个基类里已经把框架的几十种异常归类处理好了,每种都对应正确的状态码和响应格式。只需要覆盖关心的那几个,剩下的交给它。站在基类肩膀上,比从零收拾几十种异常类型划算得多。
工程里覆盖了三个,加上两个自定义异常的处理器。
| |
覆盖的三个分别是请求体校验失败、查询参数校验失败、请求体格式错误,作用都是把逐字段的错误信息整理进 errors 属性里。
ProblemDetail 有个 setProperty 方法,可以往标准五字段之外添加自定义属性。RFC 9457 明确允许,规范里说客户端应当忽略不认识的属性。所以 404 里可以塞 resourceType 和 resourceId,400 里可以塞 errors 数组,既符合标准,又多给了客户端有用的信息。

完整案例,跑起来看
先起服务。
| |
日志里看到这行就成功了。
| |
启动 1.74 秒,这是裸工程在我这台机器上的数,你那边有出入属正常。
下面这些是实际跑出来的响应,一个字没改。
创建订单。
| |
| |
三个细节都在里面了,201 状态码、Location 头、脱敏后的手机号 138****8000。订单号是服务端生成的,客户端无权指定。

非法状态流转,返回 422。
| |
| |
detail 这句话把「从什么状态到什么状态」都带上了,运维半夜看日志能一眼看明白,不用再去翻代码。
字段校验失败,返回 400,逐字段原因。
| |
这种结构前端直接拿 errors 循环渲染到表单下面就行了,不用再写一堆字符串匹配。
未映射的方法,框架自己返回 405。
| |
注意 Allow 头,框架直接给出这个路径支持哪些方法。客户端和调试工具都能自己读懂,不需要额外的文档说明。
总共跑了 12 个场景,状态码分别是 201、200、200、200、200、422、404、400、400、405、204、404,全部符合预期,应用日志里 ERROR 行数为 0。这串状态码我是对着终端记录一条条核过的,算得上铁证。
上面这些全是 curl 看到的。用浏览器直接打开接口呢?顺手开了一个不存在的订单截了图。

Chrome 把 JSON 压成了一行,得点左上角那个「美观输出」才展开成分层结构。排查接口更推荐 curl,至少状态行和响应头能一起看见。
测试
spring-boot-starter-webmvc-test 带进来的两个东西,用起来挺舒服。
一个是 MockMvcTester,它保留了 MockMvc 的链式请求写法,断言换成 AssertJ。
| |
extractingPath 直接按 JSON Path 断言,测试失败的时候响应体完整打出来,不用再自己解析字符串。
另一个是 RestTestClient。它能绑定真实服务端,也能绑定 MockMvc 或者单个 Controller,API 风格和 RestClient 一致。
| |
在真实 Tomcat 上跑完整生命周期,创建、查询、分页、流转、更新、删除,一条链下来。
| |
| |
17 个用例,15 个是 MVC 切片测试,2 个是真实端口的端到端测试。两条路我都留着:切片测试跑得快,端到端测试兜真实行为的底,缺哪个心里都不踏实。
避坑指南
这部分是工程里实打实踩到的,按痛苦程度排序。
坑一,控制器类上不要写 @Validated。
这个坑最阴,我在它上面耗了半天。控制器上顺手加了 @Validated,测试里「size 超过上限应该返回 400」那条直接失败,实际返回的是 500。
Spring Framework 官方文档里写得很直白。
If a controller has a class level
@Validated, then method validation is applied through an AOP proxy. In order to take advantage of the Spring MVC built-in support for method validation added in Spring Framework 6.1, you need to remove the class level@Validatedannotation from the controller.
类上一旦有 @Validated,方法校验就走 AOP 代理链路,抛 ConstraintViolationException。而内置校验抛的是 HandlerMethodValidationException,两者不是同一个异常,全局异常处理器里为 400 准备的那条分支不会命中,最后掉进兜底的 500 里。
这两个异常要同时处理。
Applications should handle both
MethodArgumentNotValidExceptionandHandlerMethodValidationExceptionsince either may be raised depending on the controller method signature.
请求体上的 @Valid 校验的是单个对象,抛 MethodArgumentNotValidException;而 @RequestParam 上直接挂 @Min、@Max 这种约束注解,走的是方法级校验,抛 HandlerMethodValidationException。工程里两种都有,两个 handle 方法都覆盖了。你要是只处理了其中一个,漏掉的那种请求就会莫名其妙变 500。
坑二,@AutoConfigureMockMvc 的包路径是 org.springframework.boot.webmvc.test.autoconfigure。
写成 org.springframework.boot.test.autoconfigure.web.servlet 会找不到类,编译过不去。我看到一堆 @WebMvcTest 找不到的时候先查的是依赖,折腾了两轮才想起查 import;依赖加了没用,得改 import。
另外,@SpringBootTest 不再隐式启用 MockMvc,注入 MockMvc 必须显式写 @AutoConfigureMockMvc。
坑三,Jackson 3 的包名前缀是 tools.jackson,注解模块除外。
databind 和 core 的前缀是 tools.jackson,jackson-annotations 模块还在 com.fasterxml.jackson.annotation 包下。
所以 @JsonProperty 从 com.fasterxml.jackson.annotation 导入,而 JsonMapper 得从 tools.jackson.databind.json 导入。迁移指南里写明了这是官方设计。这个组合很反直觉,一个库两套包名,IDE 的自动 import 经常替你选错。
另外,Boot 4 自动配置的 JsonMapper 可以直接注入,测试里可以用它解析响应体。
| |
坑四,Location 头给的是绝对地址,反代部署要注意。
ServletUriComponentsBuilder.fromCurrentRequest() 出来的路径实测是 http://127.0.0.1:18080/api/orders/1,绝对的。
直连没问题;挂到 Nginx 或网关后面时,容器里看到的是内网地址和内网协议,不对 X-Forwarded-* 头做处理,Location 会给出一个客户端访问不到的地址。
有现成的配置,加一行就行。
| |
坑五,重复删除返回了空响应体。
删除接口最初这么写。
| |
第一次删返回 204,没问题。第二次删返回 404,状态码也对。但响应体是空的,而其它所有接口的 404 都是 application/problem+json。
12 个场景跑完回头核对才发现,同一个状态码有两种响应格式,客户端得写两套解析逻辑。这也是我坚持把每个响应逐条抄下来的原因:自动化测试没覆盖到的地方,肉眼核对兜住了。
改成让服务层直接抛 ResourceNotFoundException,走统一的异常处理,格式就一致了。
| |
这个坑的教训:接口的一致性靠对着实际响应一条条核对。自动化测试能守住,前提是先想到要测这个。
坑六,别把 Page 直接返回给前端。
上面提过一次,再强调一遍。Page 序列化出来带着 pageable、sort、first、last、offset 这些 Spring Data 实现细节字段,会随框架升级悄悄变化,接口契约跟着变。
坑七,排序字段和分页顺序这两个细节。
排序字段不白名单,轻则异常,重则信息泄漏。分页不加次排序键,会出现翻页时数据重复或漏项。这两个都是「上线了才被发现」的问题,本地测试数据量小的时候看不出来。
小结
这个主题的当前写法整理成一张表,新工程照着抄就行。
| 项目 | 当前写法 |
|---|---|
| Web starter | spring-boot-starter-webmvc |
| 测试依赖 | spring-boot-starter-webmvc-test |
| MockMvc 注解包 | org.springframework.boot.webmvc.test.autoconfigure |
| MockMvc 启用 | @SpringBootTest 之外显式加 @AutoConfigureMockMvc |
| 控制器校验 | 类上不写 @Validated,用内置方法校验 |
| JSON 包名 | tools.jackson(注解模块除外) |
| 错误响应 | RFC 9457 ProblemDetail |
| 422 状态码常量 | UNPROCESSABLE_CONTENT |
最后那行解释一下:RFC 9110 把 422 的语义描述从 entity 换成了 content,HttpStatus 里对应的常量名是 UNPROCESSABLE_CONTENT。状态码本身还是 422;按 UNPROCESSABLE_ENTITY 这个名字引用常量,编译会报错。我第一次写就报了,查了半天才发现常量改了名,闹了个小笑话。
要说选型建议,我的排序是:新工程直接用拆分后的 starter,别背 classic 聚合包;错误响应从第一天就开 ProblemDetail,别等前端对接完了再改格式;控制器类上永远不挂 @Validated;响应体和入参 DTO 都不复用领域模型。这几件事当下成本都极低,收益全是长期的,属于稳稳的赚。
没解决的事也直说:OrderRepository 现在只有内存实现,数据进程重启就清空,这是刻意的,让工程不依赖任何外部中间件就能跑起来。但这个接口的形状能不能原样扛住 MyBatis、JPA 那类真实现,分页和排序的契约要不要跟着调,我还没验证,这是这套骨架眼下最悬的一处。