Featured image of post Spring 参数校验完整指南:JSR-380 与 Hibernate Validator

Spring 参数校验完整指南:JSR-380 与 Hibernate Validator

从基础注解到分组校验、嵌套校验、自定义约束、国际化错误消息——把 Hibernate Validator 的常见骚操作一次讲透

校验该写在哪一层?

很多项目里随处可见这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public Result createUser(UserDTO dto) {
    if (dto.getName() == null || dto.getName().isEmpty()) {
        return Result.fail("姓名不能为空");
    }
    if (dto.getAge() == null || dto.getAge() < 0 || dto.getAge() > 200) {
        return Result.fail("年龄不合法");
    }
    if (!Pattern.matches("^1[3-9]\\d{9}$", dto.getPhone())) {
        return Result.fail("手机号格式错误");
    }
    // ... 真正的业务逻辑
}

每个 Controller 都重复一遍,手工拼错误消息,堆得密不透风。这种代码不该出现在业务方法里——校验是输入边界的责任,应该在数据进入业务方法前就被自动拦截

Bean Validation(JSR-380)和它的事实标准实现 Hibernate Validator 就是为这件事而生。今天我们把它的常见用法和"骚姿势"过一遍。


基础:常用注解一览

注解作用适用类型
@NotNull不能为 null任意
@NotEmpty不能 null 且不能为空String / Collection / Array
@NotBlank不能 null 且 trim 后非空String
@Size(min,max)长度/大小范围String / Collection
@Min / @Max数值最小/最大数字
@DecimalMin/Max支持 BigDecimal 的最小最大数字
@Positive / @Negative正数 / 负数数字
@Pattern(regexp)正则匹配String
@Email邮箱格式String
@Past / @Future过去 / 未来时间时间类型
@AssertTrue必须为 trueboolean
@Valid触发嵌套校验对象 / 集合

一份典型的 DTO:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class UserCreateDTO {

    @NotBlank(message = "姓名不能为空")
    @Size(max = 20, message = "姓名最长 20 个字")
    private String name;

    @NotNull(message = "年龄必填")
    @Min(value = 0, message = "年龄不能为负")
    @Max(value = 200, message = "年龄太离谱了")
    private Integer age;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不对")
    private String phone;

    @Email(message = "邮箱格式不对")
    private String email;

    @Valid                 // 嵌套校验
    private AddressDTO address;
}

接入 Spring:一个 @Valid 全自动

Spring MVC 集成是开箱即用的——参数前加 @Valid,校验失败会抛 MethodArgumentNotValidException

1
2
3
4
5
@PostMapping("/users")
public Result createUser(@RequestBody @Valid UserCreateDTO dto) {
    // 进到这里,所有约束都已经通过
    return userService.create(dto);
}

Path Variable / Request Param 上需要 @Validated 在 Controller 类上配合:

1
2
3
4
5
6
7
8
9
@RestController
@Validated
public class UserController {

    @GetMapping("/users/{id}")
    public Result get(@PathVariable @Min(1) Long id) {
        return userService.get(id);
    }
}

Path/Param 校验失败会抛 ConstraintViolationException异常类型和 @RequestBody 不一样,需要分别处理。


全局异常处理:让校验报错优雅返回

Controller 内部不该处理校验异常,应该用全局异常处理器统一处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@RestControllerAdvice
public class GlobalExceptionHandler {

    // @RequestBody 校验失败
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleBody(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + ": " + f.getDefaultMessage())
                .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }

    // @RequestParam / @PathVariable 校验失败
    @ExceptionHandler(ConstraintViolationException.class)
    public Result handleParam(ConstraintViolationException e) {
        String msg = e.getConstraintViolations().stream()
                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                .collect(Collectors.joining("; "));
        return Result.fail(400, msg);
    }
}

至此基础就齐了。下面才是真正出彩的"骚姿势"。


骚姿势一:分组校验(Validation Groups)

最常见的场景:新增和修改用的是同一个 DTO,但字段约束不同。新增时 id 不能传,修改时 id 必填——硬拆两个 DTO 又冗余。

分组校验解决这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public interface CreateGroup {}
public interface UpdateGroup {}

public class UserDTO {

    @Null(message = "新增时不能传 ID", groups = CreateGroup.class)
    @NotNull(message = "修改时必须传 ID", groups = UpdateGroup.class)
    private Long id;

    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
    private String name;
}

@PostMapping("/users")
public Result create(@RequestBody @Validated(CreateGroup.class) UserDTO dto) { ... }

@PutMapping("/users")
public Result update(@RequestBody @Validated(UpdateGroup.class) UserDTO dto) { ... }

注意——分组要用 @Validated 不能用 @Valid,因为 @Valid 不支持分组参数。


骚姿势二:嵌套校验

DTO 嵌 DTO 时,光在外层加 @Valid 是不够的,必须在内层字段上也加 @Valid,校验才会递归下去:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderDTO {

    @NotEmpty
    @Valid                          // ← 关键
    private List<OrderItemDTO> items;
}

public class OrderItemDTO {
    @NotNull private Long skuId;
    @Min(1) private Integer count;
}

如果集合里有元素违规,错误路径会是 items[2].count,前端能精确定位。


骚姿势三:跨字段校验(自定义类级注解)

校验"密码和确认密码必须一致"这种字段间约束,单字段注解搞不定,需要写一个类级注解

 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
@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;   // 让 @NotBlank 去管 null
        boolean match = dto.getPassword().equals(dto.getConfirmPassword());
        if (!match) {
            // 让错误"挂"在 confirmPassword 字段上而不是整个对象上
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("两次密码不一致")
                    .addPropertyNode("confirmPassword")
                    .addConstraintViolation();
        }
        return match;
    }
}

@PasswordMatch
public class RegisterDTO {
    @NotBlank private String password;
    @NotBlank private String confirmPassword;
}

骚姿势四:自定义字段约束

校验"枚举值必须在指定集合内"是另一个常见需求。写一个 @Enum 自定义约束:

 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
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValidator.class)
public @interface EnumValue {
    String message() default "值不在允许范围内";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    Class<? extends Enum<?>> enumClass();
    String method() default "name";   // 用 enum 的哪个方法取值
}

public class EnumValidator implements ConstraintValidator<EnumValue, Object> {
    private Set<Object> values;

    @Override
    public void initialize(EnumValue ann) {
        values = Arrays.stream(ann.enumClass().getEnumConstants())
                .map(e -> ReflectUtil.invoke(e, ann.method()))
                .collect(Collectors.toSet());
    }
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext ctx) {
        return value == null || values.contains(value);
    }
}

用法:

1
2
@EnumValue(enumClass = OrderStatus.class, method = "getCode")
private String status;

写一次,全项目复用。


骚姿势五:Service 层方法参数校验

@Validated 不只能用在 Controller,也能用在 Service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Service
@Validated
public class UserService {

    public User getById(@Min(1) Long id) {
        // ...
    }

    public void batchCreate(@NotEmpty @Valid List<UserDTO> users) {
        // ...
    }
}

@Validated 标注的类,方法参数上的约束会通过 AOP 自动校验,失败抛 ConstraintViolationException。这意味着——业务方法可以放心地把"非法输入"假设成不可能发生


骚姿势六:错误消息国际化

message 写成 {key} 形式,Hibernate Validator 会自动从 messages.properties 读取:

1
2
@NotBlank(message = "{user.name.notBlank}")
private String name;

src/main/resources/messages.properties

1
user.name.notBlank=姓名不能为空

src/main/resources/messages_en.properties

1
user.name.notBlank=Name is required

Spring Boot 默认就开启了国际化支持,根据请求 Accept-Language 自动切换。


骚姿势七:编程式 API 与 failFast

少数场景注解搞不定——例如根据运行时参数切换分组、或希望"遇到第一个错误就停"。可以用编程式 Validator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 1. 自定义 ValidatorFactory,开启 failFast
@Bean
public Validator validator() {
    return Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(true)        // 任一字段失败立即返回,不再校验后续
            .buildValidatorFactory()
            .getValidator();
}

// 2. 编程式调用,按运行时条件选 group
@Autowired private Validator validator;

public void create(UserDTO dto, boolean strict) {
    Set<ConstraintViolation<UserDTO>> errors = strict
            ? validator.validate(dto, StrictGroup.class)
            : validator.validate(dto);
    if (!errors.isEmpty()) throw new IllegalArgumentException(...);
}

failFast 适合批量导入这种"快报快停"的场景;编程式 API 适合规则需要根据上下文动态切换。注解和编程式 API 完全可以混用——80% 用注解、20% 用 API 兜底。


一些工程上的踩坑提醒

  1. @NotEmpty@NotBlank——前者认空白字符串为有效,后者会 trim
  2. @Valid@Validated 不要混用——分组场景必须 @Validated,嵌套校验只能 @Valid
  3. 基本类型的 @NotNull 是没用的——intlong 这些永远有默认值;要校验"必填"就得用包装类型
  4. groups 默认值是 Default.class——不指定 groups 的注解只在不传分组时生效;你给某个注解加了 groups = Update.class,新增校验时它就不参与
  5. 错误消息别堆业务逻辑——消息只描述"为什么不合法",不要写"请联系管理员"这种和校验无关的话
  6. DTO 复用别贪心——三种以上场景共用一个 DTO 时,强烈建议拆开,分组校验维护成本会爆炸

小结

Hibernate Validator 的价值不只是"少写 if",它把输入校验从业务逻辑中剥离,让业务方法可以放心地假设"参数都是合法的"。

把今天的内容压一句:

校验是边界的责任,不是业务方法的责任。让框架帮你拦在门口,业务里只关心业务。

至于使用层级:

  • 能用注解就用注解——分组、嵌套、自定义约束都属于注解能力
  • 跨字段或动态规则——写自定义类级注解或编程式 API
  • Service 层也别放过——加 @Validated 让内部接口也享受框架保护

把这些招数都用上,Controller 里再也不会有那一行行傻乎乎的 if (xxx == null)

使用 Hugo 构建
主题 StackJimmy 设计