Appearance
第52课:Validation 参数校验
🎯 学习目标
- 理解参数校验为什么应该靠框架集中处理,而不是散落在业务代码中。
- 掌握 Jakarta Bean Validation 的常用注解和 Spring Boot 集成方式。
- 能区分
@Valid、@Validated、分组校验、嵌套校验和方法参数校验。 - 能设计统一错误响应,让前端准确知道哪个字段错了。
- 能识别校验位置错误、包装类型误用、分组滥用等常见问题。
📖 一、为什么需要参数校验
没有统一校验时,Service 代码会变成这样:
java
public void createUser(UserCreateRequest request) {
if (request.getUsername() == null || request.getUsername().isBlank()) {
throw new IllegalArgumentException("username is required");
}
if (request.getPassword() == null || request.getPassword().length() < 8) {
throw new IllegalArgumentException("password too short");
}
if (request.getAge() != null && request.getAge() < 0) {
throw new IllegalArgumentException("age invalid");
}
// 真正的业务逻辑被校验代码淹没
}问题:
text
校验逻辑重复
错误格式不统一
Controller 和 Service 分工混乱
字段规则无法从 DTO 上直接看出
接口文档难以自动生成准确约束Validation 的目标是把字段约束声明在 DTO 上,让框架自动校验。
📖 二、依赖配置
Spring Boot 3 使用 Jakarta Validation:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Spring Boot 2 使用的是 javax.validation 包名,Spring Boot 3 使用 jakarta.validation 包名。升级时要注意导包变化。
📖 三、常用校验注解
| 注解 | 作用 | 适用类型 |
|---|---|---|
@NotNull | 不能为 null | 任意对象 |
@NotBlank | 不能为 null,且去空格后不能为空 | String |
@NotEmpty | 不能为 null,且长度/集合大小不能为 0 | String、Collection、Map、数组 |
@Size | 长度或集合大小范围 | String、Collection、数组 |
@Min / @Max | 数值最小/最大 | 数字 |
@Positive | 必须为正数 | 数字 |
@Email | 邮箱格式 | String |
@Pattern | 正则匹配 | String |
@Past / @Future | 过去/未来时间 | 日期时间 |
示例:
java
import jakarta.validation.constraints.*;
public class UserCreateRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度必须在 3 到 20 之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 64, message = "密码长度必须在 8 到 64 之间")
private String password;
@Email(message = "邮箱格式不正确")
private String email;
@Min(value = 0, message = "年龄不能小于 0")
@Max(value = 150, message = "年龄不能大于 150")
private Integer age;
}📖 四、Controller 中启用校验
1. 请求体校验
java
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public UserDTO create(@Valid @RequestBody UserCreateRequest request) {
return userService.create(request);
}
}如果请求体不满足约束,Spring 会抛出 MethodArgumentNotValidException。
2. 查询参数校验
类上需要 @Validated:
java
@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public List<UserDTO> list(
@Min(value = 1, message = "页码从 1 开始") @RequestParam Integer page,
@Max(value = 100, message = "每页最多 100 条") @RequestParam Integer size) {
return userService.list(page, size);
}
}方法参数校验失败通常抛出 ConstraintViolationException。
📖 五、统一错误响应
只加注解还不够。企业项目必须把错误响应格式统一。
java
public class ApiError {
private String code;
private String message;
private List<FieldErrorItem> fields;
}
public class FieldErrorItem {
private String field;
private String message;
}全局异常处理:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleBodyValidation(MethodArgumentNotValidException e) {
List<FieldErrorItem> fields = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldErrorItem(error.getField(), error.getDefaultMessage()))
.toList();
ApiError body = new ApiError("VALIDATION_ERROR", "请求参数不合法", fields);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleParamValidation(ConstraintViolationException e) {
List<FieldErrorItem> fields = e.getConstraintViolations()
.stream()
.map(v -> new FieldErrorItem(v.getPropertyPath().toString(), v.getMessage()))
.toList();
ApiError body = new ApiError("VALIDATION_ERROR", "请求参数不合法", fields);
return ResponseEntity.badRequest().body(body);
}
}这样前端可以明确展示:
json
{
"code": "VALIDATION_ERROR",
"message": "请求参数不合法",
"fields": [
{"field": "username", "message": "用户名不能为空"},
{"field": "password", "message": "密码长度必须在 8 到 64 之间"}
]
}📖 六、嵌套校验
如果 DTO 里包含子对象,需要在字段上继续加 @Valid。
java
public class OrderCreateRequest {
@NotNull(message = "用户 ID 不能为空")
private Long userId;
@Valid
@NotEmpty(message = "订单明细不能为空")
private List<OrderItemRequest> items;
}
public class OrderItemRequest {
@NotNull(message = "商品 ID 不能为空")
private Long productId;
@Positive(message = "数量必须为正数")
private Integer quantity;
}如果忘记 @Valid,外层对象会校验,但 items 里的字段不会继续校验。
📖 七、分组校验
同一个 DTO 在创建和更新时规则可能不同:
java
public interface CreateGroup {}
public interface UpdateGroup {}java
public class UserRequest {
@NotNull(message = "更新时 ID 不能为空", groups = UpdateGroup.class)
private Long id;
@NotBlank(message = "用户名不能为空", groups = CreateGroup.class)
private String username;
}Controller:
java
@PostMapping
public UserDTO create(@Validated(CreateGroup.class) @RequestBody UserRequest request) {
return userService.create(request);
}
@PutMapping("/{id}")
public UserDTO update(@Validated(UpdateGroup.class) @RequestBody UserRequest request) {
return userService.update(request);
}分组校验有用,但不要滥用。规则差异很大时,创建和更新分别定义 DTO 通常更清晰。
⚠️ 八、常见陷阱
1. 基本类型无法表达缺失
错误示例:
java
@Min(1)
private int page;int 默认值是 0,无法区分“用户没传”和“用户传了 0”。请求 DTO 建议使用包装类型:
java
@NotNull
@Min(1)
private Integer page;2. @NotNull 不等于非空字符串
java
@NotNull
private String username;这允许 "" 和 " "。字符串必填通常用 @NotBlank。
3. 忘记在 Controller 参数上加 @Valid
DTO 上写了注解,但 Controller 没有:
java
public UserDTO create(@RequestBody UserCreateRequest request)校验不会执行。应改为:
java
public UserDTO create(@Valid @RequestBody UserCreateRequest request)4. 把业务规则都塞进注解
字段格式校验适合 Validation;复杂业务规则仍应放在 Service。
例如“用户名是否已存在”“库存是否足够”“优惠券是否可用”不是简单字段校验。
🆚 九、Java vs C 对比
| 维度 | C | Java + Validation |
|---|---|---|
| 参数校验 | 手写 if 判断 | 注解声明 + 框架自动执行 |
| 错误聚合 | 手动收集 | BindingResult/异常统一处理 |
| 嵌套对象 | 手动遍历结构体 | @Valid 递归校验 |
| Web 集成 | 取决于框架 | Spring MVC 自动集成 |
Java 的优势是把规则贴近 DTO 声明,接口约束更可读,也更容易生成 OpenAPI 文档。
💡 十、最佳实践
- 请求 DTO 使用包装类型,避免基本类型默认值掩盖“未传参”。
- 字符串必填用
@NotBlank,集合必填用@NotEmpty。 - 嵌套对象和集合元素校验必须加
@Valid。 - Controller 入参用 Validation 做格式和边界校验,Service 负责业务规则。
- 全局异常处理必须统一错误响应格式。
- 创建和更新规则差异很大时,优先拆 DTO,而不是过度依赖分组。
- 错误消息应面向调用方清晰表达,不要暴露内部类名或堆栈。
- Validation 规则应与 Swagger/OpenAPI 文档保持一致。
🎓 小结
Validation 让参数约束从分散的 if 判断变成集中声明。它的关键不是少写几行代码,而是让接口边界清晰、错误响应统一、业务代码更专注。
掌握 Validation 后,再结合 JSON DTO 和 Swagger/OpenAPI,就能形成完整的“请求结构、字段约束、接口文档、错误返回”闭环。
🧭 十一、项目落地清单
一个接口上线前,Validation 至少要检查:
text
1. 请求体参数是否加了 @Valid 或 @Validated。
2. Controller 类上是否为查询参数校验加了 @Validated。
3. 字符串必填是否使用 @NotBlank,而不是只用 @NotNull。
4. 数字范围是否使用包装类型,避免默认值掩盖缺失。
5. 集合是否校验非空和元素内容。
6. 嵌套对象是否加了 @Valid。
7. 错误响应是否能返回字段名和错误原因。
8. Validation 错误是否不会打印完整堆栈给前端。
9. 复杂业务规则是否放在 Service,而不是塞进注解。
10. Swagger/OpenAPI 文档是否能反映字段必填和格式。推荐错误响应格式:
json
{
"code": "VALIDATION_ERROR",
"message": "请求参数不合法",
"fields": [
{
"field": "email",
"message": "邮箱格式不正确"
}
]
}这个格式比单纯返回 "参数错误" 更适合前端表单回显。
🔍 十二、自测问题
学习完本节后,应该能回答:
text
@Valid 和 @Validated 有什么区别?
@NotNull、@NotEmpty、@NotBlank 分别适合什么场景?
为什么请求 DTO 中建议使用 Integer 而不是 int?
嵌套对象为什么需要额外加 @Valid?
MethodArgumentNotValidException 和 ConstraintViolationException 分别何时出现?
分组校验什么时候适合,什么时候不如拆 DTO?
哪些规则属于字段校验,哪些规则应该留在 Service?参数校验是接口的第一道防线。它越清晰,业务代码越干净,接口文档也越可信。
📌 十三、学习建议
建议写一个 UserCreateRequest,分别测试:
text
字段缺失
字段为空字符串
字段只有空格
邮箱格式错误
嵌套对象字段错误然后观察全局异常处理返回的字段路径和错误消息,确认前端能直接使用这些信息做表单提示。