3. [深度交互] 高级请求处理与数据绑定
摘要: 在第二章的实战中,我们已经搭建了项目骨架并实现了核心的 CRUD 功能。这让我们对 Spring MVC 的基础工作流程有了扎实的体感。从本章开始,我们将深入框架的“毛细血管”,探索那些能让我们的代码更灵活、更健壮、更专业的高级功能。
3.1. 自定义类型转换器:实现枚举参数绑定
3.1.1. 需求分析:实现按状态筛选用户
在 2.x 版本中,我们的用户查询接口只能进行简单的分页。现在,产品经理提出了新需求:在查询用户列表时,能够根据用户状态(正常/禁用)进行筛选。
从 API 设计的角度,一个理想的请求 URL 应该是这样的:GET /users?status=1,其中 1 代表“正常”。
在后端,为了代码的可读性和健壮性,我们不希望在代码里到处使用 1、2 这样的“难懂数字”,而是倾向于使用更具语义的 枚举 (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;
public static UserStatusEnum fromCode(int code) { for (UserStatusEnum status : values()) { if (status.getCode() == code) { return status; } } 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; }
|
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;
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());
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
UserStatusEnum status = query.getStatus(); if (ObjectUtil.isNotEmpty(status)) { queryWrapper.eq(User::getStatus, status.getCode()); }
Page<User> userPage = userMapper.selectPage(page, queryWrapper);
return userPage.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 public class StringToUserStatusEnumConverter implements Converter<String, UserStatusEnum> { @Override public UserStatusEnum convert(String source) { if (source == null || source.isEmpty()) { return null; } int code = Integer.parseInt(source); return UserStatusEnum.fromCode(code); } }
|
自动注册的魔力:因为我们将这个转换器声明为了一个 @Component Bean,Spring Boot 的自动配置机制会扫描到它,并自动将其添加到全局的转换服务中。这意味着我们 无需任何额外配置,这个转换规则就会对所有 Controller 生效。
最妙的是,我们的 UserController 中的 getAllUsers 方法 无需任何改动。Spring MVC 在进行参数绑定时,会自动发现并使用我们自定义的 StringToUserStatusEnumConverter,将 status 请求参数(String 类型)转换为 UserPageQuery 对象中的 status 字段(UserStatusEnum 类型)。
示例流程如下图所示:

一个 HTTP 请求所承载的信息,远不止 URL 查询参数和请求体。请求头(Headers)和 Cookies 也是传递上下文信息的重要载体。本节,我们将通过一系列真实的业务场景,来学习如何通过注解,轻松地获取这些位置的数据。
@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) { if (StrUtil.isBlank(traceId)) { traceId = cn.hutool.core.util.IdUtil.fastSimpleUUID(); }
log.info("处理业务逻辑, Trace ID: {}", traceId); return "请求已处理, Trace ID: " + traceId; } }
|
解释:
getTraceInfo 方法获取一个可选的 X-Trace-Id 请求头。我们可以在日志中记录它,这对于问题排查至关重要。
3.2.2. @CookieValue:获取 Cookie 信息
@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) { 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 字段与格式
现在,我们的项目收到了来自前端团队的两个新需求:
- 命名风格统一:前端团队习惯使用下划线命名法 (
snake_case),他们希望所有 API 交互的 JSON 字段都遵循此规范。例如,Java 中的 username 属性,在 JSON 中应该显示为 user_name。 - 日期格式化:我们需要为用户添加一个创建时间
createTime 字段。在查询用户时,需要将这个 LocalDateTime 类型的字段格式化为 yyyy-MM-dd HH:mm:ss 的标准字符串格式返回给前端。 - 安全增强:在任何情况下,用户的
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;
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
| 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) public class UserVO {
private Long id;
@JsonProperty("user_name") private String name;
private String statusText;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 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") 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
| import java.time.LocalDateTime;
@Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserMapper userMapper; @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 ? "正常" : "已禁用"); } userVO.setCreateTime(user.getCreateTime()); return userVO; } }
|
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)
- 在 Swagger UI 中,展开
POST /users 接口。 - 验证:您会发现
Request body 的 Schema 示例中,字段名已经变成了 user_name。 - 使用
{ "user_name": "jackson_user", "password": "123", "email": "jackson@test.com" } 作为请求体执行请求。 - 请求会成功,证明我们的后端已能正确接收
user_name 字段。
测试查询接口 (GET)
- 在 Swagger UI 中,执行
GET /users/{id},查询我们刚刚新增的记录。 - 验证:您会看到响应的 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 中明确地开启校验。
- 在
UserController 类 上添加 @Validated 注解。 - 在需要校验的
@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;
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 public class UserController {
private final UserService userService;
@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()); } }
|
3.4.4. 回归测试:验证校验效果
重启应用并访问 http://localhost:8080/swagger-ui.html。
- 展开
POST /users 接口,点击 “Try it out”。 - 在请求体中输入用户名为空格的非法数据:
1 2 3 4 5
| { "user_name": " ", "password": "password123", "email": "swagger@example.com" }
|
- 点击 “Execute”。
预期结果
这一次,请求 会被成功拦截,您会看到服务器返回了一个 400 Bad Request 错误,响应体中包含了详细的、由 Spring Boot 默认格式化的校验失败信息
虽然校验成功了,但这个默认的错误响应格式并不清晰,对前端并不友好。在 第四章,我们将学习如何通过 全局异常处理 来捕获这类 MethodArgumentNotValidException 异常,并返回我们自定义的、结构统一的 Result 错误信息,从而完美解决这个问题。
3.4.5 进阶:分组校验与 @Validated
在前面的章节中,我们学习了如何使用 Validation 框架对 DTO 进行数据校验,这极大地提升了接口的健壮性。然而,随着业务场景变得复杂,一个更为棘手的问题浮出水面:如何让同一个 DTO 在不同业务场景下,应用不同的校验规则?
痛点分析
我们之前的设计虽然能工作,但其脆弱性和冗余性会随着项目复杂度的增加而暴露无遗:
- 高度冗余:
UserSaveDTO 和 UserUpdateDTO 中存在大量重复的字段(如 password, email)和校验逻辑(如 @Size, @Email)。 - 维护困难: 如果未来需要给用户增加一个
phone 字段,且该字段在新增和修改时都需要校验,那么你必须同时修改两个 DTO 文件,极易遗漏。 - 扩展性差: 如果再增加一个“管理员重置密码”的场景,难道要再创建一个
UserResetPasswordDTO 吗?这会导致 DTO 类的爆炸式增长。
问题的根源在于,我们试图用 类的不同 来区分 场景的不同。而更优雅的解法,应该是用 一套数据结构,辅以 场景标签 来解决问题。这正是 @Validated 分组校验的核心思想。
解决方案:使用分组校验重构
我们的目标是:废弃 UserSaveDTO 和 UserUpdateDTO,只使用一个 UserEditDTO 来同时服务于新增和修改两个场景。
第一步:定义校验分组
分组的本质是“场景标签”。在 Java 中,最轻量的标签就是 空接口。
文件路径: src/main/java/com/example/springbootdemo/validation/ValidationGroups.java (新增)
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.example.springbootdemo.validation;
public interface ValidationGroups { interface Save {} interface Update {} }
|
第二步:创建统一的 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 34 35 36 37 38
| 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; }
|
groups 属性解析:
groups 属性接收一个或多个分组接口的 Class 对象。当一个校验注解 没有指定 groups 属性时,它属于默认分组 Default。一旦指定了 groups,它就不再属于默认分组。
第三步:在 Controller 中激活指定分组
这是见证奇迹的一步。我们将使用 Spring 提供的 @Validated 注解(注意不是 @Valid)来激活特定分组的校验。
文件路径: 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
| package com.example.springbootdemo.controller;
import com.example.springbootdemo.dto.User.UserEditDTO; import com.example.springbootdemo.validation.ValidationGroups;
import org.springframework.validation.annotation.Validated;
@Tag(name = "用户管理", description = "提供用户相关的 CRUD 接口") @RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController {
private final UserService userService;
@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()); } }
|
当请求进入 saveUser 方法时,@Validated(ValidationGroups.Save.class) 会告诉 Spring Validation 框架:请只检查 UserEditDTO 中那些被标记为 groups = ValidationGroups.Save.class 的校验规则。同理,updateUser 方法也只会触发 Update 分组的规则。
清理与验证
现在,您可以自信地删除 UserSaveDTO.java 和 UserUpdateDTO.java 这两个文件的校验注解(注意不是删除文件),并相应地重构 UserService 层的接口参数。
重启应用并进行测试:
- 调用新增接口 (
POST /users):- 不传
user_name 或 password -> 触发校验,返回 400。 - 不传
id -> 请求成功。
- 调用修改接口 (
PUT /users):- 不传
id -> 触发校验,返回 400。 - 不传
user_name 和 password -> 请求成功,因为它们在 Update 组下没有非空校验。
重构的价值: 通过分组校验,我们成功将两个高度相似的 DTO 合并为一个,用场景标签代替了冗余类,极大地提升了代码的复用性、可读性和长期可维护性。这是处理复杂表单校验场景下的标准最佳实践。