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 的接口」。约束就那么几条,但每一条都有人违反,我评审接口文档时圈出来最多的也是这几处。

https://static.xiongneng.me/rest-resource-methods-20260921204934.png

最容易被违反的一条: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 的关键约定

坑基本都集中在这一块,我按「最容易踩」到「容易忘」的顺序写。

https://static.xiongneng.me/modularization-20260921204934.png

先说最容易忽略的一个:Web 场景的 starter 是 spring-boot-starter-webmvc。

场景当前 starter
Web MVCspring-boot-starter-webmvc
AOPspring-boot-starter-aspectj
OAuth2 资源服务器spring-boot-starter-security-oauth2-resource-server
Web Servicesspring-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 下。

拉出来的依赖树是这样的。

1
2
3
tools.jackson.core:jackson-databind:3.1.5
+- com.fasterxml.jackson.core:jackson-annotations:2.21
\- tools.jackson.core:jackson-core:3.1.5

整个依赖树里没有 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 就三个依赖,很朴素。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
</parent>

<properties>
    <java.version>21</java.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <!-- 参数校验 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!-- Web 工程配套的测试 starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

application.yml 关键就两处。

1
2
3
4
5
6
spring:
  mvc:
    problemdetails:
      enabled: true
  jackson:
    default-property-inclusion: non_null

第二行让空值不出现在响应体里,是契约选择。第一行才是重点,ProblemDetail 全靠它。

这个骨架后面几篇会直接复用,MyBatis、JPA、MongoDB 那几篇只换仓储实现。所以包结构我多花了一点心思,六个包各管一摊:

1
2
3
4
5
6
controller/   资源入口,只做参数绑定与响应组装
service/      业务规则,状态机、订单号生成、终态保护
repository/   仓储接口 + 内存实现
domain/       领域模型、状态枚举、查询条件
dto/          请求体与响应体
exception/    异常定义与全局处理

https://static.xiongneng.me/layer-stack-20260921204934.png

请求从 controller 进来一路向下,响应和异常往上冒。两个刻意的设计放在这里说。

一个是领域模型用 record 做不可变。订单对象一旦创建就不能改,要改只能生成一个新的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public record Order(
        Long id, String orderNo, String customerName, String customerPhone,
        BigDecimal totalAmount, OrderStatus status, String remark,
        Instant createdAt, Instant updatedAt
) {
    public Order withStatus(OrderStatus newStatus, Instant updatedAt) {
        return new Order(id, orderNo, customerName, customerPhone, totalAmount,
                newStatus, remark, createdAt, updatedAt);
    }
}

这么写的好处是,仓储层的「更新」变成整对象替换,读操作天然拿到某个一致快照,不会出现「读到一半的订单」。存储用 ConcurrentHashMap 存整对象,连锁都不用加。刚下笔时我嫌这么写拘谨,真写下来发现它把并发问题直接消掉了,值。

另一个是响应体绝不复用领域模型。这是接口契约稳定性的问题。

OrderResponse 里做了两件领域模型不该关心的事:手机号脱敏,以及把状态枚举翻译成中文描述。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static OrderResponse from(Order order) {
    return new OrderResponse(
            order.id(), order.orderNo(), order.customerName(),
            maskPhone(order.customerPhone()), order.totalAmount(),
            order.status(), order.status().getLabel(), order.remark(),
            order.createdAt(), order.updatedAt());
}

private static String maskPhone(String phone) {
    if (phone == null || phone.length() != 11) return phone;
    return phone.substring(0, 3) + "****" + phone.substring(7);
}

文章开头说的「别把内部结构泄漏出去」,还有个更常见的坑:直接返回 Spring Data 的 Page。

Page 序列化出来会带着十几个字段,pageable、sort、first、last、numberOfElements、offset,全是 Spring Data 的内部实现细节。这些字段一旦随框架版本变动,前端就得跟着改。工程里用了自己的 PageResponse,只保留 list、page、size、total、totalPages 五个字段。

核心代码

状态机得写在枚举里

订单状态不能随便改,CREATED 不能直接跳到 COMPLETED。这类规则最怕散落在各个 if 里,过两个月谁也不知道完整规则是什么,我在别人的老项目里吃过这个亏,所以这次换了写法。

做法是把流转规则收进枚举本身。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public enum OrderStatus {
    CREATED("已创建"), PAID("已支付"), SHIPPED("已发货"),
    COMPLETED("已完成"), CANCELLED("已取消");

    private final String label;

    public boolean canTransferTo(OrderStatus target) {
        return switch (this) {
            case CREATED -> target == PAID || target == CANCELLED;
            case PAID -> target == SHIPPED || target == CANCELLED;
            case SHIPPED -> target == COMPLETED;
            case COMPLETED, CANCELLED -> false;
        };
    }
}

switch 表达式在 Java 21 上写出来干净,新增状态时编译器会直接报错提醒补规则。这比散落的 if 强太多。

流转关系画出来是这样。

1
2
3
CREATED ──> PAID ──> SHIPPED ──> COMPLETED
   │          │
   └──────────┴──────> CANCELLED

https://static.xiongneng.me/order-state-machine-20260921204934.png

COMPLETED 和 CANCELLED 是终态,不光不接受状态流转,也不允许通过 PUT 改任何业务字段。这条规则放在服务层的 ensureModifiable 里,实测返回 422。

入参用独立 DTO,客户端就改不了它不该改的东西

请求体如果用领域模型来接,客户端就能多传一个 status 字段,直接把订单改成已完成。这是很经典的越权漏洞,防它的成本极低,就是别拿领域模型接请求体。

工程里用独立的 OrderCreateRequest,字段是白名单,并且带上校验注解。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public record OrderCreateRequest(
        @NotBlank(message = "客户姓名不能为空")
        @Size(max = 32, message = "客户姓名长度不能超过 32")
        String customerName,

        @NotBlank(message = "客户手机号不能为空")
        @Pattern(regexp = "^1[3-9]\\d{9}$", message = "客户手机号格式不正确")
        String customerPhone,

        @NotNull(message = "订单金额不能为空")
        @DecimalMin(value = "0.01", message = "订单金额必须大于 0")
        @Digits(integer = 10, fraction = 2, message = "订单金额最多 10 位整数、2 位小数")
        BigDecimal totalAmount,

        @Size(max = 200, message = "备注长度不能超过 200")
        String remark
) {}

注意这里的 message 全是中文。很多项目把校验提示写成英文,然后在 @ExceptionHandler 里再翻译一遍,直接写中文就完了,校验框架会把 message 原样带进响应。

另外,totalAmount 用 BigDecimal。金额不要用 double,原因直接看 0.1 + 0.2 != 0.3,真金白银出错的案例并不少见。

Controller 里的三个关键点

第一个是创建订单的 Location 头。

1
2
3
4
5
6
7
8
9
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody OrderCreateRequest request) {
    Order created = orderService.create(request);
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.id())
            .toUri();
    return ResponseEntity.created(location).body(OrderResponse.from(created));
}

ResponseEntity.created(location) 一次把状态码 201 和 Location 头都给了。为什么要在头里给出新资源的地址?因为客户端拿到响应后不需要自己拼 URL 就能直接访问这个订单。这就是 REST 里 HATEOAS 最朴素的那个意思。

实测回来的头是这样的。

1
2
3
HTTP/1.1 201
Location: http://127.0.0.1:18080/api/orders/1
Content-Type: application/json

第二个是状态流转,用 PATCH 子资源,路径是 /api/orders/{id}/status。PATCH 比 PUT 更准确,因为只改一个字段,不算整体替换。

https://static.xiongneng.me/status-transition-422-20260918125927.png

第三个是分页查询的排序白名单,这是有实际教训的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public record OrderPageQuery(String keyword, OrderStatus status,
                             int page, int size, String sortBy, boolean descending) {

    public static final Set<String> SORTABLE_FIELDS =
            Set.of("id", "orderNo", "customerName", "totalAmount",
                   "status", "createdAt", "updatedAt");

    public OrderPageQuery {
        if (sortBy == null || !SORTABLE_FIELDS.contains(sortBy)) {
            sortBy = "createdAt";
        }
        size = Math.clamp(size, 1, MAX_SIZE);
        page = Math.max(page, 0);
    }
}

不做白名单的后果:直接拿客户端传来的字段名去 Comparator.comparing,传一个 sort=abc,desc,程序就抛异常;传一个 sort=password,desc,在有些实现里甚至能变成一次信息探测。紧凑构造器里 Math.clamp 把 size 夹进合法区间,page 兜底到 0,凑合传进来的参数就不至于把接口打挂。

另外注意 comparator 里补了一个次排序键。

1
2
comparator = comparator.thenComparing(Order::id);
return query.descending() ? comparator.reversed() : comparator;

如果只按 createdAt 排序,两条记录的时间戳恰好相同时,相对顺序在不同的请求里可能不稳定,翻页时会看到同一条数据出现两次、另一条漏掉。加上主键做次排序键,顺序就稳定了。这个问题本地数据量小的时候看不出来,上线了才暴露,属于最烦的那类。

全局异常处理为什么继承而不是从零写

这是最常见的写法错误,我单独拎出来讲。

1
2
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

常见的写法是一个空的 @RestControllerAdvice 加一个 @ExceptionHandler(Exception.class)。这么写的话,Spring MVC 内部抛出来的 405、415、400 这些异常,全都掉进 Exception.class 那个分支里,变成 500 返回。

ResponseEntityExceptionHandler 这个基类里已经把框架的几十种异常归类处理好了,每种都对应正确的状态码和响应格式。只需要覆盖关心的那几个,剩下的交给它。站在基类肩膀上,比从零收拾几十种异常类型划算得多。

工程里覆盖了三个,加上两个自定义异常的处理器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleResourceNotFound(ResourceNotFoundException ex) {
    ProblemDetail problemDetail =
            ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problemDetail.setType(TYPE_RESOURCE_NOT_FOUND);
    problemDetail.setTitle("资源不存在");
    problemDetail.setProperty("resourceType", ex.getResourceType());
    problemDetail.setProperty("resourceId", ex.getResourceId());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail);
}

覆盖的三个分别是请求体校验失败、查询参数校验失败、请求体格式错误,作用都是把逐字段的错误信息整理进 errors 属性里。

ProblemDetail 有个 setProperty 方法,可以往标准五字段之外添加自定义属性。RFC 9457 明确允许,规范里说客户端应当忽略不认识的属性。所以 404 里可以塞 resourceType 和 resourceId,400 里可以塞 errors 数组,既符合标准,又多给了客户端有用的信息。

https://static.xiongneng.me/problem-detail-20260921204934.png

完整案例,跑起来看

先起服务。

1
2
mvn clean package
java -jar target/springboot4-restful-1.0.0.jar

日志里看到这行就成功了。

1
2
Tomcat started on port 8080 (http) with context path '/'
Started RestfulApplication in 1.74 seconds

启动 1.74 秒,这是裸工程在我这台机器上的数,你那边有出入属正常。

下面这些是实际跑出来的响应,一个字没改。

创建订单。

1
2
3
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"熊大","customerPhone":"13800138000","totalAmount":299.50,"remark":"演示下单"}'
1
2
3
4
HTTP/1.1 201
Location: http://localhost:8080/api/orders/1

{"id":1,"orderNo":"ORD2026091885919224","customerName":"熊大","customerPhone":"138****8000","totalAmount":299.50,"status":"CREATED","statusLabel":"已创建","remark":"演示下单","createdAt":"2026-09-18T04:59:23.787512600Z","updatedAt":"2026-09-18T04:59:23.787512600Z"}

三个细节都在里面了,201 状态码、Location 头、脱敏后的手机号 138****8000。订单号是服务端生成的,客户端无权指定。

https://static.xiongneng.me/restful-order-created-201-20260918125925.png

非法状态流转,返回 422。

1
2
curl -i -X PATCH http://localhost:8080/api/orders/1/status \
  -H "Content-Type: application/json" -d '{"status":"CREATED"}'
1
2
3
4
HTTP/1.1 422
Content-Type: application/problem+json

{"detail":"订单不允许从 已支付 变更为 已创建","instance":"/api/orders/1/status","status":422,"title":"业务规则不满足","type":"urn:problem-type:business-rule-violation"}

detail 这句话把「从什么状态到什么状态」都带上了,运维半夜看日志能一眼看明白,不用再去翻代码。

字段校验失败,返回 400,逐字段原因。

1
2
3
curl -i -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName":"","customerPhone":"123","totalAmount":-1}'

这种结构前端直接拿 errors 循环渲染到表单下面就行了,不用再写一堆字符串匹配。

未映射的方法,框架自己返回 405。

1
2
3
4
5
HTTP/1.1 405
Allow: POST, GET
Content-Type: application/problem+json

{"detail":"Method 'PATCH' is not supported.","instance":"/api/orders","status":405,"title":"Method Not Allowed"}

注意 Allow 头,框架直接给出这个路径支持哪些方法。客户端和调试工具都能自己读懂,不需要额外的文档说明。

总共跑了 12 个场景,状态码分别是 201、200、200、200、200、422、404、400、400、405、204、404,全部符合预期,应用日志里 ERROR 行数为 0。这串状态码我是对着终端记录一条条核过的,算得上铁证。

上面这些全是 curl 看到的。用浏览器直接打开接口呢?顺手开了一个不存在的订单截了图。

https://static.xiongneng.me/restful-browser-json-view-20260918125928.png

Chrome 把 JSON 压成了一行,得点左上角那个「美观输出」才展开成分层结构。排查接口更推荐 curl,至少状态行和响应头能一起看见。

测试

spring-boot-starter-webmvc-test 带进来的两个东西,用起来挺舒服。

一个是 MockMvcTester,它保留了 MockMvc 的链式请求写法,断言换成 AssertJ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Test
@DisplayName("分页查询:关键字过滤与每页条数生效,总页数按总数换算")
void pageFiltersByKeyword() {
    String tag = uniqueTag();
    createOrder(tag);
    createOrder(tag);
    createOrder(tag);

    AbstractJsonContentAssert<?> body = mockMvc.get().uri("/api/orders")
            .param("keyword", tag)
            .param("size", "2")
            .exchange()
            .assertThat()
            .hasStatusOk()
            .bodyJson();
    body.extractingPath("$.total").asNumber().isEqualTo(3);
    body.extractingPath("$.totalPages").asNumber().isEqualTo(2);
    body.extractingPath("$.list").asArray().hasSize(2);
}

extractingPath 直接按 JSON Path 断言,测试失败的时候响应体完整打出来,不用再自己解析字符串。

另一个是 RestTestClient。它能绑定真实服务端,也能绑定 MockMvc 或者单个 Controller,API 风格和 RestClient 一致。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiEndToEndTest {

    @LocalServerPort
    private int port;

    private RestTestClient client;

    @BeforeEach
    void setUp() {
        RestTestClient.Builder<?> builder = RestTestClient.bindToServer();
        builder.baseUrl("http://localhost:" + port);
        client = builder.build();
    }

在真实 Tomcat 上跑完整生命周期,创建、查询、分页、流转、更新、删除,一条链下来。

1
mvn test
1
2
Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

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 @Validated annotation from the controller.

类上一旦有 @Validated,方法校验就走 AOP 代理链路,抛 ConstraintViolationException。而内置校验抛的是 HandlerMethodValidationException,两者不是同一个异常,全局异常处理器里为 400 准备的那条分支不会命中,最后掉进兜底的 500 里。

这两个异常要同时处理。

Applications should handle both MethodArgumentNotValidException and HandlerMethodValidationException since 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 可以直接注入,测试里可以用它解析响应体。

1
2
@Autowired
private JsonMapper jsonMapper;

坑四,Location 头给的是绝对地址,反代部署要注意。

ServletUriComponentsBuilder.fromCurrentRequest() 出来的路径实测是 http://127.0.0.1:18080/api/orders/1,绝对的。

直连没问题;挂到 Nginx 或网关后面时,容器里看到的是内网地址和内网协议,不对 X-Forwarded-* 头做处理,Location 会给出一个客户端访问不到的地址。

有现成的配置,加一行就行。

1
2
server:
  forward-headers-strategy: native

坑五,重复删除返回了空响应体。

删除接口最初这么写。

1
2
3
return orderService.delete(id)
        ? ResponseEntity.noContent().build()
        : ResponseEntity.notFound().build();

第一次删返回 204,没问题。第二次删返回 404,状态码也对。但响应体是空的,而其它所有接口的 404 都是 application/problem+json。

12 个场景跑完回头核对才发现,同一个状态码有两种响应格式,客户端得写两套解析逻辑。这也是我坚持把每个响应逐条抄下来的原因:自动化测试没覆盖到的地方,肉眼核对兜住了。

改成让服务层直接抛 ResourceNotFoundException,走统一的异常处理,格式就一致了。

1
2
3
4
5
public void delete(Long id) {
    if (!orderRepository.deleteById(id)) {
        throw new ResourceNotFoundException("订单", id);
    }
}

这个坑的教训:接口的一致性靠对着实际响应一条条核对。自动化测试能守住,前提是先想到要测这个。

坑六,别把 Page 直接返回给前端。

上面提过一次,再强调一遍。Page 序列化出来带着 pageable、sort、first、last、offset 这些 Spring Data 实现细节字段,会随框架升级悄悄变化,接口契约跟着变。

坑七,排序字段和分页顺序这两个细节。

排序字段不白名单,轻则异常,重则信息泄漏。分页不加次排序键,会出现翻页时数据重复或漏项。这两个都是「上线了才被发现」的问题,本地测试数据量小的时候看不出来。

小结

这个主题的当前写法整理成一张表,新工程照着抄就行。

项目当前写法
Web starterspring-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 那类真实现,分页和排序的契约要不要跟着调,我还没验证,这是这套骨架眼下最悬的一处。

参考链接