Java注解校验实战

一、注解校验概述

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

最佳实践

  1. Controller层 :使用 @Valid(简洁、够用)
  2. Service层 :使用 @Validated(支持方法参数校验)
  3. 需要分组 :必须使用 @Validated
  4. 嵌套对象 :在嵌套对象字段上添加 @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 设计原则

  1. 单一职责:每个注解只负责一个校验规则
  2. 组合使用:多个简单注解组合成复杂规则
  3. 错误消息清晰:提供具体、可操作的错误提示
  4. 分组管理:使用校验组区分不同场景
  5. 自定义注解:复杂业务逻辑创建自定义注解

6.2 性能优化

  1. 避免过度校验:只校验必要的数据
  2. 校验顺序:将简单的校验放在前面
  3. 缓存Validator:Validator实例可以复用
  4. 异步校验:对于复杂校验,考虑异步处理

七、总结

本文系统地介绍了Java注解校验的核心概念和实践:

  1. @Valid vs @Validated:理解两者的区别和适用场景
  2. 校验组:使用分组管理不同场景的校验规则
  3. 自定义注解:创建符合业务需求的校验注解
  4. 生产实践:异常处理、国际化、性能优化
相关推荐
末央&20 小时前
【天机论坛】项目环境搭建和数据库设计
java·数据库
枫叶落雨22220 小时前
ShardingSphere 介绍
java
花花鱼20 小时前
Spring Security 与 Spring MVC
java·spring·mvc
言慢行善21 小时前
sqlserver模糊查询问题
java·数据库·sqlserver
专吃海绵宝宝菠萝屋的派大星21 小时前
使用Dify对接自己开发的mcp
java·服务器·前端
大数据新鸟1 天前
操作系统之虚拟内存
java·服务器·网络
Tong Z1 天前
常见的限流算法和实现原理
java·开发语言
凭君语未可1 天前
Java 中的实现类是什么
java·开发语言
He少年1 天前
【基础知识、Skill、Rules和MCP案例介绍】
java·前端·python
克里斯蒂亚诺更新1 天前
myeclipse的pojie
java·ide·myeclipse