一、注解校验概述
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 |
必须是有效的邮箱格式 | @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独有):
java
public 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
// 场景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"};
}
第二步:实现校验器
java
public 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;
}
校验器实现:
java
public 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();
}
校验器实现:
java
public 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:理解两者的区别和适用场景
- 校验组:使用分组管理不同场景的校验规则
- 自定义注解:创建符合业务需求的校验注解
- 生产实践:异常处理、国际化、性能优化