校验 Validation — JSR-303 / JSR-380 Bean Validation
一句话定位:Bean Validation 是 Java 生态的"数据安检门"——在数据进入业务逻辑之前,用声明式注解检查字段是否合法,不合法直接拦下并返回清晰的错误信息。
为什么需要 Bean Validation?
乐途公司的孔蓝设计了一个商品上架接口,前端说"肯定传对了",结果后端收到:
- 商品名称为空
- 价格为负数
- 库存数量是字符串
"abc" - 邮箱格式是
"not-an-email"
如果小崔在每个方法里手写 if-else 校验:
public void createProduct(ProductForm form) {
if (form.getName() == null || form.getName().isBlank()) {
throw new IllegalArgumentException("商品名称不能为空");
}
if (form.getPrice() == null || form.getPrice() <= 0) {
throw new IllegalArgumentException("价格必须大于0");
}
// ... 20 个字段要校验,代码爆炸
}
Bean Validation 的解决思路:在字段上标注注解,让框架自动校验。
JSR-303 与 JSR-380
| 规范 | 版本 | 说明 |
|---|---|---|
| JSR-303 | Bean Validation 1.0 | 基础注解:@NotNull、@Size、@Min、@Max 等 |
| JSR-380 | Bean Validation 2.0 | 新增:@NotEmpty、@NotBlank、@Email、@Positive 等 |
| Jakarta Validation 3.0 | 继任者 | 包名从 javax.validation 迁移到 jakarta.validation(Spring 6.0+) |
Spring 从 3.0 开始集成 Bean Validation,默认使用 Hibernate Validator 作为实现。
核心注解速查
| 注解 | 适用类型 | 说明 |
|---|---|---|
@NotNull | 任意 | 值不能为 null |
@NotEmpty | String / Collection / Map / 数组 | 不能为 null 且长度 > 0 |
@NotBlank | String | 不能为 null 且去除空白后长度 > 0 |
@Size(min, max) | String / Collection / 数组 | 长度/大小范围 |
@Min(value) | 数字 | 最小值 |
@Max(value) | 数字 | 最大值 |
@Positive | 数字 | 必须为正数(> 0) |
@PositiveOrZero | 数字 | 必须 ≥ 0 |
@DecimalMin | BigDecimal / String | 最小值(支持小数) |
@DecimalMax | BigDecimal / String | 最大值 |
@Digits(integer, fraction) | 数字 | 整数位和小数位限制 |
@Email | String | 邮箱格式 |
@Pattern(regexp) | String | 正则匹配 |
@Past / @PastOrPresent | 日期 | 过去的时间 |
@Future / @FutureOrPresent | 日期 | 将来的时间 |
@AssertTrue / @AssertFalse | boolean | 必须为 true / false |
@Valid | 对象 | 级联校验(校验嵌套对象) |
乐途场景:商品与订单校验
商品上架表单
public class ProductForm {
@NotBlank(message = "商品名称不能为空")
@Size(max = 100, message = "商品名称最多 100 个字符")
private String name;
@NotNull(message = "价格不能为空")
@Positive(message = "价格必须大于 0")
@Digits(integer = 8, fraction = 2, message = "价格格式错误,最多 8 位整数 2 位小数")
private BigDecimal price;
@NotNull(message = "库存不能为空")
@Min(value = 0, message = "库存不能为负数")
@Max(value = 999999, message = "库存超出上限")
private Integer stock;
@NotBlank(message = "商品分类不能为空")
private String category;
@Email(message = "负责人邮箱格式不正确")
private String managerEmail;
@Pattern(regexp = "^FX-[0-9]+$", message = "商品编码格式必须为 FX-数字")
private String productCode;
// getters / setters...
}
Controller 中使用
@RestController
@RequestMapping("/api/products")
public class ProductController {
@PostMapping
public ResponseEntity<Product> createProduct(
@RequestBody @Valid ProductForm form) {
// 如果校验失败,Spring 自动抛 MethodArgumentNotValidException
// 由 @ControllerAdvice 统一处理返回 400
Product product = productService.create(form);
return ResponseEntity.status(HttpStatus.CREATED).body(product);
}
}
全局异常处理
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
Map<String, Object> body = Map.of(
"error", "VALIDATION_FAILED",
"message", "请求参数校验失败",
"details", errors
);
return ResponseEntity.badRequest().body(body);
}
}
分组校验
乐途公司的商品在不同阶段校验规则不同:
public interface CreateGroup {} // 创建时校验
public interface UpdateGroup {} // 更新时校验
public class ProductForm {
@NotBlank(groups = CreateGroup.class)
private String name;
@NotNull(groups = {CreateGroup.class, UpdateGroup.class})
@Positive
private BigDecimal price;
// 更新时允许不传库存(不修改)
@NotNull(groups = CreateGroup.class)
@Min(0)
private Integer stock;
}
@PostMapping
public Product create(@RequestBody @Validated(CreateGroup.class) ProductForm form) {
// 创建时校验 name、price、stock
}
@PutMapping("/{id}")
public Product update(@RequestBody @Validated(UpdateGroup.class) ProductForm form) {
// 更新时只强制校验 price
}
自定义校验注解
乐途公司需要校验商品名称不能包含敏感词:
@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = NoSensitiveWordValidator.class)
public @interface NoSensitiveWord {
String message() default "包含敏感词汇";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class NoSensitiveWordValidator implements ConstraintValidator<NoSensitiveWord, String> {
private static final List<String> SENSITIVE_WORDS = List.of("暴力", "色情", "赌博");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isBlank()) {
return true; // @NotBlank 负责判空
}
return SENSITIVE_WORDS.stream().noneMatch(value::contains);
}
}
使用:
public class ProductForm {
@NotBlank
@NoSensitiveWord(message = "商品名称包含违规内容")
private String name;
}
注意事项
| 注意点 | 说明 |
|---|---|
@Valid vs @Validated | @Valid 是 JSR 标准,支持级联校验;@Validated 是 Spring 的,支持分组校验。两者可组合使用 |
| 校验顺序 | 先类型转换,再字段校验。如果类型转换失败(如 "abc" → int),会抛 TypeMismatchException 而非校验错误 |
| 嵌套校验 | 对象内部嵌套对象需加 @Valid,否则不会级联校验 |
| 集合校验 | List<@Valid ProductForm> 可对列表中每个元素校验(需 Bean Validation 2.0+) |
| Service 层校验 | 在 Service 方法参数上加 @Validated,配合 @Valid 实现业务层校验 |
| message 国际化 | 在 ValidationMessages.properties 中配置 {javax.validation.constraints.NotBlank.message}=不能为空 |
常见面试题
Q1:@NotNull、@NotEmpty、@NotBlank 有什么区别?
@NotNull:不能为 null,但可以是空字符串""或空集合。@NotEmpty:不能为 null 且长度/大小 > 0,""不通过。@NotBlank:只能用于 String,不能为 null 且 trim 后长度 > 0," "也不通过。
Q2:为什么 @Valid 在嵌套对象上必须显式标注?
这是规范设计。
@Valid表示"级联校验",没有它,Validator 不会深入嵌套对象内部校验字段。这是为了防止无意识的深度遍历导致性能问题。
Q3:Spring 校验和 Hibernate Validator 的关系?
Spring 提供集成层(
LocalValidatorFactoryBean、@Validated),Hibernate Validator 是规范的实现(Reference Implementation)。Spring 默认自动检测并装配 Hibernate Validator。
Q4:如何在校验失败时返回自定义错误码?
实现
ConstraintValidator时,通过context.buildConstraintViolationWithTemplate("错误信息").addConstraintViolation()自定义消息。或在全局异常处理器中根据字段名映射错误码。