11- [深度交互] 高级请求处理与数据绑定

3. [深度交互] 高级请求处理与数据绑定

摘要: 在第二章的实战中,我们已经搭建了项目骨架并实现了核心的 CRUD 功能。这让我们对 Spring MVC 的基础工作流程有了扎实的体感。从本章开始,我们将深入框架的“毛细血管”,探索那些能让我们的代码更灵活、更健壮、更专业的高级功能。

3.1. 自定义类型转换器:实现枚举参数绑定

3.1.1. 需求分析:实现按状态筛选用户

2.x 版本中,我们的用户查询接口只能进行简单的分页。现在,产品经理提出了新需求:在查询用户列表时,能够根据用户状态(正常/禁用)进行筛选

从 API 设计的角度,一个理想的请求 URL 应该是这样的:GET /users?status=1,其中 1 代表“正常”。

在后端,为了代码的可读性和健壮性,我们不希望在代码里到处使用 12 这样的“难懂数字”,而是倾向于使用更具语义的枚举 (Enum) 来代表用户状态。这就带来了一个问题:

Spring MVC 默认不知道如何将前端传来的字符串 "1" 转换为我们后端定义的 UserStatusEnum 枚举。 本节,我们就来优雅地解决这个问题。

3.1.2. 改造实践:在 DTO 与 Service 中使用枚举

1. 创建状态枚举

首先,我们创建一个代表用户状态的枚举类。

文件路径: src/main/java/com/example/springbootdemo/enums/UserStatusEnum.java (新增文件)

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
package com.example.springbootdemo.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UserStatusEnum {
NORMAL(1, "正常"),
DISABLED(2, "已禁用");

private final int code;
private final String description;

// 根据 code 查找枚举的静态方法,便于后续转换
public static UserStatusEnum fromCode(int code) {
for (UserStatusEnum status : values()) {
if (status.getCode() == code) {
return status;
}
}
// 如果找不到匹配的 code,可以返回 null 或抛出异常
return null;
}
}

2. 更新查询 DTO

接下来,我们在分页查询 DTO 中,添加 status 字段,并将其类型定义为我们刚刚创建的枚举。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.springbootdemo.dto.User;

import com.example.springbootdemo.enums.UserStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户分页查询参数")
public class UserPageQuery {

@Schema(description = "页码,从1开始", example = "1")
private int pageNo = 1;

@Schema(description = "每页条数", example = "10")
private int pageSize = 10;

@Schema(description = "用户状态: 1-正常, 2-禁用", example = "1")
private UserStatusEnum status; // 添加 status 字段,类型为枚举
}

3. 更新 Service 层

现在,我们修改 Service 层的 findAllUsers 方法,让它能够根据传入的 status 参数,动态地构建查询条件。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

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
28
29
30
31
32
33
34
35
36
package com.example.springbootdemo.service.impl;

// ... other imports ...
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.springbootdemo.entity.User;

// ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
public List<UserVO> findAllUsers(UserPageQuery query) {
Page<User> page = new Page<>(query.getPageNo(), query.getPageSize());

// 1. 创建 LambdaQueryWrapper 来构建查询条件,支持Lambda表达式
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();

// 2. 只有当 query.getStatus() 不为 null 时,才添加状态筛选条件
UserStatusEnum status = query.getStatus();
if (ObjectUtil.isNotEmpty(status)) {
queryWrapper.eq(User::getStatus, status.getCode());
}

// 3. 执行分页查询
Page<User> pageResult = userMapper.selectPage(page, queryWrapper);

// 4. 转换为 VO 列表并返回
return pageResult.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
}
}

3.1.3. 核心技术:实现并注册自定义 Converter

完成了业务逻辑的改造,现在我们来解决最核心的问题:搭建起前端传入的字符串 “1” 和后端 UserStatusEnum.NORMAL 之间的桥梁。

我们需要实现 Spring 提供的 Converter<S, T> 接口,其中 S 是源类型(String),T 是目标类型(UserStatusEnum)。

文件路径: src/main/java/com/example/springbootdemo/converter/StringToUserStatusEnumConverter.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.springbootdemo.converter;

import com.example.springbootdemo.enums.UserStatusEnum;
import org.springframework.stereotype.Component;
import org.springframework.core.convert.converter.Converter;

@Component // 将此转换器注册为 Spring Bean,Spring Boot 会自动发现并应用它
public class StringToUserStatusEnumConverter implements Converter<String, UserStatusEnum> {
@Override
public UserStatusEnum convert(String source) {
if (source == null || source.isEmpty()) {
return null;
}
// 根据前端传入的 1 或 2 转换成对应的枚举值
int code = Integer.parseInt(source);
return UserStatusEnum.fromCode(code);
}
}

自动注册的魔力:因为我们将这个转换器声明为了一个 @Component Bean,Spring Boot 的自动配置机制会扫描到它,并自动将其添加到全局的转换服务中。这意味着我们无需任何额外配置,这个转换规则就会对所有 Controller 生效。

最妙的是,我们的 UserController 中的 getAllUsers 方法无需任何改动。Spring MVC 在进行参数绑定时,会自动发现并使用我们自定义的 StringToUserStatusEnumConverter,将 status 请求参数(String 类型)转换为 UserPageQuery 对象中的 status 字段(UserStatusEnum 类型)。

示例流程如下图所示:

mermaid-diagram-2025-08-17-104210


一个 HTTP 请求所承载的信息,远不止 URL 查询参数和请求体。请求头(Headers)和 Cookies 也是传递上下文信息的重要载体。本节,我们将通过一系列真实的业务场景,来学习如何通过注解,轻松地获取这些位置的数据。

3.2.1. @RequestHeader:获取请求头信息

@RequestHeader 注解用于将请求头(Request Header)中的字段值,绑定到控制器方法的参数上。

场景一:API 版本控制

在 API 开发中,我们经常通过请求头来传递版本号,以便后端可以针对不同版本的客户端返回不同的数据结构或执行不同的逻辑。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class VersionController {

@GetMapping("/version")
public String getApiVersion(
@RequestHeader(value = "X-API-Version", defaultValue = "1.0") String apiVersion) {
return "当前请求的 API 版本号是: " + apiVersion;
}
}

解释:
getApiVersion 方法通过 @RequestHeader("X-API-VERSION") 注解获取请求头中的版本信息,并提供了一个默认值 "1.0"

场景二:链路追踪

在微服务架构中,为了追踪一个请求在多个服务之间的调用链,通常会在初始请求时生成一个唯一的追踪ID(Trace ID),并通过请求头(如 X-Trace-Id)在后续服务间传递。

代码示例:

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
package com.example.springbootdemo.controller;

import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class TraceController {

@GetMapping("/trace")
public String getTraceInfo(
@RequestHeader(value = "X-Trace-Id", required = false) String traceId) {

// 如果没有追踪ID,我们可以为其生成一个
if (StrUtil.isBlank(traceId)) {
traceId = cn.hutool.core.util.IdUtil.fastSimpleUUID();
}

// 在日志中打印追踪ID,便于后续ELK等日志系统进行聚合查询
log.info("处理业务逻辑, Trace ID: {}", traceId);

return "请求已处理, Trace ID: " + traceId;
}
}

解释:
getTraceInfo 方法获取一个可选的 X-Trace-Id 请求头。我们可以在日志中记录它,这对于问题排查至关重要。

@CookieValue 注解是 Spring 框架中用于获取 HTTP 请求中 Cookie 值的便捷工具。

场景一:用户认证

在传统的会话管理中,用户的会话ID(Session ID)通常存储在 Cookie 中。通过 @CookieValue 注解,可以轻松获取用户的会话信息。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

@GetMapping("/auth/info")
public String getUserInfo(@CookieValue("session-id") String sessionId) {
// 实际业务中,会根据 sessionId 从 Redis 或其他存储中获取用户信息
String userInfo = getUserInfoFromSession(sessionId);
return "获取到用户信息: " + userInfo;
}

private String getUserInfoFromSession(String sessionId) {
// 模拟从会话存储中获取用户信息
return "User_" + sessionId.substring(0, 6);
}
}

解释:
getUserInfo 方法通过 @CookieValue("session-id") 注解获取用户的会话 ID,并根据会话 ID 获取用户信息。

场景二:语言偏好设置

在多语言应用中,通常会将用户的语言偏好(如 en-US, zh-CN)存储在 Cookie 中。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LanguageController {

@GetMapping("/language")
public String getLanguagePreference(
@CookieValue(value = "language", defaultValue = "zh-CN") String language) {
return "您当前的语言偏好是: " + language;
}
}

解释:
getLanguagePreference 方法通过 @CookieValue("language") 注解获取用户的语言偏好,并优雅地使用了默认值 "zh-CN"

3.2.3. @PathVariable: 路径变量回顾

最后,我们再次回顾一个已经熟练使用的注解——@PathVariable,以形成完整的知识体系。它专门用于从 URL 路径中提取动态片段。

回顾代码
文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (回顾)

1
2
3
4
5
6
7
8
9
10
// ...
@Operation(summary = "根据ID查询单个用户")
@GetMapping("/{id}")
public ResponseEntity<Result<UserVO>> getUserById(
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id // @PathVariable 从路径 /users/{id} 中提取 id
) {
// ...
}
// ...

总结:参数绑定的位置
至此,我们已经掌握了从 HTTP 请求不同位置获取数据的核心注解:

  • @PathVariable: 从 URL 路径 (/users/{id}) 中获取。
  • @RequestParam: 从 URL 查询参数 (?name=value) 中获取。
  • @RequestHeader: 从 请求头 (Headers) 中获取。
  • @CookieValue: 从 Cookie 中获取。
  • @RequestBody: 从 请求体 (Request Body) 中获取。

3.3. 解构请求体:@RequestBody 与 Jackson 定制

在第二章,我们已经成功地使用 @RequestBody 将前端传来的 JSON 数据自动绑定到了 UserSaveDTO 上。这个过程之所以能自动完成,是因为 Spring Boot 默认集成的 Jackson 库在背后默默地承担了“反序列化”(JSON -> Java 对象)的工作。

然而,在真实的业务场景中,我们经常会遇到前端约定的 JSON 格式与后端 Java 对象的属性不完全一致的情况。例如,字段命名风格不同(下划线 vs. 驼峰)、日期格式需要特殊处理、某些字段需要被忽略等。本节,我们就将深入学习如何通过 Jackson 提供的注解,来精确地定制 JSON 与 Java 对象之间的相互转换,进一步优化我们的用户管理 API。

3.3.1. 需求升级:定制 JSON 字段与格式

现在,我们的项目收到了来自前端团队的两个新需求:

  1. 命名风格统一:前端团队习惯使用下划线命名法 (snake_case),他们希望所有 API 交互的 JSON 字段都遵循此规范。例如,Java 中的 username 属性,在 JSON 中应该显示为 user_name
  2. 日期格式化:我们需要为用户添加一个创建时间 createTime 字段。在查询用户时,需要将这个 LocalDateTime 类型的字段格式化为 yyyy-MM-dd HH:mm:ss 的标准字符串格式返回给前端。
  3. 安全增强:在任何情况下,用户的 password 字段都绝对不能出现在返回给前端的 JSON 数据中。

3.3.2. 改造实践:在 VO 与 DTO 中应用 Jackson 注解

1. 更新数据库与实体类

首先,我们需要为 t_user 表添加 create_time 字段。请在您的数据库中执行以下 SQL 语句:

1
2
ALTER TABLE `t_user`
ADD COLUMN `create_time` datetime NULL COMMENT '创建时间' AFTER `status`;

接着,更新 User 实体类。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdemo.entity;

// ... imports ...
import java.time.LocalDateTime;

@Data
@TableName("t_user")
public class User {
// ... 其他字段 ...
private Integer status;
private LocalDateTime createTime; // 新增创建时间字段
}

2. 定制 VO (View Object)

现在,我们来改造 UserVO,以满足前端的输出格式需求。

文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.springbootdemo.vo;

import cn.hutool.core.annotation.Alias;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@JsonInclude(JsonInclude.Include.NON_NULL) // 序列化时,值为 null 的字段将被忽略
public class UserVO {

private Long id;

@JsonProperty("user_name") // 将 name 属性在 JSON 中映射为 user_name
@Alias("username") // HuTool用于转换的别名,在2.1章节中我们使用过 他和我们的实体类是对应的
private String name;

private String statusText;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 将 LocalDateTime 格式化为指定样式
private LocalDateTime createTime;
}

3. 定制 DTO (Data Transfer Object)

同样,我们也需要改造 UserSaveDTO,以正确接收前端传递的输入数据

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.dto.User;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", required = true, example = "newuser")
@JsonProperty("user_name") // 接收前端传来的 user_name 字段,并映射到 username 属性
private String username;

@Schema(description = "密码", required = true, example = "123456")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
private String email;
}

4. 更新 Service 层

最后,我们需要在 Service 层中处理 createTime 字段的赋值和转换。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

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
28
29
30
31
32
33
34
35
36
37
38
39
40
// ... imports ...
import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... findUserById 和 findAllUsers 方法保持不变 ...

@Override
public Long saveUser(UserSaveDTO dto) {
// ... 已有的业务校验 ...
User user = Convert.convert(User.class, dto);
user.setStatus(1);
user.setCreateTime(LocalDateTime.now()); // 设置创建时间

userMapper.insert(user);
return user.getId();
}

private UserVO convertToVO(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
BeanUtil.copyProperties(user, userVO, "username");
userVO.setName(user.getUsername());
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
// 拷贝 createTime 属性
userVO.setCreateTime(user.getCreateTime());
// 注意,我们已经在Hutool中使用过了别名注解,所以这里不需要对于username进行转换
return userVO;
}

// ... updateUser 和 deleteUserById 方法保持不变 ...
}

3.3.3. 核心技术:Jackson 核心注解详解

我们刚刚在实战中使用了几个强大的 Jackson 注解,现在来系统性地总结一下:

注解作用常用场景
@JsonProperty在 Java 属性和 JSON 字段之间建立双向映射关系。解决 Java(驼峰)与 JSON(下划线)的命名不一致问题。
@JsonFormat序列化时,将日期时间类型格式化为指定的字符串样式。LocalDateTime 格式化为 yyyy-MM-dd HH:mm:ss
@JsonIgnore在序列化和反序列化时,完全忽略某个属性。防止密码等敏感信息泄露到前端。
@JsonInclude序列化时,可以指定包含属性的条件,最常用的是 NON_NULL忽略值为 null 的字段,精简 API 响应体。

3.3.4. 回归测试:验证定制效果

重启应用并访问 http://localhost:8080/swagger-ui.html

测试新增接口 (POST)

  1. 在 Swagger UI 中,展开 POST /users 接口。
  2. 验证:您会发现 Request body 的 Schema 示例中,字段名已经变成了 user_name
  3. 使用 { "user_name": "jackson_user", "password": "123", "email": "jackson@test.com" } 作为请求体执行请求。
  4. 请求会成功,证明我们的后端已能正确接收 user_name 字段。

测试查询接口 (GET)

  1. 在 Swagger UI 中,执行 GET /users/{id},查询我们刚刚新增的记录。
  2. 验证:您会看到响应的 JSON 中,createTime 字段被格式化为了 "2025-08-17 10:30:00",由于我们之前的Vo对象并不期望

3.4. 数据校验:Validation API 最佳实践

目前,我们的新增(saveUser)和修改(updateUser)接口存在一个严重的安全隐患:我们对前端传来的数据完全信任。这会导致数据库中出现大量的“垃圾数据”,甚至引发程序异常。

本节,我们将学习如何通过 Jakarta Bean Validation API 和 Spring 的 @Validated 注解,实现声明式的、自动化的参数校验。

3.4.1. 关键一步:引入 Validation Starter

要使校验注解生效,我们必须首先在 pom.xml 中显式地添加 spring-boot-starter-validation 依赖。

文件路径: pom.xml (修改)

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

3.4.2. 改造实践:为 DTO 添加 Validation 注解

现在,我们为 DTO 的字段添加上具体的校验规则。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java (修改)

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
package com.example.springbootdemo.dto.User;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "newuser")
@JsonProperty("user_name")
@NotBlank(message = "用户名不能为空")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
@Email(message = "邮箱格式不正确")
private String email;
}

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.springbootdemo.dto.User;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
@Schema(description = "用户修改数据传输对象")
public class UserUpdateDTO {

@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "用户ID不能为空")
private Long id;

@Schema(description = "邮箱", example = "new_email@example.com")
@Email(message = "邮箱格式不正确")
private String email;

// ... 其他需要校验的字段 ...
}

3.4.3. 核心技术:在 Controller 中使用 @Validated 激活校验

仅仅在 DTO 中添加注解还不够,我们还需要在 Controller 中明确地开启校验。

  1. UserController 上添加 @Validated 注解。
  2. 在需要校验的 @RequestBody 参数前,同样使用 @Validated 注解。

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

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
28
29
30
31
32
33
34
35
36
package com.example.springbootdemo.controller;

// ... other imports ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated // 1. 在类上添加 @Validated 注解
public class UserController {

private final UserService userService;

// ... 已有的 GET 接口 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(@Validated @RequestBody UserSaveDTO dto) {
Long userId = userService.saveUser(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(@Validated @RequestBody UserUpdateDTO dto) {
userService.updateUser(dto);
return ResponseEntity.ok(Result.success());
}

// ... 已有的 DELETE 接口 ...
}

3.4.4. 回归测试:验证校验效果

重启应用并访问 http://localhost:8080/swagger-ui.html

  1. 展开 POST /users 接口,点击 “Try it out”
  2. 在请求体中输入用户名为空格的非法数据:
    1
    2
    3
    4
    5
    {
    "user_name": " ",
    "password": "password123",
    "email": "swagger@example.com"
    }
  3. 点击 “Execute”

预期结果
这一次,请求会被成功拦截,您会看到服务器返回了一个 400 Bad Request 错误,响应体中包含了详细的、由 Spring Boot 默认格式化的校验失败信息

虽然校验成功了,但这个默认的错误响应格式并不清晰,对前端并不友好。在 第四章,我们将学习如何通过全局异常处理来捕获这类 MethodArgumentNotValidException 异常,并返回我们自定义的、结构统一的 Result 错误信息,从而完美解决这个问题。


3.4.5. 进阶:分组校验与 @Validated

痛点:我们当前的校验有一个潜在问题。@Validated 会触发 DTO 内所有它能找到的校验注解。但如果未来我们的 UserSaveDTOUserUpdateDTO 中有同名字段,但校验规则却略有不同呢?或者,我们想创建一个包含所有字段的 UserDTO,然后根据是“新增”还是“修改”场景,来执行不同的校验规则,应该怎么做?

解决方案:使用 @Validated 注解独有的分组校验功能

定义校验分组接口:
文件路径: src/main/java/com/example/springbootdemo/validation/ValidationGroups.java(新增文件)

1
2
3
4
5
package com.example.springbootdemo.validation;
public interface ValidationGroups {
interface Save {}
interface Update {}
}

我们将通过一次代码重构,来真正体验分组校验的强大之处。我们的目标是:废弃 UserSaveDTOUserUpdateDTO,只用一个 UserEditDTO 来同时服务于新增和修改两个场景。

1. 创建统一的 UserEditDTO

这个新的 DTO 将包含新增和修改所需的所有字段,并通过 groups 属性为每个字段的校验规则打上“场景标签”。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserEditDTO.java (新增文件)

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
28
29
30
31
32
33
package com.example.springbootdemo.dto.User;

import com.example.springbootdemo.validation.ValidationGroups;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
@Schema(description = "用户编辑(新增/修改)数据传输对象")
public class UserEditDTO {

@Schema(description = "用户ID,修改时必填", example = "1")
@NotNull(message = "用户ID不能为空", groups = ValidationGroups.Update.class)
private Long id;

@Schema(description = "用户名,新增时必填", example = "newuser")
@JsonProperty("user_name")
@NotBlank(message = "用户名不能为空", groups = ValidationGroups.Save.class)
private String username;

@Schema(description = "密码,新增时必填,修改时可选", example = "123456")
@NotBlank(message = "密码不能为空", groups = ValidationGroups.Save.class)
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class})
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
@Email(message = "邮箱格式不正确", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class})
private String email;
}

注解解析:

  • @NotNull(groups = ValidationGroups.Update.class): id 字段只在 Update 这个场景下才校验非空。
  • @NotBlank(groups = ValidationGroups.Save.class): usernamepassword 字段只在 Save 这个场景下才校验非空。
  • @Size(groups = {Save.class, Update.class}): 密码长度的校验,在 SaveUpdate 两种场景下都会生效(前提是 password 字段不为 null)。

2. 重构 Service 层

现在,我们修改 UserService 接口和实现类,让它们都使用这个新的 UserEditDTO

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserEditDTO; // 修改导入
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {
// ...
Long saveUser(UserEditDTO dto); // 修改参数类型
void updateUser(UserEditDTO dto); // 修改参数类型
void deleteUserById(Long id);
}

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... imports ...
import com.example.springbootdemo.dto.User.UserEditDTO;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@Override
public Long saveUser(UserEditDTO dto) { // 修改参数类型
}
@Override
public void updateUser(UserEditDTO dto) { // 修改参数类型
}
// ...
}

3. 重构 Controller 层 (见证奇迹)

最后,我们来修改 UserController

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

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
28
29
30
31
32
33
34
35
36
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserEditDTO;
// ...
import com.example.springbootdemo.validation.ValidationGroups;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserController {

private final UserService userService;

// ... GET 接口不变 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(
@Validated(ValidationGroups.Save.class) @RequestBody UserEditDTO dto) {
Long userId = userService.saveUser(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(
@Validated(ValidationGroups.Update.class) @RequestBody UserEditDTO dto) {
userService.updateUser(dto);
return ResponseEntity.ok(Result.success());
}

// ... DELETE 接口不变 ...
}

4. 清理与验证

现在,您可以安全地删除 UserSaveDTO.javaUserUpdateDTO.java 这两个文件了。

重启应用并访问 Swagger UI:

  • 测试新增 (POST /users):
    • 如果您不提供 user_namepassword,请求将被 400 Bad Request 拦截。
    • 如果您提供了 id,它会被忽略。
  • 测试修改 (PUT /users):
    • 如果您不提供 id,请求将被 400 Bad Request 拦截。
    • 您可以不提供 password,只修改 email,请求会成功。
    • 如果您提供了 user_name,它会被忽略(因为 DTO 到 PO 的转换不会处理这个字段)。

这才是分组校验的真正威力! 我们通过一个 UserEditDTO,结合 @Validated 注解中不同的分组,实现了对“新增”和“修改”两个不同业务场景的精准校验,极大地提升了代码的复用性和可维护性。