Skip to content
第 52 / 250 章后端⏱ 10 分钟阅读

第 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邮箱格式
@URLURL 格式(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.validationorg.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 入门

本站基于 VitePress 构建 · 由 Codebook 团队维护