一、注解校验概述
1.1 为什么需要注解校验?
在实际开发中,我们经常需要对输入数据进行校验。你是否也曾写过冗长而难以维护的校验代码?
// 传统方式:代码冗长、难以维护
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) {
//...
}
相比之下,基于Java的注解校验方案优势明显:
- 声明式:通过注解声明校验规则,代码更简洁。
- 可复用:校验逻辑可以复用,避免重复代码。
- 易维护:校验规则集中管理,易于维护。
- 标准化:遵循JSR-303/JSR-380标准。
- 国际化:支持国际化错误消息。
1.2 常用校验注解

Jakarta Bean Validation(前身为 Hibernate Validator)提供了一系列开箱即用的注解:
| 注解 |
说明 |
示例 |
@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这两个注解功能相似,但在Spring框架中使用时,存在一些关键区别:
| 特性 |
@Valid |
@Validated |
| 来源 |
Jakarta Bean Validation (JSR-380) |
Spring Framework |
| 位置 |
方法、字段、构造器参数 |
方法、类型、参数 |
| 嵌套校验 |
✅ 支持 |
✅ 支持 |
| 分组校验 |
❌ 不支持 |
✅ 支持 |
| 校验组序列 |
❌ 不支持 |
✅ 支持 |
| Spring集成 |
需要配置 |
原生支持 |
2.2 @Valid的使用
基本用法:在DTO类上声明注解。
@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中使用:在接收参数的@RequestBody前添加@Valid。
@RestController
@RequestMapping("/api/users")
public class UserController {
// 使用@Valid触发校验
@PostMapping
public ResponseEntity<String> createUser(@Valid @RequestBody UserDTO userDTO) {
// 如果校验失败,会自动抛出MethodArgumentNotValidException
return ResponseEntity.ok("用户创建成功");
}
}
嵌套校验:对于包含其他对象的字段,需要显式添加@Valid。
@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的使用
基本用法:在Service类上添加@Validated,可校验方法参数。
@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独有):允许同一实体在不同场景下应用不同的校验规则。
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 选择建议

选择决策树:
是否需要分组校验?
├─ 是 → 使用 @Validated
└─ 否 → 是否在Controller中?
├─ 是 → 两者都可以,推荐 @Valid
└─ 否 → 使用 @Validated
最佳实践:
- Controller层:使用
@Valid(简洁、够用)。
- Service层:使用
@Validated(支持方法参数校验)。
- 需要分组:必须使用
@Validated。
- 嵌套对象:在嵌套对象字段上添加
@Valid。
三、校验组(Validation Groups)
3.1 为什么需要校验组?
不同场景下,同一对象的校验规则可能不同。例如:
// 场景1:新增用户
// - id为空(由数据库生成)
// - username必填
// - password必填
// 场景2:更新用户
// - id必填(根据id更新)
// - username可选
// - password可选(不修改则不传)
3.2 定义校验组

校验组通常定义为空接口。
/**
* 校验组定义
*/
public interface ValidationGroups {
// 新增操作
interface Create {}
// 更新操作
interface Update {}
// 删除操作
interface Delete {}
// 默认组(不指定group时使用)
interface Default {}
}
3.3 在实体类中使用分组
@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 使用校验组
在Controller方法参数上,通过@Validated指定要激活的校验组。
@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)
控制校验组的执行顺序,默认按照定义的顺序依次校验。
@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 实现手机号校验注解
第一步:定义注解
一个自定义注解必须包含 message,groups 和 payload 三个属性。
@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"};
}
第二步:实现校验器
校验器需要实现ConstraintValidator接口,并指定其校验的注解和数据类型。
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;
}
}
第三步:使用注解
@Data
public class UserDTO {
@PhoneNumber(message = "手机号格式不正确")
private String mobile;
@PhoneNumber(international = true,
countryCodes = {"+86", "+1", "+44"},
message = “国际手机号格式不正确”)
private String internationalPhone;
}
4.3 实现密码强度校验
注解定义:
@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;
}
校验器实现:
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 跨字段校验
实现“密码”和“确认密码”必须一致的校验:
注解定义:
注意,跨字段校验的@Target是ElementType.TYPE。
@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();
}
校验器实现:
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;
}
}
}
使用示例:
@Data
@FieldMatch(first = “password”, second = “confirmPassword”,
message = “两次输入的密码不一致”)
public class RegisterRequest {
private String username;
private String password;
private String confirmPassword;
}
五、生产环境实战
5.1 统一异常处理
在生产环境中,必须统一处理校验异常,为前端返回结构化的错误信息。这直接关系到后端与架构层面的用户体验和接口规范。

@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理 @Valid 触发的校验异常 (用于RequestBody)
*/
@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);
}
/**
* 处理请求参数绑定异常 (用于RequestParam等)
*/
@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会校验所有约束并返回所有错误。如果需要在第一个错误时就停止校验以提高性能(尤其在测试或某些场景下),可以配置快速失败。
@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.3 手动触发校验
除了依赖框架自动拦截,你还可以在Service层、工具类或任何需要的地方手动触发校验,这在某些软件测试场景或复杂业务逻辑中非常有用。
@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 设计原则
- 单一职责:每个注解只负责一个校验规则。
- 组合使用:多个简单注解组合成复杂规则(如
@NotBlank + @Size)。
- 错误消息清晰:提供具体、可操作的错误提示,考虑支持国际化。
- 分组管理:使用校验组清晰区分创建、更新、删除等不同场景。
- 自定义注解:对于复杂的、特定于业务的校验逻辑(如身份证、手机号),优先创建自定义注解,提高代码可读性和复用性。
6.2 性能优化
- 避免过度校验:只校验从外部接口传入的必要数据,内部流转的数据可酌情减少校验。
- 校验顺序:在自定义校验器中,将开销小、易失败的校验逻辑放在前面。
- 缓存Validator:
Validator实例是线程安全的,可以注入复用,无需每次创建。
- 异步校验:对于涉及远程调用(如数据库唯一性校验)的复杂校验,考虑异步处理,避免阻塞主流程。
七、总结
本文系统地介绍了Java注解校验在Spring Boot项目中的核心概念和实战应用:
@Valid vs @Validated:理解了标准JSR-380注解与Spring增强注解的区别与适用场景。
- 校验组:掌握了使用分组来精细化管理不同业务场景(增删改查)下的校验规则。
- 自定义注解:学会了如何通过定义注解和实现校验器,来创建符合复杂业务需求的校验规则,提升代码质量。
- 生产实践:熟悉了统一的异常处理、快速失败配置以及手动触发校验等高级用法,确保应用健壮性。
合理运用注解校验,能极大提升开发效率,保证代码质量,并构建出更健壮、更易维护的应用程序。如果你想了解更多Java实战技巧或与同行交流,欢迎访问云栈社区探索更多资源。