Spring Boot 中的 Bean Validation 验证(@Valid、@NotBlank…)

1. 概览

构建 REST API 时,总是需要在处理业务逻辑前验证输入数据:邮箱格式是否正确、名称不能为空、年龄必须为正数……Bean Validation(JSR 380,实现为 Hibernate Validator)让你通过注解来完成这些工作,而无需编写大量手动的 if-else 语句。

本文将介绍如何配置、使用 @Valid 和常见注解、验证嵌套对象、编写自定义验证器,以及处理验证错误并向客户端返回友好的响应。读完本文后,你将能够自信地在 Spring Boot 项目中对所有 DTO 应用验证。

2. 添加依赖

Spring Boot 默认不包含 Bean Validation,你需要添加 spring-boot-starter-validation:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

该依赖会引入 Hibernate Validator——Bean Validation API 最流行的实现。如果你使用 spring-boot-starter-web,可能会以为验证已经包含了,但实际上从 Spring Boot 2.3 开始,该 starter 已被分离出来——没有它,@Valid 将不会生效且不会报错,错误数据仍会通过。

3. 使用 @Valid 验证请求体

3.1. 使用约束注解声明 DTO

public record CreateOrderRequest(
        @NotBlank(message = "客户名称不能为空")
        String customerName,

        @Email(message = "邮箱格式无效")
        @NotBlank
        String email,

        @Positive(message = "数量必须大于 0")
        Integer quantity,

        @DecimalMin(value = "0.0", inclusive = false, message = "价格必须大于 0")
        BigDecimal unitPrice
) {}

每个字段都绑定一个或多个约束注解:@NotBlank 不允许 null/空字符串/仅包含空格,@Email 检查邮箱格式,@Positive 要求正数,@DecimalMin 设置最小值。message 参数是违反约束时返回的错误消息。

3.2. 在 controller 中应用 @Valid

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // 此处 request 已被验证为有效
        return ResponseEntity.ok("Order created for " + request.customerName());
    }
}

@Valid 放在 @RequestBody 前,告诉 Spring MVC 在反序列化 JSON 后对对象进行验证。如果任何字段违反约束,Spring 会在方法体执行前抛出 MethodArgumentNotValidException——这意味着你不需要在业务逻辑中手动检查 null 或空值。

发送缺少 customerName 的请求:

POST /api/orders
{
  "email": "a@b.com",
  "quantity": 2,
  "unitPrice": 10.5
}

在默认情况下(未处理异常),Spring 返回 HTTP 400,响应体很长且难以阅读。第 5 部分将以更简洁的方式处理这个问题。

4. 常用注解

注解 含义
@NotNull 值不能为 null
@NotBlank String 不为 null、不为空、不仅包含空格
@NotEmpty Collection/String/Array 不为 null 且不为空
@Size(min, max) String 长度或 Collection 大小
@Min / @Max 整数限制
@Positive / @Negative 正数/负数
@Email 邮箱格式
@Pattern(regexp) 正则表达式匹配
@Past / @Future 过去/未来的日期

在一个字段上结合多个约束的示例:

public class Customer {

    @Size(min = 3, max = 50, message = "名称长度必须在 3 到 50 个字符之间")
    private String name;

    @Pattern(regexp = "^0\\d{9}$", message = "电话号码必须是 10 位,以 0 开头")
    private String phone;

    @Past(message = "出生日期必须在过去")
    private LocalDate birthDate;
}

你可以在同一字段上绑定多个注解——例如 @NotBlank 加上 @Size——既禁止空值又限制长度。验证器运行所有约束并汇总错误,不会在第一个错误处停止。

5. 使用 @ExceptionHandler 处理验证错误

与其让 Spring 返回默认的杂乱响应,不如使用 @RestControllerAdvice 来格式化响应:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        Map<String, String> errors = new HashMap<>();
        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }
        return ResponseEntity.badRequest().body(errors);
    }
}

MethodArgumentNotValidException 包含 BindingResult,其中有 FieldError 列表——每个错误都有字段名和消息。上面的代码将所有错误收集到一个 Map<field, message> 中,然后返回 HTTP 400。

再次调用前面缺少 customerName 的请求,现在得到的结果是:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "customerName": "客户名称不能为空"
}

响应现在更简洁,客户端可以轻松将错误映射到表单上的每个字段。

6. 验证嵌套对象和验证集合

6.1. 嵌套对象使用 @Valid

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) {}

public record CustomerRequest(
        @NotBlank String name,

        @Valid
        @NotNull
        AddressRequest address
) {}

如果 CustomerRequest.address 只有 @NotNull 而没有 @Valid,AddressRequest 内的约束(如 city 上的 @NotBlank)将不会被检查——这是使用 Bean Validation 时的常见错误。@Valid 在这里要求验证器“深入”到嵌套对象中。

6.2. 验证 List 中的每个元素

public record BulkOrderRequest(
        @NotEmpty(message = "项目列表不能为空")
        @Valid
        List<@Valid OrderItem> items
) {}

public record OrderItem(
        @NotBlank String productCode,
        @Positive Integer quantity
) {}

@Valid 放在 List<...> 前(或在泛型类型参数上)告诉验证器检查列表中的每个元素,而不仅仅是检查列表是否为 null。如果列表中的第二项的 quantity = -1,返回的错误将明确指出 items[1].quantity。

7. 编写自定义验证器

当现有约束不够时,你可以创建自己的注解。例如,检查订单代码是否以 ORD- 开头:

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
    String message() default "订单代码必须是 ORD-xxxxx 的格式";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // 让 @NotNull 单独处理,避免重复错误
        }
        return value.matches("^ORD-\\d{5}$");
    }
}

@Constraint(validatedBy = ...) 将注解与处理类关联起来。ConstraintValidator<A, T> 接收 A 作为注解,T 是适用的字段类型。isValid 方法在有效时返回 true。注意:当 value == null 时应返回 true,因为强制非 null 应由 @NotNull 单独处理——避免一个字段报告两个不同的错误。

在 DTO 中使用:

public record OrderLookupRequest(
        @NotBlank
        @ValidOrderCode
        String orderCode
) {}

8. 使用 JUnit 5 + AssertJ 进行验证测试

你可以直接测试 Validator,而不需要启动 Spring MVC 上下文,速度更快:

class CreateOrderRequestValidationTest {

    private static Validator validator;

    @BeforeAll
    static void setUp() {
        try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
            validator = factory.getValidator();
        }
    }

    @Test
    void shouldRejectBlankCustomerName() {
        CreateOrderRequest request = new CreateOrderRequest(
                "", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations)
                .extracting(v -> v.getPropertyPath().toString())
                .contains("customerName");
    }

    @Test
    void shouldPassWhenAllFieldsValid() {
        CreateOrderRequest request = new CreateOrderRequest(
                "Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);

        Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);

        assertThat(violations).isEmpty();
    }
}

Validation.buildDefaultValidatorFactory() 创建独立的 Validator,不需要 Spring 上下文。validator.validate(request) 返回 Set<ConstraintViolation>——如果为空则有效。这种测试方式适合单独测试 DTO,与 controller 逻辑分离。

9. 验证路径变量和查询参数

@Valid 在 @RequestBody 上最常见,但你也可以通过在 controller 类上添加 @Validated 来验证单个参数:

@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(
            @PathVariable @Positive(message = "ID 必须大于 0") Long id) {
        return ResponseEntity.ok("Order " + id);
    }
}

@Validated(来自 Spring,不同于 Bean Validation 的 @Valid)在方法参数级别启用验证。当 id = -1 时,Spring 抛出 ConstraintViolationException(不同于第 5 部分的 MethodArgumentNotValidException)——你需要为这个异常编写单独的 @ExceptionHandler:

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
    return ResponseEntity.badRequest().body(ex.getMessage());
}

注意: 这两个异常容易混淆——MethodArgumentNotValidException 用于 @Valid @RequestBody/@ModelAttribute,而 ConstraintViolationException 用于路径变量/查询参数上的 @Validated。少了任何一个 handler,错误会被默认 500 响应处理,非常不好看。

10. 常见错误和何时考虑其他方案

  • 忘记 @Valid:约束注解不会自动激活,你必须在正确的位置声明 @Valid(controller 参数、嵌套对象字段、泛型类型参数)。
  • 缺少 starter-validation 依赖:约束注解被静默忽略,不会抛出任何异常——如果不写测试就很难发现。
  • 验证跨多个字段的复杂逻辑(例如“结束日期必须晚于开始日期”):Bean Validation 仍然可以通过类级别约束处理,但如果逻辑依赖数据库数据(例如“邮箱尚未存在”),应该在 service 层处理而不是验证器——因为验证器不应该调用 repository,这会使其难以测试且违反单一职责原则。
  • 多语言错误消息:使用 message = "{order.code.invalid}" 和 messages.properties 文件而不是在注解中硬编码中文,如果项目需要 i18n。

11. 结论

Bean Validation 通过注解帮助你在 DTO 上声明数据检查规则,代替散布各处的 if-else 代码。将 @Valid/@Validated 与 @RestControllerAdvice 结合,你就拥有一个整个 API 的一致验证——错误报告流程。当现有约束不够时,自定义验证器允许灵活扩展,同时保持声明式风格、易读、易测试。

spring-bootvalidationbean-validationjavarest-api