欢迎你来读这篇博客,这篇博客主要是关于 Validation。
参数校验这东西,看起来只是几个注解,实际上是后端接口质量的第一道门。
写得好,Controller 很清爽;写得烂,到处都是 if xxx == null,代码像长满了野草。
这篇文章主要整理 Spring Boot 中 Validation 的常见用法,包括:
@Valid 和 @Validated 的区别;
- 常见校验注解;
- Controller 入参校验;
- Service 方法参数校验;
- 分组校验;
- 嵌套对象、集合对象校验;
- 自定义校验注解;
- 自定义
ValidatorUtils 工具类;
- 全局异常统一处理;
- 常见坑和工程建议。
序言
在项目开发中,接口入参校验几乎是绕不开的。
如果没有统一校验机制,代码很容易变成这样:
1 2 3 4 5 6 7 8 9 10 11
| if (userDTO == null) { throw new BizException("参数不能为空"); }
if (userDTO.getUsername() == null || userDTO.getUsername().trim().isEmpty()) { throw new BizException("用户名不能为空"); }
if (userDTO.getEmail() == null || !userDTO.getEmail().matches("xxx")) { throw new BizException("邮箱格式不正确"); }
|
这类代码不是不能写,而是写多了以后会出现几个问题:
- 业务代码被大量参数判断污染;
- 校验规则分散,不方便维护;
- 相同字段规则重复写;
- Controller、Service、工具类之间的校验风格不统一;
- 错误返回格式不好统一。
于是就有了 Bean Validation。
在 Spring Boot 中,我们通常使用:
- Bean Validation 规范;
- Hibernate Validator 实现;
- Spring Validation 集成能力;
@Valid / @Validated 触发校验;
@RestControllerAdvice 做统一异常处理。
简单理解:
Bean Validation 定规则,Hibernate Validator 负责执行,Spring Boot 负责集成和触发。
正文
一、依赖引入
如果是 Spring Boot 项目,建议直接引入:
1 2 3 4 5
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
|
需要注意版本差异。
Spring Boot 2.x 常见包名是:
1 2
| javax.validation.Valid; javax.validation.constraints.NotBlank;
|
Spring Boot 3.x 之后迁移到了 Jakarta EE,对应包名变成:
1 2
| jakarta.validation.Valid; jakarta.validation.constraints.NotBlank;
|
所以如果你升级到 Spring Boot 3.x,发现:
1
| package javax.validation does not exist
|
大概率不是代码逻辑错了,而是包名该换成 jakarta.validation 了。
别慌,Java 没跑路,只是搬家了。
二、Validation 的核心概念
Validation 主要解决的是:
如何用声明式方式描述一个对象应该满足什么规则。
比如一个用户注册 DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Data;
@Data public class UserRegisterDTO {
@NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在 3 到 20 之间") private String username;
@NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email;
@NotBlank(message = "密码不能为空") @Size(min = 6, max = 32, message = "密码长度必须在 6 到 32 之间") private String password; }
|
这段代码的核心意思是:
username 不能为空,长度必须在 3 到 20;
email 不能为空,并且必须是邮箱格式;
password 不能为空,长度必须在 6 到 32。
校验规则不再写在 Controller 的 if 里,而是直接写在 DTO 字段上。
三、常见校验注解
| 注解 |
作用 |
适用类型 |
@NotNull |
不能为 null |
任意类型 |
@NotEmpty |
不能为 null,且不能为空 |
字符串、集合、数组、Map |
@NotBlank |
不能为 null,且去除空格后不能为空 |
字符串 |
@Size |
限制长度或集合大小 |
字符串、集合、数组、Map |
@Min |
最小值 |
数字 |
@Max |
最大值 |
数字 |
@DecimalMin |
最小小数值 |
数字、字符串数字 |
@DecimalMax |
最大小数值 |
数字、字符串数字 |
@Digits |
限制整数位和小数位 |
数字 |
@Email |
邮箱格式 |
字符串 |
@Pattern |
正则校验 |
字符串 |
@Past |
必须是过去时间 |
日期 |
@Future |
必须是未来时间 |
日期 |
@AssertTrue |
必须为 true |
boolean / Boolean |
@AssertFalse |
必须为 false |
boolean / Boolean |
@NotNull、@NotEmpty、@NotBlank 的区别
这三个最容易混。
1 2
| @NotNull private String name;
|
只要求不能是 null,但是 "" 和 " " 可以通过。
1 2
| @NotEmpty private String name;
|
不能是 null,也不能是 "",但是 " " 可以通过。
1 2
| @NotBlank private String name;
|
不能是 null,不能是 "",也不能是 " "。
所以字符串必填字段,优先使用:
1 2
| @NotBlank(message = "名称不能为空") private String name;
|
集合必填字段,优先使用:
1 2
| @NotEmpty(message = "明细不能为空") private List<ItemDTO> items;
|
对象必填字段,使用:
1 2
| @NotNull(message = "用户ID不能为空") private Long userId;
|
四、@Valid 和 @Validated 的区别
@Valid 和 @Validated 都可以触发校验,但定位不同。
| 对比项 |
@Valid |
@Validated |
| 来源 |
Bean Validation 标准 |
Spring 提供 |
| 是否支持分组 |
不直接指定分组 |
支持分组 |
| 是否支持嵌套校验 |
支持,常用于嵌套对象 |
需要配合 @Valid |
| 常用位置 |
字段、方法参数、返回值 |
类、方法、方法参数 |
| 常见场景 |
Controller 请求体校验、嵌套对象校验 |
分组校验、Service 方法校验、简单参数校验 |
一句话总结:
@Valid 偏标准校验,@Validated 偏 Spring 增强,尤其适合分组校验和方法级校验。
实际项目里经常组合使用:
1 2 3 4
| @PostMapping("/users") public Result<Void> createUser(@Validated(CreateGroup.class) @RequestBody UserDTO userDTO) { return Result.success(); }
|
嵌套对象时:
1 2 3 4 5 6 7 8 9 10
| @Data public class OrderCreateDTO {
@NotNull(message = "用户ID不能为空") private Long userId;
@Valid @NotEmpty(message = "订单明细不能为空") private List<OrderItemDTO> items; }
|
这里外层可以用 @Validated 指定分组,内层用 @Valid 触发级联校验。
五、Controller 中的基本用法
1. @RequestBody 对象校验
1 2 3 4 5 6 7 8 9 10 11 12
| import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/users") public class UserController {
@PostMapping("/register") public Result<Void> register(@Valid @RequestBody UserRegisterDTO request) { return Result.success(); } }
|
只要 UserRegisterDTO 中任意字段不满足注解规则,就不会进入方法体,而是直接抛出校验异常。
常见异常是:
1
| MethodArgumentNotValidException
|
这个异常一般交给全局异常处理器统一处理。
2. @PathVariable 和 @RequestParam 简单参数校验
如果是简单参数,比如路径参数、查询参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/users") @Validated public class UserQueryController {
@GetMapping("/{userId}") public Result<Void> detail(@PathVariable @Min(value = 1, message = "用户ID必须大于0") Long userId) { return Result.success(); }
@GetMapping("/search") public Result<Void> search(@RequestParam @NotBlank(message = "关键字不能为空") String keyword) { return Result.success(); } }
|
注意:
建议加在 Controller 类上,用于启用方法参数级校验。
这种场景校验失败常见异常是:
1
| ConstraintViolationException
|
在 Spring Boot 3.2 / Spring Framework 6.1 之后,也可能遇到:
1
| HandlerMethodValidationException
|
所以全局异常处理器里最好把这几类都处理掉,不然升级后一脸懵逼。
3. 表单对象校验
如果不是 JSON 请求体,而是表单提交或 query 参数绑定对象:
1 2 3 4
| @GetMapping("/page") public Result<Void> page(@Valid UserPageQuery query) { return Result.success(); }
|
这类场景可能抛出:
六、集合参数校验
集合校验是比较容易翻车的地方。
比如前端传:
1 2 3 4 5 6 7 8 9 10
| [ { "skuId": 1001, "quantity": 2 }, { "skuId": null, "quantity": 0 } ]
|
DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data;
@Data public class OrderItemDTO {
@NotNull(message = "商品ID不能为空") private Long skuId;
@NotNull(message = "数量不能为空") @Min(value = 1, message = "数量必须大于0") private Integer quantity; }
|
Controller 可以这样写:
1 2 3 4
| @PostMapping("/batch") public Result<Void> batchCreate(@RequestBody @Valid List<OrderItemDTO> items) { return Result.success(); }
|
或者使用类型参数上的 @Valid:
1 2 3 4
| @PostMapping("/batch") public Result<Void> batchCreate(@RequestBody List<@Valid OrderItemDTO> items) { return Result.success(); }
|
更推荐工程上封装一层请求对象:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.Data;
import java.util.List;
@Data public class OrderBatchCreateDTO {
@Valid @NotEmpty(message = "订单明细不能为空") private List<OrderItemDTO> items; }
|
Controller:
1 2 3 4
| @PostMapping("/batch") public Result<Void> batchCreate(@Valid @RequestBody OrderBatchCreateDTO request) { return Result.success(); }
|
这样有几个好处:
- 可以校验集合本身不能为空;
- 可以校验集合内部元素;
- 后续加字段更方便,比如
operatorId、source、remark;
- 异常路径更清楚;
- 接口扩展性更好。
七、嵌套对象校验
嵌套对象一定要加 @Valid。
错误示例:
1 2 3 4 5 6 7 8
| @Data public class UserCreateDTO {
@NotBlank(message = "用户名不能为空") private String username;
private AddressDTO address; }
|
即使 AddressDTO 里面写了校验注解,也不会自动校验。
正确示例:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import lombok.Data;
@Data public class UserCreateDTO {
@NotBlank(message = "用户名不能为空") private String username;
@Valid private AddressDTO address; }
|
1 2 3 4 5 6 7 8 9 10 11 12
| import jakarta.validation.constraints.NotBlank; import lombok.Data;
@Data public class AddressDTO {
@NotBlank(message = "城市不能为空") private String city;
@NotBlank(message = "详细地址不能为空") private String detail; }
|
记住:
外层对象触发校验,不代表内层对象会自动递归校验。嵌套对象字段上要显式加 @Valid。
八、分组校验
实际业务中,同一个 DTO 在新增和修改时规则可能不同。
比如:
- 新增时
id 必须为空;
- 修改时
id 必须不为空;
- 用户名新增和修改都不能为空。
这时候就可以用分组校验。
1. 定义分组接口
1 2 3 4
| import jakarta.validation.groups.Default;
public interface CreateGroup extends Default { }
|
1 2 3 4
| import jakarta.validation.groups.Default;
public interface UpdateGroup extends Default { }
|
这里让 CreateGroup、UpdateGroup 继承 Default,是为了让默认组的校验规则也能一起生效。
2. DTO 中指定分组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Null; import lombok.Data;
@Data public class UserDTO {
@Null(groups = CreateGroup.class, message = "新增时ID必须为空") @NotNull(groups = UpdateGroup.class, message = "修改时ID不能为空") private Long id;
@NotBlank(message = "用户名不能为空") private String username;
@NotBlank(groups = {CreateGroup.class, UpdateGroup.class}, message = "邮箱不能为空") private String email; }
|
3. Controller 中指定分组
1 2 3 4
| @PostMapping("/create") public Result<Void> create(@Validated(CreateGroup.class) @RequestBody UserDTO request) { return Result.success(); }
|
1 2 3 4
| @PutMapping("/update") public Result<Void> update(@Validated(UpdateGroup.class) @RequestBody UserDTO request) { return Result.success(); }
|
4. 分组校验的注意点
如果一个字段这样写:
1 2
| @NotBlank(message = "用户名不能为空") private String username;
|
没有指定 groups,它属于默认组 Default。
如果你执行:
1
| @Validated(CreateGroup.class)
|
但是 CreateGroup 没有继承 Default,那么默认组规则可能不会执行。
所以工程里可以统一约定:
1 2 3 4
| public interface Add extends Default {} public interface Edit extends Default {} public interface Delete extends Default {} public interface Query extends Default {}
|
这样更稳。
九、Service 方法参数校验
Validation 不只能用在 Controller,也可以用在 Service。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated;
@Service @Validated public class UserService {
public void create(@Valid UserCreateDTO request) { }
public void delete(@NotNull(message = "用户ID不能为空") Long userId) { } }
|
重点是:
要加在类上。
否则方法参数上的 @NotNull、@Min 这类注解可能不会生效。
Service 方法级校验失败时,常见异常是:
1
| ConstraintViolationException
|
十、自定义校验注解
内置注解解决的是通用规则。
但是业务里经常会有一些定制规则,比如:
- 性别只能是指定枚举;
- 单据状态必须合法;
- 开始时间必须小于结束时间;
- 用户名不能重复;
- 某字段必须符合公司内部编码规则;
- 某个 ID 必须存在于数据库。
这时候就需要自定义校验注解。
1. 示例:@EnumValue
比如我们希望某个字段只能是指定枚举值。
定义枚举:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public enum GenderEnum {
MALE(1, "男"), FEMALE(2, "女"), UNKNOWN(3, "未知");
private final Integer code; private final String desc;
GenderEnum(Integer code, String desc) { this.code = code; this.desc = desc; }
public Integer getCode() { return code; }
public String getDesc() { return desc; } }
|
定义注解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import jakarta.validation.Constraint; import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = EnumValueValidator.class) public @interface EnumValue {
String message() default "枚举值不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends Enum<?>> enumClass();
String method() default "getCode"; }
|
实现校验器:
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 31 32 33 34 35 36 37
| import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext;
import java.lang.reflect.Method; import java.util.HashSet; import java.util.Set;
public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {
private final Set<Object> allowedValues = new HashSet<>();
@Override public void initialize(EnumValue annotation) { Class<? extends Enum<?>> enumClass = annotation.enumClass(); String methodName = annotation.method();
try { Method method = enumClass.getMethod(methodName); Enum<?>[] enumConstants = enumClass.getEnumConstants();
for (Enum<?> enumConstant : enumConstants) { allowedValues.add(method.invoke(enumConstant)); } } catch (Exception e) { throw new IllegalArgumentException("初始化枚举校验器失败", e); } }
@Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; } return allowedValues.contains(value); } }
|
使用:
1 2 3 4 5 6 7 8 9 10
| import jakarta.validation.constraints.NotNull; import lombok.Data;
@Data public class UserCreateDTO {
@NotNull(message = "性别不能为空") @EnumValue(enumClass = GenderEnum.class, message = "性别值不合法") private Integer gender; }
|
2. 自定义校验器的固定结构
一个自定义校验注解一般包括三部分:
- 注解本身;
ConstraintValidator 实现类;
- 错误提示信息。
注解里这三个属性基本是固定要写的:
1 2 3 4 5
| String message() default "参数不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
|
否则容易在运行时报错。
3. isValid 中为什么通常允许 null 通过?
很多自定义校验器里都会这样写:
1 2 3
| if (value == null) { return true; }
|
这不是偷懒,而是职责分离。
自定义注解只管自己的业务规则,比如「枚举是否合法」。
字段是否必填,交给 @NotNull、@NotBlank、@NotEmpty。
比如:
1 2
| @EnumValue(enumClass = GenderEnum.class) private Integer gender;
|
表示:如果传了 gender,就必须合法;不传也可以。
1 2 3
| @NotNull(message = "性别不能为空") @EnumValue(enumClass = GenderEnum.class) private Integer gender;
|
表示:必须传,并且必须合法。
这样规则更清楚。
十一、多字段联动校验
有些规则不是单字段能解决的,比如:
- 开始时间必须小于结束时间;
- 最小金额不能大于最大金额;
- 两个字段不能同时为空;
- 某个字段为 A 时,另一个字段必须填写。
这类校验适合做类级别注解。
1. 定义注解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import jakarta.validation.Constraint; import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = DateRangeValidator.class) public @interface DateRange {
String message() default "开始时间不能晚于结束时间";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startField();
String endField(); }
|
2. 实现校验器
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl;
import java.time.LocalDateTime;
public class DateRangeValidator implements ConstraintValidator<DateRange, Object> {
private String startField;
private String endField;
@Override public void initialize(DateRange annotation) { this.startField = annotation.startField(); this.endField = annotation.endField(); }
@Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; }
BeanWrapper beanWrapper = new BeanWrapperImpl(value); Object startValue = beanWrapper.getPropertyValue(startField); Object endValue = beanWrapper.getPropertyValue(endField);
if (startValue == null || endValue == null) { return true; }
if (!(startValue instanceof LocalDateTime startTime) || !(endValue instanceof LocalDateTime endTime)) { return false; }
boolean valid = !startTime.isAfter(endTime);
if (!valid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("开始时间不能晚于结束时间") .addPropertyNode(endField) .addConstraintViolation(); }
return valid; } }
|
3. 使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import jakarta.validation.constraints.NotNull; import lombok.Data;
import java.time.LocalDateTime;
@Data @DateRange(startField = "startTime", endField = "endTime") public class ActivityCreateDTO {
@NotNull(message = "开始时间不能为空") private LocalDateTime startTime;
@NotNull(message = "结束时间不能为空") private LocalDateTime endTime; }
|
这种方式比在 Controller 里写一堆 if 要优雅很多。
当然,也别滥用。
如果规则强依赖数据库、状态机、复杂业务流程,建议放在业务层校验,不要硬塞进注解里。
注解适合稳定、通用、可复用的规则。
十二、自定义 ValidatorUtils
自动校验适合 Controller 和 Service 方法。
但有些场景需要手动校验:
- MQ 消息消费;
- 定时任务;
- 批量导入;
- RPC 入参;
- 单元测试;
- 手动组装对象后校验;
- 非 Spring 管理对象校验;
- 业务流程中间态校验。
这时候可以封装一个 ValidatorUtils。
1. 推荐写法:使用 Spring 注入的 Validator
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils;
import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.stream.Collectors;
@Component @RequiredArgsConstructor public class ValidatorUtils {
private final Validator validator;
public <T> void validateAndThrow(T target, Class<?>... groups) { Set<ConstraintViolation<T>> violations = validator.validate(target, groups);
if (!CollectionUtils.isEmpty(violations)) { throw new ConstraintViolationException(violations); } }
public <T> Map<String, String> validate(T target, Class<?>... groups) { Set<ConstraintViolation<T>> violations = validator.validate(target, groups);
if (CollectionUtils.isEmpty(violations)) { return new LinkedHashMap<>(); }
return violations.stream() .collect(Collectors.toMap( violation -> violation.getPropertyPath().toString(), ConstraintViolation::getMessage, (oldValue, newValue) -> oldValue, LinkedHashMap::new )); }
public <T> String validateFirstMessage(T target, Class<?>... groups) { Set<ConstraintViolation<T>> violations = validator.validate(target, groups);
if (CollectionUtils.isEmpty(violations)) { return null; }
return violations.iterator().next().getMessage(); } }
|
使用:
1 2 3 4 5 6 7 8 9 10 11 12
| @Service @RequiredArgsConstructor public class UserImportService {
private final ValidatorUtils validatorUtils;
public void importUser(UserCreateDTO userCreateDTO) { validatorUtils.validateAndThrow(userCreateDTO, CreateGroup.class);
} }
|
2. 为什么推荐注入 Spring 的 Validator?
因为如果你的自定义校验器里需要注入 Spring Bean,比如:
1 2
| @Autowired private UserRepository userRepository;
|
那么使用 Spring 管理的 Validator 更稳。
如果你自己静态创建:
1
| Validation.buildDefaultValidatorFactory().getValidator();
|
可能会绕开 Spring 的依赖注入机制,导致自定义校验器里注入的 Bean 失效。
所以工程里优先推荐:
1
| private final Validator validator;
|
由 Spring 注入。
3. 非 Spring 环境的静态工具类写法
如果只是普通 Java 工具类,或者非 Spring 环境,可以这样写:
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory;
import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.stream.Collectors;
public final class SimpleValidatorUtils {
private static final Validator VALIDATOR;
static { ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); VALIDATOR = validatorFactory.getValidator(); }
private SimpleValidatorUtils() { }
public static <T> Map<String, String> validate(T target, Class<?>... groups) { Set<ConstraintViolation<T>> violations = VALIDATOR.validate(target, groups);
return violations.stream() .collect(Collectors.toMap( violation -> violation.getPropertyPath().toString(), ConstraintViolation::getMessage, (oldValue, newValue) -> oldValue, LinkedHashMap::new )); }
public static <T> void validateAndThrow(T target, Class<?>... groups) { Set<ConstraintViolation<T>> violations = VALIDATOR.validate(target, groups);
if (!violations.isEmpty()) { throw new IllegalArgumentException( violations.stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining("; ")) ); } } }
|
这种方式简单,但不适合需要 Spring Bean 注入的复杂自定义校验器。
4. 支持 Fail Fast
默认情况下,Validation 会收集所有错误。
比如一个 DTO 有 5 个字段错了,就返回 5 个错误。
如果你只想返回第一条错误,可以开启 Fail Fast。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import jakarta.validation.Validation; import jakarta.validation.Validator; import org.hibernate.validator.HibernateValidator;
public final class FastValidatorUtils {
private static final Validator VALIDATOR = Validation .byProvider(HibernateValidator.class) .configure() .failFast(true) .buildValidatorFactory() .getValidator();
private FastValidatorUtils() { } }
|
Fail Fast 适合只关心第一条错误的场景。
但如果是前端表单,通常建议一次返回全部错误,用户体验更好。
十三、全局异常统一处理
如果不做全局异常处理,Validation 抛出来的异常格式会比较散。
建议统一处理。
1. 定义统一错误返回
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
| import lombok.AllArgsConstructor; import lombok.Data;
@Data @AllArgsConstructor public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success() { return new Result<>(0, "success", null); }
public static <T> Result<T> success(T data) { return new Result<>(0, "success", data); }
public static <T> Result<T> error(String message) { return new Result<>(1, message, null); } }
|
2. 处理 @RequestBody 校验异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String message = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); } }
|
3. 处理表单绑定异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import org.springframework.validation.BindException; import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.stream.Collectors;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(BindException.class) public Result<Void> handleBindException(BindException ex) { String message = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); } }
|
4. 处理方法参数校验异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import jakarta.validation.ConstraintViolationException; import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.stream.Collectors;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class) public Result<Void> handleConstraintViolationException(ConstraintViolationException ex) { String message = ex.getConstraintViolations() .stream() .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) .collect(Collectors.joining("; "));
return Result.error(message); } }
|
5. Spring Boot 3.2 / Spring 6.1 的 HandlerMethodValidationException
Spring Boot 3.2 以后,如果你直接在 Controller 方法参数上写约束注解,可能会遇到:
1
| HandlerMethodValidationException
|
可以这样处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException;
import java.util.List; import java.util.stream.Collectors;
@RestControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(HandlerMethodValidationException.class) public Result<Void> handleHandlerMethodValidationException(HandlerMethodValidationException ex) { String message = ex.getAllValidationResults() .stream() .map(ParameterValidationResult::getResolvableErrors) .flatMap(List::stream) .map(error -> error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); } }
|
6. 完整版异常处理器
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| import jakarta.validation.ConstraintViolationException; import org.springframework.validation.BindException; import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException;
import java.util.List; import java.util.stream.Collectors;
@RestControllerAdvice public class GlobalValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String message = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); }
@ExceptionHandler(BindException.class) public Result<Void> handleBindException(BindException ex) { String message = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); }
@ExceptionHandler(ConstraintViolationException.class) public Result<Void> handleConstraintViolationException(ConstraintViolationException ex) { String message = ex.getConstraintViolations() .stream() .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) .collect(Collectors.joining("; "));
return Result.error(message); }
@ExceptionHandler(HandlerMethodValidationException.class) public Result<Void> handleHandlerMethodValidationException(HandlerMethodValidationException ex) { String message = ex.getAllValidationResults() .stream() .map(ParameterValidationResult::getResolvableErrors) .flatMap(List::stream) .map(error -> error.getDefaultMessage()) .collect(Collectors.joining("; "));
return Result.error(message); } }
|
十四、BindingResult 是否还需要?
有些文章会这样写:
1 2 3 4 5 6 7 8
| @PostMapping("/users") public Result<Void> create(@Valid @RequestBody UserDTO userDTO, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return Result.error(bindingResult.getFieldError().getDefaultMessage()); }
return Result.success(); }
|
这种写法可以用,但我不太推荐在每个接口都这么写。
原因很简单:
如果每个 Controller 方法都手动判断 BindingResult,那 Validation 的优雅程度直接减半。
更推荐:
1 2 3 4
| @PostMapping("/users") public Result<Void> create(@Valid @RequestBody UserDTO userDTO) { return Result.success(); }
|
然后交给全局异常处理器统一处理。
什么时候可以用 BindingResult?
- 某个接口需要特殊错误处理;
- 某个页面需要返回复杂字段级错误;
- 你不想让校验异常中断流程。
普通 REST API 里,统一异常处理更干净。
十五、业务校验和参数校验的边界
Validation 适合做稳定、通用、轻量的校验。
比如:
- 非空;
- 长度;
- 数字范围;
- 日期范围;
- 格式;
- 枚举值;
- 简单字段联动。
不建议把复杂业务都塞进注解里。
比如:
- 用户是否有权限;
- 单据状态是否允许流转;
- 库存是否足够;
- 结算单是否已锁定;
- 数据是否跨租户;
- 复杂数据库一致性检查。
这些更适合放在 Service 业务逻辑中。
可以这样分层:
1 2 3 4 5 6 7 8
| Controller 入参校验: 校验参数格式和基本合法性
Service 业务校验: 校验业务规则、权限、状态流转、数据一致性
Repository / DB: 通过唯一索引、外键、事务等保证最终一致性
|
Validation 是门卫,不是法官。
门卫负责看你有没有票,法官才负责判你有没有罪。
十六、常见坑总结
1. 忘记引入依赖
没有引入:
1 2 3 4 5
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
|
注解写了也可能不生效。
2. Spring Boot 2 和 3 包名不同
Spring Boot 2:
Spring Boot 3:
1
| jakarta.validation.Valid;
|
别混用。
3. 简单参数校验忘记加 @Validated
1 2 3 4
| @RestController @Validated public class UserController { }
|
方法参数上直接写 @NotNull、@Min 时,建议类上加 @Validated。
4. 嵌套对象忘记加 @Valid
1 2
| @Valid private AddressDTO address;
|
不加就不递归校验。
5. 集合元素校验不生效
建议封装请求对象:
1 2 3 4 5 6 7
| @Data public class BatchDTO {
@Valid @NotEmpty(message = "明细不能为空") private List<ItemDTO> items; }
|
6. int 不能校验 null
1 2
| @NotNull private int age;
|
这样没意义,因为 int 默认就是 0,不可能是 null。
应该用包装类型:
1 2
| @NotNull(message = "年龄不能为空") private Integer age;
|
7. @NotNull 不能防空字符串
1 2
| @NotNull private String name;
|
"" 可以通过。
字符串必填优先用:
1 2
| @NotBlank private String name;
|
8. 自定义校验器里直接查数据库要慎重
可以查,但不要滥用。
如果每个字段校验都查数据库,批量导入时可能直接变成 N 次查询,性能爆炸。
更好的方式是:
- 简单存在性校验可以用自定义注解;
- 批量场景提前查出数据集合;
- 复杂业务规则放 Service 层。
9. 分组校验可能漏掉默认组
如果你希望分组校验时默认规则也执行,可以让分组接口继承 Default:
1
| public interface CreateGroup extends Default {}
|
10. 不同参数类型抛出的异常不一样
常见异常包括:
1 2 3 4
| MethodArgumentNotValidException BindException ConstraintViolationException HandlerMethodValidationException
|
全局异常处理器里最好都处理。
十七、我的建议
如果是普通 Spring Boot REST API,我建议这样定规范:
1. Controller 请求体对象
1 2 3 4
| @PostMapping public Result<Void> create(@Valid @RequestBody CreateDTO request) { return Result.success(); }
|
2. 需要分组时
1 2 3 4
| @PostMapping public Result<Void> create(@Validated(CreateGroup.class) @RequestBody UserDTO request) { return Result.success(); }
|
3. 简单参数校验
1 2 3 4 5 6 7 8 9
| @RestController @Validated public class UserController {
@GetMapping("/{id}") public Result<Void> detail(@PathVariable @Min(1) Long id) { return Result.success(); } }
|
4. 集合参数
优先封装请求对象:
1 2 3 4 5 6 7
| @Data public class BatchCreateDTO {
@Valid @NotEmpty(message = "明细不能为空") private List<ItemDTO> items; }
|
5. 手动校验
在 MQ、定时任务、批量导入、RPC 等场景使用:
1
| validatorUtils.validateAndThrow(request);
|
6. 复杂业务规则
不要硬塞进注解。
该放 Service 就放 Service。代码不是许愿池,什么都往注解里扔,最后只会变成维护噩梦。
总结
Validation 的核心价值不是少写几个 if,而是让参数规则变得清晰、统一、可复用。
一套比较舒服的使用方式是:
- DTO 上声明字段规则;
- Controller 使用
@Valid / @Validated 触发校验;
- 嵌套对象和集合使用
@Valid;
- 新增、修改等场景使用分组校验;
- 通用业务规则抽成自定义注解;
- MQ、定时任务、批处理场景使用
ValidatorUtils 手动校验;
- 所有校验异常交给全局异常处理器统一返回。
最终目标是让代码变成这样:
1 2 3 4 5
| @PostMapping("/users") public Result<Void> create(@Validated(CreateGroup.class) @RequestBody UserDTO request) { userService.create(request); return Result.success(); }
|
而不是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @PostMapping("/users") public Result<Void> create(@RequestBody UserDTO request) { if (request == null) { return Result.error("参数不能为空"); } if (request.getName() == null || request.getName().trim().isEmpty()) { return Result.error("用户名不能为空"); } if (request.getEmail() == null) { return Result.error("邮箱不能为空"); } return Result.success(); }
|
前者像工程代码。
后者像代码在工地搬砖,还没戴安全帽。
参考资料
启示录
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。