第 52 章:参数校验 JSR-303
学习目标
- 掌握常用校验注解
- 理解 @Valid 与 @Validated 的区别
- 学会分组校验与自定义校验
一、为什么要用注解校验?
java
// ❌ 手写校验:业务代码被淹没
@PostMapping
public Result<Void> create(@RequestBody UserCreateDTO dto) {
if (dto.getUsername() == null || dto.getUsername().isBlank()) {
return Result.fail(10001, "用户名不能为空");
}
if (dto.getUsername().length() < 4 || dto.getUsername().length() > 20) {
return Result.fail(10001, "用户名长度 4-20");
}
if (dto.getPassword() == null || dto.getPassword().length() < 8) {
return Result.fail(10001, "密码至少 8 位");
}
if (dto.getPhone() != null && !dto.getPhone().matches("^1[3-9]\\d{9}$")) {
return Result.fail(10001, "手机号格式错误");
}
// ... 20 行校验后才是 1 行业务
userService.create(dto);
return Result.success();
}
// ✅ 注解校验:规则写在 DTO 上,Controller 干净
@PostMapping
public Result<Void> create(@RequestBody @Valid UserCreateDTO dto) {
userService.create(dto);
return Result.success();
}二、引入依赖
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>⚠️ Spring Boot 2.3 之后校验不再包含在 web starter 里,必须单独引入,否则注解写了也不生效。
三、常用校验注解
通用
| 注解 | 说明 |
|---|---|
@NotNull | 不能为 null(可以是空串) |
@NotEmpty | 不能为 null 且长度/大小 > 0(String、Collection、Map、数组) |
@NotBlank | 不能为 null 且去空格后长度 > 0(仅 String) |
@Null | 必须为 null |
java
String s = " ";
// @NotNull → ✅ 通过(不是 null)
// @NotEmpty → ✅ 通过(长度是 3)
// @NotBlank → ❌ 不通过(trim 后为空)字符串一律用
@NotBlank,@NotNull拦不住" "。
数值
| 注解 | 说明 |
|---|---|
@Min(v) / @Max(v) | 最小/最大值(整数) |
@DecimalMin / @DecimalMax | 最小/最大值(含小数) |
@Positive / @PositiveOrZero | 正数 / 非负数 |
@Negative / @NegativeOrZero | 负数 / 非正数 |
@Digits(integer=8, fraction=2) | 整数位 ≤ 8,小数位 ≤ 2 |
字符串
| 注解 | 说明 |
|---|---|
@Size(min, max) | 长度范围(String、Collection、Map、数组) |
@Length(min, max) | 长度范围(Hibernate 扩展,仅 String) |
@Pattern(regexp) | 正则匹配 |
@Email | 邮箱格式 |
@URL | URL 格式(Hibernate 扩展) |
时间
| 注解 | 说明 |
|---|---|
@Past / @PastOrPresent | 过去的时间 |
@Future / @FutureOrPresent | 未来的时间 |
布尔
| 注解 | 说明 |
|---|---|
@AssertTrue | 必须为 true(如「同意用户协议」) |
@AssertFalse | 必须为 false |
四、完整示例
java
@Data
public class UserCreateDTO {
@NotBlank(message = "用户名不能为空")
@Length(min = 4, max = 20, message = "用户名长度必须在 4-20 之间")
@Pattern(regexp = "^[a-zA-Z][a-zA-Z0-9_]*$",
message = "用户名必须以字母开头,只能包含字母、数字、下划线")
private String username;
@NotBlank(message = "密码不能为空")
@Length(min = 8, max = 32, message = "密码长度必须在 8-32 之间")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
message = "密码必须包含大小写字母和数字")
private String password;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Email(message = "邮箱格式不正确")
private String email; // ① 没有 @NotBlank,说明是选填
@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄必须大于 0")
@Max(value = 150, message = "年龄不能超过 150")
private Integer age;
@NotNull(message = "部门不能为空")
private Long deptId;
@NotEmpty(message = "至少选择一个角色")
@Size(max = 10, message = "角色最多选择 10 个")
private List<Long> roleIds;
@Past(message = "出生日期必须是过去的时间")
private LocalDate birthday;
@AssertTrue(message = "必须同意用户协议")
private Boolean agreeTerms;
@Valid // ② 嵌套对象必须加 @Valid 才会级联校验
private AddressDTO address;
@Valid // ③ 集合内元素校验
private List<ContactDTO> contacts;
}② 嵌套校验必须加
@Valid。这是最容易漏的一点:不加的话,AddressDTO内部的校验注解完全不生效。
五、@Valid vs @Validated
| 维度 | @Valid(JSR-303 标准) | @Validated(Spring 扩展) |
|---|---|---|
| 来源 | jakarta.validation | org.springframework |
| 分组校验 | ❌ 不支持 | ✅ 支持 |
| 用在类上 | ❌ | ✅ 可校验方法参数 |
| 嵌套校验 | ✅ 支持 | ❌ 不支持(要配合 @Valid) |
java
// ① @RequestBody 用 @Valid(支持嵌套)
@PostMapping
public Result<Void> create(@RequestBody @Valid UserCreateDTO dto) { }
// ② 需要分组时用 @Validated
@PostMapping
public Result<Void> create(@RequestBody @Validated(Create.class) UserDTO dto) { }
// ③ 校验单个参数:类上必须加 @Validated
@RestController
@Validated // ← 关键!
public class UserController {
@GetMapping("/{id}")
public Result<UserVO> get(
@PathVariable @Min(value = 1, message = "ID 必须大于 0") Long id) { }
@GetMapping("/search")
public Result<List<UserVO>> search(
@RequestParam @NotBlank(message = "关键词不能为空") String keyword) { }
}记忆口诀:类上
@Validated,参数上@Valid。这样组合能覆盖所有场景。
六、分组校验
场景:新增时 id 不能传,修改时 id 必填。同一个 DTO 想复用。
java
// ① 定义分组接口(空接口,只做标记)
public interface ValidGroup {
interface Create extends Default { } // 继承 Default 才会校验无分组的字段
interface Update extends Default { }
}
@Data
public class UserDTO {
@Null(message = "新增时不能传 ID", groups = ValidGroup.Create.class)
@NotNull(message = "修改时 ID 必填", groups = ValidGroup.Update.class)
private Long id;
@NotBlank(message = "用户名不能为空") // ② 无分组 = Default 组,两种场景都校验
private String username;
@NotBlank(message = "密码不能为空", groups = ValidGroup.Create.class)
private String password; // ③ 只在新增时必填(修改时不改密码)
}java
@PostMapping
public Result<Void> create(@RequestBody @Validated(ValidGroup.Create.class) UserDTO dto) { }
@PutMapping
public Result<Void> update(@RequestBody @Validated(ValidGroup.Update.class) UserDTO dto) { }② 为什么分组接口要
extends Default? 指定了groups后,Spring 只校验该分组的注解,没写groups的注解(属于Default组)会被跳过。继承Default后,Default组的注解也会一起校验。
七、自定义校验注解
需求:校验手机号在系统中未被注册。
第 1 步:定义注解
java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneUniqueValidator.class) // ① 指定校验器
public @interface PhoneUnique {
String message() default "该手机号已被注册"; // ② 这三个方法是必需的
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}第 2 步:实现校验器
java
@RequiredArgsConstructor
public class PhoneUniqueValidator implements ConstraintValidator<PhoneUnique, String> {
private final UserMapper userMapper; // ① 校验器可以注入 Spring Bean
@Override
public boolean isValid(String phone, ConstraintValidatorContext context) {
if (!StringUtils.hasText(phone)) {
return true; // ② 空值交给 @NotBlank 管,职责单一
}
return userMapper.selectCount(
new LambdaQueryWrapper<User>().eq(User::getPhone, phone)) == 0;
}
}第 3 步:使用
java
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@PhoneUnique
private String phone;② 为什么空值直接返回 true? 单一职责。「是否必填」由
@NotBlank负责,「是否唯一」由@PhoneUnique负责。这样选填字段也能复用这个注解。
类级别校验(跨字段)
需求:确认密码必须和密码一致。
java
@Target(ElementType.TYPE) // ① 贴在类上
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "两次输入的密码不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordMatchValidator
implements ConstraintValidator<PasswordMatch, RegisterDTO> {
@Override
public boolean isValid(RegisterDTO dto, ConstraintValidatorContext context) {
if (dto.getPassword() == null) return true;
boolean matched = dto.getPassword().equals(dto.getConfirmPassword());
if (!matched) {
// ② 把错误定位到具体字段,前端才能高亮
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
context.getDefaultConstraintMessageTemplate())
.addPropertyNode("confirmPassword")
.addConstraintViolation();
}
return matched;
}
}
@Data
@PasswordMatch // ③ 贴在类上
public class RegisterDTO {
private String password;
private String confirmPassword;
}八、Service 层方法校验
java
@Service
@Validated // ① 类上必须加
public class UserServiceImpl implements UserService {
public void updateStatus(
@NotNull(message = "ID 不能为空") Long id,
@Min(value = 0, message = "状态值非法")
@Max(value = 1, message = "状态值非法") Integer status) { }
@NotNull // ② 也能校验返回值
public User getById(Long id) { }
}校验失败抛
ConstraintViolationException,记得在全局异常处理器中处理(见第 51 章)。
九、快速失败模式
java
@Configuration
public class ValidationConfig {
@Bean
public Validator validator() {
try (ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
.configure()
.failFast(true) // ① 第一个校验失败就返回,不再校验后续
.buildValidatorFactory()) {
return factory.getValidator();
}
}
}| 模式 | 行为 | 适用 |
|---|---|---|
| 默认(全部校验) | 返回所有错误 | 表单提交(一次性告诉用户所有问题) |
failFast=true | 遇到第一个错误就返回 | 性能敏感、内部调用 |
前端表单推荐默认模式。快速失败会导致用户改一个错误提交一次,体验很差。
十、常见坑
java
// 坑 1:忘记引 validation starter → 注解完全不生效,静默通过
// 坑 2:@RequestBody 上忘记加 @Valid
public Result<Void> create(@RequestBody UserDTO dto) // ❌ 不校验
public Result<Void> create(@RequestBody @Valid UserDTO dto) // ✅
// 坑 3:嵌套对象忘记加 @Valid
private AddressDTO address; // ❌ 内部不校验
@Valid private AddressDTO address; // ✅
// 坑 4:@PathVariable 校验时类上忘加 @Validated
// 坑 5:分组接口没继承 Default,导致无分组的注解被跳过
// 坑 6:自定义校验器里做了慢查询,每次请求都查库 → 接口变慢
// 坑 7:用 @NotNull 校验 String
@NotNull private String name; // ❌ " " 能通过
@NotBlank private String name; // ✅十一、本章小结
| 要点 | 关键 |
|---|---|
| 依赖 | 必须单独引 spring-boot-starter-validation |
| 字符串 | 用 @NotBlank 不是 @NotNull |
@Valid | 支持嵌套,加在参数上 |
@Validated | 支持分组,加在类上 |
| 嵌套校验 | 内部对象字段必须加 @Valid |
| 分组 | 接口要 extends Default |
| 自定义 | 注解 + ConstraintValidator,可注入 Bean |
| 空值处理 | 交给 @NotBlank,自定义校验器返回 true |
| 快速失败 | 表单场景不要开 |
动手练习
练习 1:基础题
为「商品创建」DTO 写完整校验:名称 2-50 字、价格大于 0 且最多 2 位小数、库存非负整数、至少一张图片、上架时间必须是未来。
练习 2:进阶题
自定义 @EnumValue(enumClass = OrderStatus.class) 注解,校验传入的值必须是指定枚举中的合法 code。
下一章:第 53 章:MyBatis-Plus 入门 →