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. 生产实践:异常处理、国际化、性能优化
相关推荐
心丑姑娘2 小时前
怎么理解ClickHouse的向量化执行
java·服务器·clickhouse
寻星探路2 小时前
【算法进阶】滑动窗口与前缀和:从“和为 K”到“最小覆盖子串”的极限挑战
java·开发语言·c++·人工智能·python·算法·ai
阿蒙Amon2 小时前
C#每日面试题-简述C#构造函数和析构函数
java·开发语言·c#
musenh2 小时前
spring学习1
java·学习·spring
专注于大数据技术栈2 小时前
java学习--Vector
java·学习
sheji34162 小时前
【开题答辩全过程】以 基于Java的校内美食推荐系统的设计与实现为例,包含答辩的问题和答案
java·开发语言·美食
白典典2 小时前
解决iTextPDF生成手册时目录页码与实际页码不匹配问题
java·spring·intellij-idea
静心观复2 小时前
foreach中使用remove踩坑
java
内存不泄露2 小时前
基于 Spring Boot 的医院预约挂号系统(全端协同)设计与实现
java·vue.js·spring boot·python·flask