一、注解校验概述
1.1 为什么需要注解校验?
在实际开发中,我们经常需要对输入数据进行校验:
java// 传统方式:代码冗长、难以维护 public void createUser(String username, String email, Integer age) { if (username == null || username.length() < 3 || username.length() > 20) { throw new IllegalArgumentException("用户名长度必须在3-20之间"); } if (email == null || !email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { throw new IllegalArgumentException("邮箱格式不正确"); } if (age == null || age < 18 || age > 120) { throw new IllegalArgumentException("年龄必须在18-120之间"); } //... } // ✅ 注解校验:简洁、声明式、可复用 public void createUser(@Valid UserDTO userDTO) { //... }
注解校验的优势:
- ✅声明式:通过注解声明校验规则,代码更简洁
- ✅可复用:校验逻辑可以复用,避免重复代码
- ✅易维护:校验规则集中管理,易于维护
- ✅标准化:遵循JSR-303/JSR-380标准
- ✅国际化:支持国际化错误消息
1.2 常用校验注解
Jakarta Bean Validation提供的注解:
注解 | 说明 | 示例 |
@NotNull | 值不能为null | @NotNull String name |
@NotEmpty | 集合、字符串、数组不能为空 | @NotEmpty List<String> items |
@NotBlank | 字符串不能为空白(去除首尾空格后长度>0) | @NotBlank String content |
@Size(min, max) | 大小必须在指定范围内 | @Size(min=3, max=20) String name |
@Min(value) | 数值必须大于等于指定值 | @Min(18) Integer age |
@Max(value) | 数值必须小于等于指定值 | @Max(120) Integer age |
必须是有效的邮箱格式 | @Email String email | |
@Pattern(regexp) | 必须匹配指定的正则表达式 | @Pattern(regexp="^1[3-9]\\d{9}$") String phone |
@Past | 日期必须是过去的时间 | @Past Date birthDate |
@Future | 日期必须是未来的时间 | @Future Date appointmentDate |
@AssertTrue | 布尔值必须是true | @AssertTrue Boolean agreed |
@Negative | 数值必须是负数 | @Negative Integer balance |
@Positive | 数值必须是正数 | @Positive Integer amount |
二、@Valid vs @Validated
2.1 核心区别
这两个注解虽然功能相似,但有关键区别:
特性 | @Valid | @Validated |
来源 | Jakarta Bean Validation (JSR-380) | Spring Framework |
位置 | 方法、字段、构造器参数 | 方法、类型、参数 |
嵌套校验 | ✅ 支持 | ✅ 支持 |
分组校验 | ❌ 不支持 | ✅ 支持 |
校验组序列 | ❌ 不支持 | ✅ 支持 |
Spring集成 | 需要配置 | 原生支持 |
2.2 @Valid的使用
基本用法:
java@Data public class UserDTO { @NotNull(message = "用户ID不能为空") private Long id; @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间") private String username; @Email(message = "邮箱格式不正确") @NotBlank(message = "邮箱不能为空") private String email; @Min(value = 18, message = "年龄必须大于等于18岁") @Max(value = 120, message = "年龄必须小于等于120岁") private Integer age; }在Controller中使用:
java@RestController @RequestMapping("/api/users") public class UserController { // 使用@Valid触发校验 @PostMapping public ResponseEntity<String> createUser(@Valid @RequestBody UserDTO userDTO) { // 如果校验失败,会自动抛出MethodArgumentNotValidException return ResponseEntity.ok("用户创建成功"); } }
嵌套校验:
java@Data public class OrderDTO { @NotNull(message = "订单ID不能为空") private Long orderId; @Valid // 关键:必须使用@Valid才能触发嵌套对象的校验 @NotNull(message = "用户信息不能为空") private UserDTO user; @Valid @NotEmpty(message = "订单项不能为空") private List<OrderItemDTO> items; } @Data public class OrderItemDTO { @NotNull(message = "商品ID不能为空") private Long productId; @Min(value = 1, message = "数量必须大于0") private Integer quantity; }
2.3 @Validated的使用
基本用法:
java@Service @Validated // 类级别添加@Validated,启用方法参数校验 public class UserService { // 简单参数校验 public void updateUser( @NotNull(message = "用户ID不能为空") Long id, @NotBlank(message = "用户名不能为空") String username) { // 业务逻辑... } // 对象校验 public void createUser(@Valid UserDTO userDTO) { // 业务逻辑... } }
分组校验(@Validated独有):
javapublic interface CreateGroup {} public interface UpdateGroup {} @Data public class UserDTO { @Null(groups = CreateGroup.class, message = "创建时ID必须为空") @NotNull(groups = UpdateGroup.class, message = "更新时ID不能为空") private Long id; @NotBlank(groups = {CreateGroup.class, UpdateGroup.class}) private String username; } @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public ResponseEntity<String> create( @Validated(CreateGroup.class) @RequestBody UserDTO userDTO) { return ResponseEntity.ok("创建成功"); } @PutMapping public ResponseEntity<String> update( @Validated(UpdateGroup.class) @RequestBody UserDTO userDTO) { return ResponseEntity.ok("更新成功"); } }
2.4 选择建议
选择决策树:
less是否需要分组校验? ├─ 是 → 使用 @Validated └─ 否 → 是否在Controller中? ├─ 是 → 两者都可以,推荐 @Valid └─ 否 → 使用 @Validated
最佳实践:
- Controller层:使用 @Valid(简洁、够用)
- Service层:使用 @Validated(支持方法参数校验)
- 需要分组:必须使用 @Validated
- 嵌套对象:在嵌套对象字段上添加 @Valid
三、校验组(Validation Groups)
3.1 为什么需要校验组?
不同场景下,同一对象的校验规则可能不同:
java体验AI代码助手
代码解读
复制代码
// 场景1:新增用户 // - id为空(由数据库生成) // - username必填 // - password必填 // 场景2:更新用户 // - id必填(根据id更新) // - username可选 // - password可选(不修改则不传)
3.2 定义校验组
java/** * 校验组定义 */ public interface ValidationGroups { // 新增操作 interface Create {} // 更新操作 interface Update {} // 删除操作 interface Delete {} // 默认组(不指定group时使用) interface Default {} }
3.3 在实体类中使用分组
java@Data public class UserDTO { // 创建时ID必须为空,更新时ID不能为空 @Null(groups = ValidationGroups.Create.class, message = "创建用户时ID必须为空") @NotNull(groups = {ValidationGroups.Update.class, ValidationGroups.Delete.class}, message = "更新/删除用户时ID不能为空") private Long id; @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "用户名不能为空") @Size(min = 3, max = 20, groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "用户名长度必须在3-20之间") private String username; @Email(groups = ValidationGroups.Create.class, message = "邮箱格式不正确") @NotBlank(groups = ValidationGroups.Create.class, message = "邮箱不能为空") private String email; // 创建时密码必填,更新时可选 @NotBlank(groups = ValidationGroups.Create.class, message = "密码不能为空") @Size(min = 6, max = 20, groups = ValidationGroups.Create.class, message = "密码长度必须在6-20之间") private String password; @NotNull(groups = ValidationGroups.Create.class, message = "年龄不能为空") @Min(value = 18, groups = ValidationGroups.Create.class, message = "年龄必须大于等于18岁") private Integer age; }
3.4 使用校验组
java@RestController @RequestMapping("/api/users") public class UserController { @PostMapping public ResponseEntity<?> create( @Validated(ValidationGroups.Create.class) @RequestBody UserDTO userDTO) { // 只校验Create组中定义的规则 return ResponseEntity.ok("创建成功"); } @PutMapping("/{id}") public ResponseEntity<?> update( @PathVariable Long id, @Validated(ValidationGroups.Update.class) @RequestBody UserDTO userDTO) { // 只校验Update组中定义的规则 return ResponseEntity.ok("更新成功"); } @DeleteMapping("/{id}") public ResponseEntity<?> delete( @PathVariable Long id, @Validated(ValidationGroups.Delete.class) @RequestBody UserDTO userDTO) { // 只校验Delete组中定义的规则 return ResponseEntity.ok("删除成功"); } }
3.5 组序列(Group Sequence)
控制校验组的执行顺序,默认按照定义的顺序依次校验:
java@GroupSequence({CreateGroup.class, UpdateGroup.class, Default.class}) public interface OrderedGroup { } @RestController public class UserController { @PostMapping public ResponseEntity<?> create( @Validated(OrderedGroup.class) @RequestBody UserDTO userDTO) { return ResponseEntity.ok("创建成功"); } }
注意:一旦某个组校验失败,后续组不会再执行。
四、自定义校验注解
4.1 自定义注解的应用场景
当内置注解无法满足需求时,可以创建自定义校验注解:
- 手机号校验:@PhoneNumber
- 身份证号校验:@IdCard
- 枚举值校验:@EnumValue
- 字段互斥:@FieldMatch
- 密码强度:@StrongPassword
4.2 实现手机号校验注解
第一步:定义注解
java@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PhoneNumberValidator.class) @Documented public @interface PhoneNumber { // 必须的三个属性 String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; // 自定义属性:是否支持国际化号码 boolean international() default false; // 自定义属性:支持的国家代码 String[] countryCodes() default {"+86"}; }
第二步:实现校验器
javapublic class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> { private boolean international; private String[] countryCodes; // 中国大陆手机号正则 private static final String CHINA_PHONE_PATTERN = "^1[3-9]\\d{9}$"; @Override public void initialize(PhoneNumber constraintAnnotation) { this.international = constraintAnnotation.international(); this.countryCodes = constraintAnnotation.countryCodes(); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { // null值由@NotNull处理 if (value == null) { return true; } // 国际号码校验 if (international) { return validateInternational(value); } // 中国手机号校验 return value.matches(CHINA_PHONE_PATTERN); } private boolean validateInternational(String phone) { // 简单的国际号码校验逻辑 for (String code : countryCodes) { if (phone.startsWith(code)) { String number = phone.substring(code.length()); return number.matches("^\\d{6,15}$"); } } return false; } }
第三步:使用注解
java@Data public class UserDTO { @PhoneNumber(message = "手机号格式不正确") private String mobile; @PhoneNumber(international = true, countryCodes = {"+86", "+1", "+44"}, message = "国际手机号格式不正确") private String internationalPhone; }
4.3 实现密码强度校验
注解定义:
java@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = StrongPasswordValidator.class) @Documented public @interface StrongPassword { String message() default "密码强度不足"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; // 最小长度 int minLength() default 8; // 是否需要大写字母 boolean requireUppercase() default true; // 是否需要小写字母 boolean requireLowercase() default true; // 是否需要数字 boolean requireDigit() default true; // 是否需要特殊字符 boolean requireSpecialChar() default true; }
校验器实现:
javapublic class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> { private int minLength; private boolean requireUppercase; private boolean requireLowercase; private boolean requireDigit; private boolean requireSpecialChar; private static final Pattern UPPERCASE_PATTERN = Pattern.compile("[A-Z]"); private static final Pattern LOWERCASE_PATTERN = Pattern.compile("[a-z]"); private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d"); private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]"); @Override public void initialize(StrongPassword constraintAnnotation) { this.minLength = constraintAnnotation.minLength(); this.requireUppercase = constraintAnnotation.requireUppercase(); this.requireLowercase = constraintAnnotation.requireLowercase(); this.requireDigit = constraintAnnotation.requireDigit(); this.requireSpecialChar = constraintAnnotation.requireSpecialChar(); } @Override public boolean isValid(String password, ConstraintValidatorContext context) { if (password == null) { return true; } if (password.length() < minLength) { return false; } if (requireUppercase && !UPPERCASE_PATTERN.matcher(password).find()) { return false; } if (requireLowercase && !LOWERCASE_PATTERN.matcher(password).find()) { return false; } if (requireDigit && !DIGIT_PATTERN.matcher(password).find()) { return false; } if (requireSpecialChar && !SPECIAL_CHAR_PATTERN.matcher(password).find()) { return false; } return true; } }
4.4 跨字段校验
实现"密码"和"确认密码"必须一致的校验:
注解定义:
java@Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = FieldMatchValidator.class) @Documented public @interface FieldMatch { String message() default "字段值不匹配"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; // 第一个字段名 String first(); // 第二个字段名 String second(); }
校验器实现:
javapublic class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> { private String firstFieldName; private String secondFieldName; @Override public void initialize(FieldMatch constraintAnnotation) { this.firstFieldName = constraintAnnotation.first(); this.secondFieldName = constraintAnnotation.second(); } @Override public boolean isValid(Object value, ConstraintValidatorContext context) { if (value == null) { return true; } try { Field firstField = value.getClass().getDeclaredField(firstFieldName); firstField.setAccessible(true); Object firstValue = firstField.get(value); Field secondField = value.getClass().getDeclaredField(secondFieldName); secondField.setAccessible(true); Object secondValue = secondField.get(value); return Objects.equals(firstValue, secondValue); } catch (Exception e) { return false; } } }
使用示例:
java@Data @FieldMatch(first = "password", second = "confirmPassword", message = "两次输入的密码不一致") public class RegisterRequest { private String username; private String password; private String confirmPassword; }
五、生产环境实战
5.1 统一异常处理
在生产环境中,需要统一处理校验异常:
java@RestControllerAdvice public class GlobalExceptionHandler { /** * 处理 @Valid 触发的校验异常 */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationException( MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList()); ErrorResponse response = ErrorResponse.builder() .code(400) .message("参数校验失败") .errors(errors) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.badRequest().body(response); } /** * 处理 @Validated 触发的校验异常 */ @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<ErrorResponse> handleConstraintViolation( ConstraintViolationException ex) { List<String> errors = ex.getConstraintViolations() .stream() .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) .collect(Collectors.toList()); ErrorResponse response = ErrorResponse.builder() .code(400) .message("参数校验失败") .errors(errors) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.badRequest().body(response); } /** * 处理请求参数绑定异常 */ @ExceptionHandler(BindException.class) public ResponseEntity<ErrorResponse> handleBindException(BindException ex) { List<String> errors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList()); ErrorResponse response = ErrorResponse.builder() .code(400) .message("参数绑定失败") .errors(errors) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.badRequest().body(response); } } @Data @Builder class ErrorResponse { private Integer code; private String message; private List<String> errors; private LocalDateTime timestamp; }
5.2 快速失败机制
默认情况下,Bean Validation会校验所有约束并返回所有错误。如果需要在第一个错误时就停止:
java@Configuration public class ValidationConfig { @Bean public Validator validator() { ValidatorFactory factory = Validation.byDefaultProvider() .configure() .failFast(true) // 启用快速失败 .buildValidatorFactory(); return factory.getValidator(); } @Bean public MethodValidationPostProcessor methodValidationPostProcessor() { MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); processor.setValidator(validator()); return processor; } }
5.4 手动触发校验
在Service层手动触发校验:
java@Service @RequiredArgsConstructor public class UserService { private final Validator validator; public void createUser(UserDTO userDTO) { // 手动校验 Set<ConstraintViolation<UserDTO>> violations = validator.validate(userDTO, Default.class); if (!violations.isEmpty()) { throw new ConstraintViolationException(violations); } // 业务逻辑... } }
六、最佳实践
6.1 设计原则
- 单一职责:每个注解只负责一个校验规则
- 组合使用:多个简单注解组合成复杂规则
- 错误消息清晰:提供具体、可操作的错误提示
- 分组管理:使用校验组区分不同场景
- 自定义注解:复杂业务逻辑创建自定义注解
6.2 性能优化
- 避免过度校验:只校验必要的数据
- 校验顺序:将简单的校验放在前面
- 缓存Validator:Validator实例可以复用
- 异步校验:对于复杂校验,考虑异步处理
七、总结
本文系统地介绍了Java注解校验的核心概念和实践:
- @Valid vs @Validated:理解两者的区别和适用场景
- 校验组:使用分组管理不同场景的校验规则
- 自定义注解:创建符合业务需求的校验注解
- 生产实践:异常处理、国际化、性能优化