@Valid 和 @Validated 不是一回事
@Valid 来自 Jakarta Bean Validation(jakarta.validation.Valid),@Validated 是 Spring 自己的注解。前者只能标在参数、字段、方法上,触发标准校验;后者多了 value 属性,可以指定校验分组,还能标在类上开启方法级校验。
Spring Boot 3.x 使用 Hibernate Validator 8,包名全部是 jakarta.*。从 Boot 2.3 开始,spring-boot-starter-web 不再传递引入 validation,必须显式加 spring-boot-starter-validation,否则注解写了也不会生效,这是升级到 3.x 后最常见的问题之一。
分组解决“新增和修改字段要求不同”
同一个 DTO,新增时 id 必须为空,修改时 id 必须非空;新增时某些字段必填,修改时可以不传。用分组接口把这些规则分开:
public interface Create {
}
public interface Update {
}
public class UserRequest {
@Null(groups = Create.class, message = "新增时不能指定 id")
@NotNull(groups = Update.class, message = "修改时 id 不能为空")
private Long id;
@NotBlank(groups = {Create.class, Update.class}, message = "名称不能为空")
private String name;
@Email(groups = {Create.class, Update.class}, message = "邮箱格式不正确")
private String email;
}
控制器上按场景指定分组:
@PostMapping("/users")
public Result<Long> create(@RequestBody @Validated(Create.class) UserRequest request) {
return Result.ok(userService.create(request));
}
@PutMapping("/users/{id}")
public Result<Void> update(@PathVariable Long id,
@RequestBody @Validated(Update.class) UserRequest request) {
userService.update(id, request);
return Result.ok();
}
这里有一个容易忽略的规则:一旦指定了分组,没有写 groups 的约束属于 Default 分组,默认不会被校验。如果希望 Create 分组同时包含默认约束,可以让 Create extends Default,或者在注解上显式写出分组。很多人发现“加了分组后其他校验全失效了”,原因就在这里。
多个分组需要按顺序校验时,用 @GroupSequence 定义序列,前一组失败就不再执行后一组,避免一次性返回大量无关错误:
@GroupSequence({Create.class, Update.class})
public interface OrderedValidation {
}
嵌套对象与集合
嵌套对象默认不会级联校验,必须在字段上再加 @Valid:
public class OrderRequest {
@NotNull(groups = Create.class)
private Long userId;
@Valid
@NotNull(groups = Create.class)
private AddressRequest address;
@Valid
private List<OrderItemRequest> items;
}
List<OrderItemRequest> 上的 @Valid 会逐个校验元素;如果只想校验元素里的某个字段,可以用容器元素约束,比如 List<@NotBlank String> tags。嵌套对象在不同分组下规则不同,可以用 @ConvertGroup 转换:
@Valid
@ConvertGroup(from = Create.class, to = Update.class)
private AddressRequest address;
分组不是越多越好。两三个场景用分组能减少 DTO 数量,场景再多就应该拆成独立的请求对象,否则一个类上挂十几个分组注解,维护成本比复制一个 DTO 还高。
统一处理校验异常
请求体校验失败抛 MethodArgumentNotValidException,方法参数(@RequestParam、@PathVariable)校验失败抛 ConstraintViolationException,表单绑定失败抛 BindException。三种异常要分别处理,否则前端拿到的错误格式不一致:
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBody(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return Result.fail("PARAM_INVALID", message);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleParam(ConstraintViolationException e) {
String message = e.getConstraintViolations().stream()
.map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
.collect(Collectors.joining("; "));
return Result.fail("PARAM_INVALID", message);
}
}
方法参数校验需要在控制器类上标 @Validated,只在参数上写 @NotBlank 是不够的。这个注解加在类上,Spring 才会生成方法校验的代理。
常见坑
1. 没引 validation starter
@Valid 不报错,也不生效,请求照样进业务。先看依赖树里有没有 hibernate-validator,没有就加 spring-boot-starter-validation。
2. 分组吞掉了默认约束
指定 @Validated(Create.class) 后,所有没写 groups 的注解都归 Default,不会执行。要么让分组接口继承 Default,要么给每个约束补上分组。
3. 嵌套对象没加 @Valid
外层字段校验通过,内层对象的字段完全没校验。级联校验必须层层标注 @Valid,集合元素也一样。
4. 自定义校验器把 null 当成合法
ConstraintValidator#isValid 返回 value == null || ...,配合 @NotNull 才能覆盖必填场景。如果只写自定义注解、不写 @NotNull,null 会直接通过。
5. 分组顺序不符合预期
没有 @GroupSequence 时,分组的校验顺序是不确定的,错误信息可能一次全返回,也可能顺序变化。需要“先校验基础字段,再校验业务字段”时,显式定义序列。
6. 异常被全局处理器覆盖
项目里已有 @ExceptionHandler(Exception.class),把校验异常也吞成了 500 或统一错误码。校验异常要放在更具体的处理器里,利用异常匹配的优先级,别让兜底处理器抢先。
排查步骤
先确认异常到底有没有抛出来。可以在全局处理器里临时打印异常类型,或者打开校验日志:
logging:
level:
org.springframework.validation: DEBUG
org.hibernate.validator: DEBUG
再确认分组是否被正确指定:
Set<ConstraintViolation<UserRequest>> violations =
validator.validate(request, Create.class);
log.info("violations={}", violations.size());
如果手工调用有结果、接口却没有,问题多半在参数注解或依赖上。最后检查依赖树:
mvn dependency:tree | grep -i validator
小结
参数校验的关键是“规则归属”:哪些约束属于默认分组,哪些属于新增、修改,嵌套对象要不要级联,异常由谁处理。把这些边界定清楚,分组是减少重复代码的工具;定不清楚,分组只会让失效变得更隐蔽。
评论