Bean Validation 与 Hibernate Validator 数据校验精要

掌握 JSR-380 Bean Validation 规范与 Hibernate Validator 扩展,实现分层校验、自定义约束与国际化错误消息

数据校验是企业级应用的第一道防线,在数据进入业务逻辑之前拦截非法输入,可有效减少系统漏洞和异常。Bean Validation 2.0(JSR-380)与 Hibernate Validator 6.x 提供了强大且灵活的校验机制。

一、核心注解速查

1.1 内置约束注解

注解适用范围说明
@NotNull任意值不能为 null
@NotEmptyString/Collection/Map/数组不能为空串且长度 > 0
@NotBlankString不能为 null 且 trim 后长度 > 0
@Size(min, max)String/Collection/Map/数组长度/大小范围
@Min / @Max数字数值范围
@DecimalMin / @DecimalMaxBigDecimal/String小数范围
@Positive / @PositiveOrZero数字正数/非负数
@Negative / @NegativeOrZero数字负数/非正数
@Digits(int, frac)数字整数位和小数位限制
@Past / @PastOrPresent日期过去时间
@Future / @FutureOrPresent日期未来时间
@Pattern(regexp)String正则匹配
@EmailString邮箱格式
@AssertTrue / @AssertFalseBoolean必须为 true/false
@Valid对象/集合级联校验

1.2 基础用法

public class UserRegistrationRequest {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3-20 之间")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 32, message = "密码长度必须在 8-32 之间")
    @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
             message = "密码必须包含大小写字母和数字")
    private String password;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄必须大于等于 18 岁")
    @Max(value = 120, message = "年龄必须小于等于 120 岁")
    private Integer age;
    
    @NotNull(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
    
    @NotNull(message = "生日不能为空")
    @Past(message = "生日必须是过去的时间")
    private LocalDate birthday;
    
    @Valid  // 级联校验
    @NotNull(message = "地址不能为空")
    private Address address;
}

public class Address {
    @NotBlank(message = "省份不能为空")
    private String province;
    
    @NotBlank(message = "城市不能为空")
    private String city;
    
    @NotBlank(message = "详细地址不能为空")
    @Size(max = 200, message = "详细地址不能超过 200 字")
    private String detail;
}

二、Spring Boot 集成

2.1 Controller 层校验

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @PostMapping
    public ResponseEntity<Void> register(
        @Valid @RequestBody UserRegistrationRequest request  // @Valid 触发校验
    ) {
        userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
    
    @GetMapping
    public List<User> list(
        @RequestParam @Min(0) @Max(1000) Integer page,
        @RequestParam @Min(1) @Max(100) Integer size
    ) {
        return userService.findPage(page, size);
    }
    
    @GetMapping("/{userId}")
    public User getUser(
        @PathVariable @Pattern(regexp = "^\\d{10}$") String userId
    ) {
        return userService.findById(userId);
    }
}

2.2 统一异常处理

@RestControllerAdvice
public class ValidationExceptionHandler {
    
    // 处理 @Valid 校验失败
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(error -> new FieldError(
                error.getField(),
                error.getDefaultMessage(),
                error.getRejectedValue()
            ))
            .collect(Collectors.toList());
        
        return ResponseEntity.badRequest()
            .body(new ErrorResponse(400, "参数校验失败", errors));
    }
    
    // 处理 @RequestParam / @PathVariable 校验失败
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
        List<FieldError> errors = ex.getConstraintViolations().stream()
            .map(v -> new FieldError(
                v.getPropertyPath().toString(),
                v.getMessage(),
                v.getInvalidValue()
            ))
            .collect(Collectors.toList());
        
        return ResponseEntity.badRequest()
            .body(new ErrorResponse(400, "参数校验失败", errors));
    }
}

2.3 分组校验

public interface ValidationGroups {
    interface Create {}   // 创建场景
    interface Update {}   // 更新场景
    interface Delete {}   // 删除场景
}

public class UserDto {
    
    @Null(groups = ValidationGroups.Create.class, message = "创建时 ID 必须为空")
    @NotNull(groups = ValidationGroups.Update.class, message = "更新时 ID 不能为空")
    private Long id;
    
    @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class})
    private String username;
    
    @NotBlank(groups = ValidationGroups.Create.class)
    private String password;
    
    @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class})
    private String email;
}

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @PostMapping
    public void create(@Validated(ValidationGroups.Create.class) @RequestBody UserDto dto) {
        userService.create(dto);
    }
    
    @PutMapping("/{id}")
    public void update(@Validated(ValidationGroups.Update.class) @RequestBody UserDto dto) {
        userService.update(dto);
    }
}

三、自定义约束注解

3.1 手机号校验

@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PhoneValidator implements ConstraintValidator<Phone, String> {
    
    private static final Pattern PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true;  // @NotNull/@NotBlank 处理空值
        }
        return PATTERN.matcher(value).matches();
    }
}

// 使用
public class UserDto {
    @Phone
    private String phone;
}

3.2 枚举值校验

@Documented
@Constraint(validatedBy = EnumValueValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface EnumValue {
    Class<? extends Enum<?>> enumClass();
    String message() default "值不在允许的枚举范围内";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class EnumValueValidator implements ConstraintValidator<EnumValue, String> {
    
    private Set<String> validValues;
    
    @Override
    public void initialize(EnumValue annotation) {
        validValues = Arrays.stream(annotation.enumClass().getEnumConstants())
            .map(Enum::name)
            .collect(Collectors.toSet());
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;
        return validValues.contains(value);
    }
}

// 使用
public enum OrderStatus {
    PENDING, PAID, SHIPPED, COMPLETED, CANCELLED
}

public class OrderQuery {
    @EnumValue(enumClass = OrderStatus.class)
    private String status;
}

3.3 字段关联校验

@Documented
@Constraint(validatedBy = DateRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDateRange {
    String message() default "结束时间必须晚于开始时间";
    String startField();
    String endField();
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class DateRangeValidator implements ConstraintValidator<ValidDateRange, Object> {
    
    private String startField;
    private String endField;
    
    @Override
    public void initialize(ValidDateRange annotation) {
        this.startField = annotation.startField();
        this.endField = annotation.endField();
    }
    
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        try {
            LocalDateTime start = (LocalDateTime) new PropertyDescriptor(startField, obj.getClass())
                .getReadMethod().invoke(obj);
            LocalDateTime end = (LocalDateTime) new PropertyDescriptor(endField, obj.getClass())
                .getReadMethod().invoke(obj);
            
            if (start == null || end == null) return true;
            return end.isAfter(start);
        } catch (Exception e) {
            return false;
        }
    }
}

// 使用
@ValidDateRange(startField = "startTime", endField = "endTime")
public class EventCreateRequest {
    @NotNull
    private LocalDateTime startTime;
    
    @NotNull
    private LocalDateTime endTime;
}

四、Service 层校验

4.1 编程式校验

@Service
public class OrderService {
    
    @Autowired
    private Validator validator;
    
    public void createOrder(OrderCreateRequest request) {
        // 手动触发校验
        Set<ConstraintViolation<OrderCreateRequest>> violations = validator.validate(request);
        
        if (!violations.isEmpty()) {
            String message = violations.stream()
                .map(v -> v.getPropertyPath() + ": " + v.getMessage())
                .collect(Collectors.joining("; "));
            throw new ValidationException(message);
        }
        
        // 业务逻辑...
    }
    
    public void updateOrder(Long id, OrderUpdateRequest request) {
        // 指定分组校验
        Set<ConstraintViolation<OrderUpdateRequest>> violations = 
            validator.validate(request, ValidationGroups.Update.class);
        
        if (!violations.isEmpty()) {
            throw new ValidationException("参数校验失败");
        }
        
        // 特定字段快速校验
        Set<ConstraintViolation<OrderUpdateRequest>> priceViolation = 
            validator.validateProperty(request, "price");
    }
}

4.2 方法参数校验(AOP)

@Service
@Validated  // 开启方法参数校验
public class UserService {
    
    public User createUser(
        @NotBlank String username,
        @Email String email,
        @Min(18) @Max(120) int age
    ) {
        // 参数会在调用前自动校验
        return userDao.save(new User(username, email, age));
    }
    
    public void updateStatus(
        @NotNull Long userId,
        @Pattern(regexp = "ACTIVE|INACTIVE|BANNED") String status
    ) {
        userDao.updateStatus(userId, status);
    }
}

五、国际化错误消息

5.1 配置消息源

spring:
  messages:
    basename: validation-messages
    encoding: UTF-8
# validation-messages.properties(默认)
user.username.notblank=Username is required
user.email.invalid=Please enter a valid email address

# validation-messages_zh.properties(中文)
user.username.notblank=用户名不能为空
user.email.invalid=请输入有效的邮箱地址

5.2 自定义消息解析

public class I18nMessageInterpolator implements MessageInterpolator {
    
    @Autowired
    private MessageSource messageSource;
    
    @Override
    public String interpolate(String messageTemplate, Context context) {
        return interpolate(messageTemplate, context, LocaleContextHolder.getLocale());
    }
    
    @Override
    public String interpolate(String messageTemplate, Context context, Locale locale) {
        if (messageTemplate.startsWith("{")) {
            String key = messageTemplate.substring(1, messageTemplate.length() - 1);
            return messageSource.getMessage(key, null, messageTemplate, locale);
        }
        return messageTemplate;
    }
}

5.3 Hibernate Validator 配置

@Configuration
public class ValidationConfig {
    
    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
        factoryBean.setValidationMessageSource(messageSource);
        return factoryBean;
    }
    
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor(Validator validator) {
        MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
        processor.setValidator(validator);
        return processor;
    }
}

六、最佳实践

6.1 分层校验策略

Controller 层:格式校验(非空、长度、格式)
    ↓
Service 层:业务校验(存在性、状态、权限)
    ↓
DAO 层:数据库约束(唯一性、外键)

6.2 校验规则封装

public class ValidationPatterns {
    public static final String PHONE = "^1[3-9]\\d{9}$";
    public static final String PASSWORD = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,32}$";
    public static final String USERNAME = "^[a-zA-Z0-9_]{3,20}$";
    public static final String ID_CARD = "^(\\d{15}|\\d{18}|\\d{17}[Xx])$";
}

public class ValidationMessages {
    public static final String PHONE_INVALID = "手机号格式不正确";
    public static final String PASSWORD_WEAK = "密码强度不足";
}

6.3 容器校验

public class BatchCreateRequest {
    
    @NotEmpty(message = "至少需要一个订单")
    @Size(max = 100, message = "单次最多创建 100 个订单")
    @Valid  // 校验集合中每个元素
    private List<@Valid OrderCreateRequest> orders;
}

// Java 8+ 支持容器元素注解
public class ScoreMap {
    private Map<@NotBlank String, @Min(0) @Max(100) Integer> scores;
}

七、性能优化与工程实践

7.1 Validator 复用与缓存

Validator 实例是线程安全的,应作为单例复用:

@Configuration
public class ValidationConfig {
    
    @Bean
    public Validator validator() {
        return Validation.buildDefaultValidatorFactory().getValidator();
    }
}

@Service
public class OrderService {
    @Autowired
    private Validator validator;  // 复用单例
    
    public void batchValidate(List<OrderRequest> requests) {
        // 批量校验时,避免对每个对象重复构建约束元数据
        for (OrderRequest req : requests) {
            Set<ConstraintViolation<OrderRequest>> violations = validator.validate(req);
            // ...
        }
    }
}

7.2 快速失败模式

批量场景下,首个验证失败即返回,而非收集全部错误:

ValidatorFactory factory = Validation.byProvider(HibernateValidator.class)
    .configure()
    .failFast(true)  // 快速失败
    .buildValidatorFactory();

Validator fastValidator = factory.getValidator();

适用场景:

  • ✅ 快速失败:高并发接口、批量导入、实时交互
  • ❌ 完整失败:表单提交、配置导入(用户需要看到所有错误一次性修正)

7.3 Jakarta Bean Validation 演进

版本规范Java 包Spring Boot 版本关键特性
Bean Validation 1.0JSR-303javax.validation< 2.0基础注解、XML 配置
Bean Validation 2.0JSR-380javax.validation2.x@NotEmpty/@NotBlank、Optional 支持、自定义容器
Jakarta Validation 3.0—jakarta.validation3.x包迁移至 jakarta,无新特性
Jakarta Validation 3.1—jakarta.validation3.2+` specified in Jakarta EE 10

迁移注意:Spring Boot 3.x 全面迁移至 jakarta 命名空间,升级时需同步替换所有 javax.validation 和 javax.persistence 依赖。

7.4 防御性编程与验证的边界

校验注解是输入验证层的防线,不能替代业务逻辑中的防御性编程:

@Service
public class TransferService {
    
    public void transfer(@Valid TransferRequest req) {
        // 校验层已验证格式(金额>0、账户非空)
        
        // 但防御性编程仍需:
        Account from = accountRepo.findById(req.getFromAccount())
            .orElseThrow(() -> new BusinessException("账户不存在"));
        Account to = accountRepo.findById(req.getToAccount())
            .orElseThrow(() -> new BusinessException("目标账户不存在"));
            
        if (from.getBalance().compareTo(req.getAmount()) < 0) {
            throw new InsufficientBalanceException();
        }
        
        if (from.getStatus() != AccountStatus.ACTIVE) {
            throw new AccountFrozenException();
        }
        
        // 真正的业务逻辑...
    }
}

分层防御模型:

  • 格式校验(Bean Validation):非空、长度、正则、范围
  • 业务存在性校验(Service 层):ID 是否存在、状态是否允许
  • 业务规则校验(Domain 层):余额是否足够、权限是否匹配
  • 数据库约束(DAO 层):唯一索引、外键、Check 约束

八、测试策略

8.1 单元测试

@Test
public void shouldRejectInvalidEmail() {
    UserDto dto = new UserDto();
    dto.setEmail("invalid-email");
    
    Set<ConstraintViolation<UserDto>> violations = validator.validateProperty(dto, "email");
    
    assertThat(violations).hasSize(1);
    assertThat(violations.iterator().next().getMessage())
        .contains("邮箱格式不正确");
}

@Test
public void shouldPassValidUser() {
    UserDto dto = createValidUser();
    
    Set<ConstraintViolation<UserDto>> violations = validator.validate(dto);
    
    assertThat(violations).isEmpty();
}

8.2 集成测试(Spring Boot Test)

@SpringBootTest
@AutoConfigureMockMvc
public class UserControllerValidationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    public void shouldReturn400WhenEmailInvalid() throws Exception {
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"email\": \"bad\", \"age\": 10}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[?(@.field=='email')].message")
                .value("邮箱格式不正确"));
    }
}

8.3 参数化测试(JUnit 5)

@ParameterizedTest
@CsvSource({
    "'', 用户名不能为空",
    "ab, 用户名长度必须在 3-20 之间",
    "a@b, 用户名只能包含字母、数字和下划线",
    "valid_user123, ''"
})
public void shouldValidateUsername(String username, String expectedMessage) {
    UserDto dto = new UserDto();
    dto.setUsername(username);
    dto.setEmail("test@example.com");
    dto.setPassword("Password123");
    
    Set<ConstraintViolation<UserDto>> violations = validator.validateProperty(dto, "username");
    
    if (expectedMessage.isEmpty()) {
        assertThat(violations).isEmpty();
    } else {
        assertThat(violations).extracting(ConstraintViolation::getMessage)
            .contains(expectedMessage);
    }
}

七、总结

能力实现方式适用场景
基础校验内置注解通用格式规则
分组校验@Validated(Group.class)增删改查不同规则
级联校验@Valid嵌套对象校验
自定义约束@Constraint + Validator业务专属规则
关联校验类级别注解字段间逻辑关系
国际化MessageSource多语言应用
编程式校验validator.validate()复杂动态校验

数据校验是防御式编程的核心实践。在正确的层级应用恰当的校验策略,配合清晰的错误反馈,可显著提升 API 的健壮性和用户体验。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java-enterprise」更多文章

  1. JPMS 模块化:module-info 与 JLink 精简运行时
  2. CDC 数据同步:Debezium 与 Kafka 架构实战
  3. 可观测性工程:Micrometer 指标模型与 OTLP 导出