找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2033

积分

0

好友

285

主题
发表于 昨天 07:22 | 查看: 3| 回复: 0

一、注解校验概述

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 常用校验注解

常用Java校验注解及使用示例

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的核心区别与对比

@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使用决策流程图

选择决策树

是否需要分组校验?
├─ 是 → 使用 @Validated
└─ 否 → 是否在Controller中?
    ├─ 是 → 两者都可以,推荐 @Valid
    └─ 否 → 使用 @Validated

最佳实践

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

第一步:定义注解
一个自定义注解必须包含 messagegroupspayload 三个属性。

@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 跨字段校验

实现“密码”和“确认密码”必须一致的校验:

注解定义
注意,跨字段校验的@TargetElementType.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 设计原则

  1. 单一职责:每个注解只负责一个校验规则。
  2. 组合使用:多个简单注解组合成复杂规则(如@NotBlank + @Size)。
  3. 错误消息清晰:提供具体、可操作的错误提示,考虑支持国际化。
  4. 分组管理:使用校验组清晰区分创建、更新、删除等不同场景。
  5. 自定义注解:对于复杂的、特定于业务的校验逻辑(如身份证、手机号),优先创建自定义注解,提高代码可读性和复用性。

6.2 性能优化

  1. 避免过度校验:只校验从外部接口传入的必要数据,内部流转的数据可酌情减少校验。
  2. 校验顺序:在自定义校验器中,将开销小、易失败的校验逻辑放在前面。
  3. 缓存ValidatorValidator实例是线程安全的,可以注入复用,无需每次创建。
  4. 异步校验:对于涉及远程调用(如数据库唯一性校验)的复杂校验,考虑异步处理,避免阻塞主流程。

七、总结

本文系统地介绍了Java注解校验在Spring Boot项目中的核心概念和实战应用:

  1. @Valid vs @Validated:理解了标准JSR-380注解与Spring增强注解的区别与适用场景。
  2. 校验组:掌握了使用分组来精细化管理不同业务场景(增删改查)下的校验规则。
  3. 自定义注解:学会了如何通过定义注解和实现校验器,来创建符合复杂业务需求的校验规则,提升代码质量。
  4. 生产实践:熟悉了统一的异常处理、快速失败配置以及手动触发校验等高级用法,确保应用健壮性。

合理运用注解校验,能极大提升开发效率,保证代码质量,并构建出更健壮、更易维护的应用程序。如果你想了解更多Java实战技巧或与同行交流,欢迎访问云栈社区探索更多资源。




上一篇:NAS搭配UPS使用心得:如何判断电池更换时机?
下一篇:早期以太网技术揭秘:从共享总线、CSMA/CD到10BASE5黄粗线
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-16 00:35 , Processed in 0.240647 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表