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 的一致验证——错误报告流程。当现有约束不够时,自定义验证器允许灵活扩展,同时保持声明式风格、易读、易测试。