Java-Validation

欢迎你来读这篇博客,这篇博客主要是关于 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("邮箱格式不正确");
}

这类代码不是不能写,而是写多了以后会出现几个问题:

  1. 业务代码被大量参数判断污染;
  2. 校验规则分散,不方便维护;
  3. 相同字段规则重复写;
  4. Controller、Service、工具类之间的校验风格不统一;
  5. 错误返回格式不好统一。

于是就有了 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();
}
}

注意:

1
@Validated

建议加在 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
BindException

六、集合参数校验

集合校验是比较容易翻车的地方。

比如前端传:

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();
}

这样有几个好处:

  1. 可以校验集合本身不能为空;
  2. 可以校验集合内部元素;
  3. 后续加字段更方便,比如 operatorIdsourceremark
  4. 异常路径更清楚;
  5. 接口扩展性更好。

七、嵌套对象校验

嵌套对象一定要加 @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 {
}

这里让 CreateGroupUpdateGroup 继承 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) {
// business logic
}

public void delete(@NotNull(message = "用户ID不能为空") Long userId) {
// business logic
}
}

重点是:

1
@Validated

要加在类上。

否则方法参数上的 @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) {
// 为空时不在这里处理,是否必填交给 @NotNull / @NotBlank
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. 自定义校验器的固定结构

一个自定义校验注解一般包括三部分:

  1. 注解本身;
  2. ConstraintValidator 实现类;
  3. 错误提示信息。

注解里这三个属性基本是固定要写的:

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;

/**
* 校验对象,不通过直接抛出 ConstraintViolationException。
*/
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:

1
javax.validation.Valid;

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,而是让参数规则变得清晰、统一、可复用。

一套比较舒服的使用方式是:

  1. DTO 上声明字段规则;
  2. Controller 使用 @Valid / @Validated 触发校验;
  3. 嵌套对象和集合使用 @Valid
  4. 新增、修改等场景使用分组校验;
  5. 通用业务规则抽成自定义注解;
  6. MQ、定时任务、批处理场景使用 ValidatorUtils 手动校验;
  7. 所有校验异常交给全局异常处理器统一返回。

最终目标是让代码变成这样:

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();
}

前者像工程代码。
后者像代码在工地搬砖,还没戴安全帽。

参考资料

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Java-Validation
https://allendericdalexander.github.io/2025/08/06/java/spring/java_validated/
作者
AtLuoFu
发布于
2025年8月6日
许可协议