新来的一个同事,把SpringBoot参数校验玩的那叫一个优雅

关注公众号【码农Academy】,每日技术干货马上就来!

contact.jpg

介绍

在开发现代应用程序时,数据验证是确保用户输入的正确性和应用程序数据完整性的关键方面。Spring Boot 提供了强大的数据验证机制,使开发者能够轻松地执行验证操作。本文将深入介绍 Spring Boot 中的 Validation,以及如何在应用程序中正确使用它。

为什么使用数据验证?

  • 1.用户输入的正确性:数据验证是确保用户输入的正确性的一种重要手段。通过验证用户输入的数据,可以防止无效或错误的数据进入应用程序,提高数据的质量。例如:系统中的备注字段数据库中对应的长度是256,如果用户输入的备注超过这个长度值,那么就会导致mysql报Data too long

    1. 数据完整性: 数据完整性是指数据在存储和传输过程中的准确性和一致性。数据验证有助于确保数据满足特定的格式、长度、范围等要求,从而提高数据的完整性。
  1. 安全性: 数据验证也是保障应用程序安全性的关键因素。通过验证用户输入,可以防范一些潜在的安全威胁,例如 SQL 注入、跨站脚本攻击等。

  2. 业务规则的执行: 在应用程序中,通常存在一些业务规则,例如某个字段不能为空、日期范围必须在某个特定范围内等。通过数据验证,可以确保这些业务规则在应用程序中得到正确执行。

手动数据校验的痛点

日常开发中,有些写项目可能没有采用Spring Validator,采用的是在代码中手动校验数据。但是手动校验数据会带来代码冗余、错误处理的一致性以及业务规则的维护的一些痛点。

  • 代码冗余的手动校验逻辑,导致代码中大量的if-else
public ResponseEntity<String> registerUser(UserRegistrationRequest request) {
    if (request == null) {
        return ResponseEntity.badRequest().body("Request cannot be null");
    }

    if (StringUtils.isBlank(request.getUsername())) {
        return ResponseEntity.badRequest().body("Username cannot be blank");
    }

    if (StringUtils.length(request.getPassword()) < 6) {
        return ResponseEntity.badRequest().body("Password must be at least 6 characters long");
    }

    // 处理用户注册逻辑
    return ResponseEntity.ok("User registered successfully");
}
  • 缺乏统一的错误处理机制

  • 业务规则维护的困难
    随着业务规则的增加,手动编写的校验逻辑可能变得庞大且难以维护。修改和扩展校验规则可能需要修改多个地方,增加了维护成本。

  • 缺乏验证组的支持
    手动校验通常不支持验证组的概念,难以根据不同场景执行不同的验证规则。

  • 不易于集成前端验证
    手动校验不易与前端验证框架集成,导致前后端验证逻辑可能不一致。

通过引入 Spring Validator,我们能够有效解决这些痛点,提高代码的可读性、可维护性,并确保校验逻辑的一致性。

Spring Boot 中的 Validation 概述

因Springboot的spring-boot-starter-web默认内置了Hibernate-Validator(Spring boot 2.3以前版本),虽然Hibernate-Validator也能做到数据校验,但是考虑到spring-boot-starter-validation 是一个抽象层,使得验证框架的具体实现变得可插拔。这意味着,除了 Hibernate Validator,开发者可以选择其他符合 Bean Validation 规范的实现。所以我们可以手动引入spring-boot-starter-validation实现数据验证。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>  
    <groupId>org.springframework.boot</groupId>  
    <artifactId>spring-boot-starter-web</artifactId>  
</dependency>

spring-boot-starter-validation 不仅支持 JSR-303(Bean Validation 1.0)规范,还提供了对 JSR-380(Bean Validation 2.0)规范的全面支持。这使得开发者可以利用 Bean Validation 2.0 的新特性,更灵活地定义验证规则,包括对集合、嵌套对象的验证等。

通过在实体类的字段上使用标准的 Bean Validation 注解(如 @NotBlank@Size@Email 等),我们能够直观地定义数据的验证规则。这些验证规则会在应用程序的不同层次(如控制器层)生效,确保输入数据的正确性。

基本用法

Spring Boot Validation 提供了一系列注解,用于在实体类中定义验证规则。以下是一些常用的校验相关的注解及其功能以及用法:
1.@NotNull 校验元素值不能为 null。如果元素为null,则验证失败。通常用于字段级别的验证。

@NotNull(message = "Name cannot be null")
private String name;

2.@NotBlank 校验字符串元素值不能为 null 或空字符串。必须包含至少一个非空格字符(即执行trim()之后不为’’)。如果元素为null或者‘‘,则验证失败。通常用于String类型的字段校验。

@NotBlank(message = "Username cannot be blank")
private String username;

3.NotEmpty 校验集合元素或数组元素或者字符串是否非空。通常作用于集合字段或数组字段,此时需要集合或者数字的元素个数大于0。也可以作用于字符串,此时校验字符串不能为null或空串(可以是一个空格)。注意与@NotBlank的使用区别。

@NotEmpty(message = "List cannot be empty")
private List<String> items;

4.@Length 校验字符串元素的长度。作用于字符串。注:Hibernate-Validator中注解,等同于spring-boot-starter-validation中的@Size

@Length(min = 5, max = 20, message = "Length must be between 5 and 20 characters")
private String username;

5.@Size 校验集合元素个数或字符串的长度在指定范围内。在集合或字符串字段上添加 @Size 注解。

@Size(min = 1, max = 10, message = "Number of items must be between 1 and 10")
private List<String> items;

@Size(min = 5, max = 20, message = "Length must be between 5 and 20 characters")
private String username;

6.@Min 校验数字元素的最小值。

@Min(value = 18, message = "Age must be at least 18")
private int age;

7.@Max 校验数字元素的最大值。

@Max(value = 100, message = "Age must not exceed 100")
private int age;

9.@DecimalMax 作用于BigDecimal类型字段, 校验字段的最大值,支持比较的值为字符串表示的十进制数。通常搭配它的inclusive()使用,区别边界问题。value 属性表示最大值,inclusive 属性表示是否包含最大值。

@DecimalMax(value = "100.00", inclusive = true, message = "Value must be less than or equal to 100.00")
private BigDecimal amount;

10.@DecimalMin 作用于BigDecimal类型字段, 校验字段的最小值,支持比较的值为字符串表示的十进制数。通常搭配它的inclusive()使用,区别边界问题。value 属性表示最小值,inclusive 属性表示是否包含最小值。

@DecimalMin(value = "0.00", inclusive = false, message = "Value must be greater than 0.00")
private BigDecimal amount;

11.@Email 校验字符串元素是否为有效的电子邮件地址。可以通过regexp自定义邮箱匹配正则。

@Email(message = "Invalid email address")
private String email;

12.@Pattern 根据正则表达式校验字符串元素的格式。

@Pattern(regexp = "[a-zA-Z0-9]+", message = "Only alphanumeric characters are allowed")
private String username;

13.@Digits 校验数字元素的整数部分和小数部分的位数。作用于BigDecimalBigInteger,字符串,以及byte, short,int, long以及它们的包装类型。

@Digits(integer = 5, fraction = 2, message = "Number must have up to 5 integer digits and 2 fraction digits")
private BigDecimal amount;

14.@Past 校验日期或时间元素是否在当前时间之前。即是否是过去时间。作用于Date相关类型的字段。

@Past(message = "Date must be in the past")
private LocalDate startDate;

15.@Future 校验日期或时间元素是否在当前时间之后。即是否是未来时间。作用于Date相关类型的字段。

@Future(message = "Date must be in the future")
private LocalDate endDate;

注:以上只罗列部分注解以及它们的功能,其余他们的字段属性并没有详细说明,其他注解以及详细的说明需要去看源码。

用法示例

1.定义接口入参请求参数
/**  
* @version 1.0  
* @description: <p></p >  
* @author: 码农Academy  
* @create: 2024/1/8 16:46  
*/  
@Data  
public class UserCreateRequestVO {  

    @NotBlank(message = "请输入用户名")  
    @Size(max = 128, message = "用户名长度最大为128个字符")  
    private String userName;  

    @Email(message = "请填写正确的邮箱地址")  
    private String email;  

    @Min(value = 18, message = "用户年龄必须大于18岁")  
    @Max(value = 60, message = "用户年龄必须小于60岁")  
    private Integer age;  

    @NotEmpty(message = "请输入你的兴趣爱好")  
    @Size(max = 5, message = "兴趣爱好最多可以输入5个")  
    private List<String> hobbies;  

    @DecimalMin(value = "50", inclusive = false, message = "体重必须大于50KG")  
    private BigDecimal weight;  

    @Validated
    @NotNull(message = "请输入地址信息")  
    private UserAddressRequestVO address;  
}
2.定义请求接口
@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    /**  
    * 创建用户  
    * @param requestVO  
    * @return  
    */  
    @PostMapping("create")  
    public ResultResponse<Void> createUser(@Validated @RequestBody UserCreateRequestVO requestVO){  
    return ResultResponse.success(null);  
    }  

    /**  
    * 校验用户邮箱是否合法  
    * @param email  
    * @return  
    */  
    @GetMapping("email")  
    public ResultResponse<Void> validUserEmail(@Email(message = "邮箱格式不正确") String email){  
    return ResultResponse.success(null);  
    }
}
3.测试
  • 创建用户校验,Json请求体校验
    image.png

我们需要捕获一下MethodArgumentNotValidException。该部分内容请参考文章:SpringBoot统一异常处理

  • 校验邮箱,单参数校验
    image.png

注:单参数校验时我们需要,在方法的类上加上@Validated注解,否则校验不生效。

嵌套对象的校验

UserCreateRequestVO中增加一个address的校验,即需要对嵌套对象进行校验

/**  
* @version 1.0  
* @description: <p></p >  
* @author: 码农Academy  
* @create: 2024/1/8 19:45  
*/  
@Data  
public class UserAddressRequestVO {  
    @Size(max = 16, message = "地址信息中国家长度不能超过16个字符")  
    @NotBlank(message = "地址信息国家不能为空")  
    private String country;  

    private String city;  

    @Size(max = 128, message = "详细地址长度不能超过128个字符")  
    private String address1;  
}

UserAddressRequestVO中增加address属性

@Data  
public class UserCreateRequestVO {
    @NotNull(message = "请输入地址信息")  
    private UserAddressRequestVO address;
}

解决办法,要在嵌套对象上使用 @Valid 注解

@Data  
public class UserCreateRequestVO {

    @NotNull(message = "请输入地址信息")  
    @Valid  
    private UserAddressRequestVO address;
}

image.png

试了一些其他的方式,好像都不行,有知道其他方式的,欢迎评论区留言探讨

自定义验证注解

在项目开发中,我们也可以自定义注解去完成我们的字段校验,比如某些枚举值的传递,需要校验枚举值是否合法。在创建自定义注解之前,我们需要了解一下ConstraintValidator以及实现自定义验证注解的原理

1.ConstraintValidator 接口

ConstraintValidator 是 Java Bean Validation (JSR 380) 规范中用于自定义验证逻辑的接口。它允许你定义针对特定自定义注解的验证规则。它是一个泛型接口,需要提供两个类型参数:

  • A:是你的自定义注解的类型。
  • T:是被验证的元素类型,通常是字段类型。
public interface ConstraintValidator<A extends Annotation, T> {

    void initialize(A constraintAnnotation);

    boolean isValid(T value, ConstraintValidatorContext context);
}

其中:

  • initialize 方法:在验证器初始化时被调用,可以用于获取约束注解中的配置信息。
  • isValid 方法:执行实际的验证逻辑,返回 true 表示验证通过,false 表示验证失败。

以下为枚举校验注解的校验规则实现

/**  
* @version 1.0  
* <p> </p>  
* @author: 码农Academy  
* @create: 2024/01/09 3:11 下午  
*/  
public class EnumValidator implements ConstraintValidator<EnumValid, Object> {  

    private Class clazz;  

    private String validField;  

    @Override  
    public void initialize(EnumValid constraintAnnotation) {  
        clazz = constraintAnnotation.enumClass();  
        validField = constraintAnnotation.field();  
    }  

    @SneakyThrows  
    @Override  
        public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {  
        if (object == null || "".equals(object)){  
            return true;  
        }  

        if (!clazz.isEnum()){  
            return false;  
        }  

        Class<Enum> enumClass = (Class<Enum>)clazz;  
        //获取所有枚举实例  
        Enum[] enumConstants = enumClass.getEnumConstants();  

        // 需要比对的字段  
        Field field = enumClass.getDeclaredField(validField);  
        field.setAccessible(true);  

        for(Enum constant : enumConstants){  
            // 取值final修饰  
            Object validValue = field.get(constant);  
            if (validValue == null){  
                Method method = enumClass.getMethod(validField);  
                validValue = method.invoke(constant);  
            }  

            if(validValue instanceof Number) {  
                validValue = ((Number)validValue).intValue();  
                object = ((Number) object).intValue();  
            }  
            if (Objects.equals(validValue,object)){  
                return true;  
            }  
        }  
        return false;  
    }  
}

2.创建自定义注解

在 Java Bean Validation 中,约束注解(Constraint Annotation)是通过元注解 @Constraint 来定义的。这个注解包含了以下关键元素:

  • validatedBy: 指定用于执行验证的 ConstraintValidator 实现类。

以校验枚举值的合法行为例,我们创建一个EnumValid约束注解

@Constraint(validatedBy = {EnumValidator.class})  
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })  
@Retention(RUNTIME)  
public @interface EnumValid {  

    /**  
    * 不合法时 抛出异常信息  
    */  
    String message() default "值不合法";  

    /**  
    * 校验的枚举类  
    * @return  
    */  
    Class enumClass() default Enum.class;  

    /**  
    * 对应枚举类中需要比对的字段  
    * @return  
    */  
    String field() default "code";  

    Class<?>[] groups() default { };  

    Class<? extends Payload>[] payload() default { };  
}

3.注册 ConstraintValidator

在大多数情况下,不需要手动注册 ConstraintValidator。当你使用 @Constraint(validatedBy = EnumValidator.class) 注解时,Java Bean Validation 的实现框架会自动发现并注册相应的验证器。但在一些特殊情况下,你可能需要将验证器注册为 Spring 组件或手动配置。比如

  • 需要使用 Spring 管理的组件: 如果你的验证器需要依赖于 Spring 管理的组件(例如,使用 @Autowired 注解注入其他 bean),那么你可能需要将验证器注册为 Spring bean。这确保了验证器能够正确地使用 Spring 的依赖注入机制。

  • 需要通过属性文件进行配置: 如果你的验证器需要配置属性,而这些属性需要从 Spring 的 application.propertiesapplication.yml 文件中获取,那么将验证器注册为 Spring bean 可以更容易地实现这一点。

  • 需要在验证器中使用 Spring AOP: 如果你希望在验证逻辑中使用 Spring AOP 切面,以便添加额外的逻辑或跟踪行为,那么将验证器注册为 Spring bean 可以让你更容易集成这些方面。
    这种方式可以运用到一些业务校验中,比如账户注册时用户名称不能重复。定义一个校验用户唯一的注解@UniqueUser

@Constraint(validatedBy = {UniqueUserValidator.class})  
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })  
@Retention(RUNTIME)  
public @interface UniqueUser {  

    /**  
    * 不合法时 抛出异常信息  
    */  
    String message() default "值不合法";  

    Class<?>[] groups() default { };  

    Class<? extends Payload>[] payload() default { };  
}

然后定义一个业务的Validator

@Slf4j  
@Component  
public class UniqueUserValidator implements ConstraintValidator<UniqueUser, UserCreateRequestVO> {  

    private UserRepository userRepository;  

    @Override  
    public boolean isValid(UserCreateRequestVO value, ConstraintValidatorContext context) {  
        final String userName = value.getUserName();  
        final UserDO userDO = userRepository.selectUserByName(userName);  
        final String userId = value.getUserId();  
        if (StringUtils.isBlank(userId)){  
            return userDO == null;  
        }  

        return userDO == null || Objects.equals(userDO.getUserId(), userId);  
    }  

    @Autowired  
    public void setUserRepository(UserRepository userRepository) {  
        this.userRepository = userRepository;  
    }  
}

在创建用户的接口中使用@UniqueUser

@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    /**  
    * 创建用户  
    * @param requestVO  
    * @return  
    */  
    @PostMapping("create")  
    public ResultResponse<Void> createUser(@Validated @UniqueUser(message = "用户名称已存在") @RequestBody UserCreateRequestVO requestVO){  
            return ResultResponse.success(null);  
        }
}

模拟当用户名存在时,校验不通过

image.png

此时会抛出javax.validation.ConstraintViolationException。异常统一处理请参考:SpringBoot统一异常处理

4.自定义校验注解使用

我们创建一个性别的枚举类:

/**  
* @version 1.0  
* @description: <p></p >  
* @author: 码农Academy  
* @create: 2024/1/9 16:07  
*/  
@AllArgsConstructor  
public enum SexEnum {  

    MAN(1, "男"),  
    WOMAN(2,"女");  

    public final Integer code;  

    public final String desc;  

}

然后我们在入参中增加sex字段,并使用@EmunValid注解

@Data  
public class UserCreateRequestVO {  

    @NotBlank(message = "请输入用户名")  
    @Size(max = 128, message = "用户名长度最大为128个字符")  
    private String userName;  

    @Email(message = "请填写正确的邮箱地址")  
    private String email;  

    @Min(value = 18, message = "用户年龄必须大于18岁")  
    @Max(value = 60, message = "用户年龄必须小于60岁")  
    private Integer age;  

    @NotNull(message = "请输入性别")  
    @EnumValid(enumClass = SexEnum.class, message = "输入性别不合法")  
    private Integer sex;  

    @NotEmpty(message = "请输入你的兴趣爱好")  
    @Size(max = 5, message = "兴趣爱好最多可以输入5个")  
    private List<String> hobbies;  

    @DecimalMin(value = "50", inclusive = false, message = "体重必须大于50KG")  
    private BigDecimal weight;  
}

测试结果如下:

image.png

分组验证

在一个应用中,同一个实体类可能会被用于不同的场景,比如用户创建、用户更新、用户删除等。每个场景对于字段的要求可能不同,有些字段在某个场景下需要验证,而在另一个场景下不需要。不同的业务操作可能对同一实体的验证有不同的需求。例如,在用户创建时可能强调用户名和密码的合法性,而在用户更新时可能更关心其他信息的完整性。

开发中我们针对这种情况,在不知道分组校验的知识时,通常采取的都是对应不同的场景或者业务创建不同的入参实体,比如创建用户UserCreateRequestVO,更新用户UserUpdateRequestVO,删除用户UserDeleteRuquestVO,在不同的实体中根据业务场景设置不同的校验规则。这样做虽然也可以,但是会造成类的膨胀,业务的重复实现。

而实际上用分组校验可以让你根据场景以及业务的差异性,有选择地执行特定组的验证规则。

1.定义验证分组接口

我们定义两个分组接口CreateUserGroup(用户创建组),UpdateUserGroup(用户更新组),分别继承javax.validation.groups.Default,标识不同的业务场景。

public interface CreateUserGroup extends Default {  
}

public interface UpdateUserGroup extends Default {  
}

2.分组校验的使用

在 Bean Validation 中,分组校验是通过在验证注解上指定 groups 属性来实现的。这个属性允许你为验证规则分配一个或多个验证组。我们设定用户创建时不传递用户ID,其余的参数必传,用户更新接口必须传递用户ID,可以不传递用户名,其他参数必须传递。

@Data  
public class UserCreateRequestVO {  

    @NotBlank(message = "请选择用户", groups = UpdateUserGroup.class)  
    private String userId;  

    @NotBlank(message = "请输入用户名", groups = CreateUserGroup.class)  
    @Size(max = 128, message = "用户名长度最大为128个字符")  
    private String userName;  

    @Email(message = "请填写正确的邮箱地址")  
    private String email;  

    @Min(value = 18, message = "用户年龄必须大于18岁")  
    @Max(value = 60, message = "用户年龄必须小于60岁")  
    private Integer age;  

    @NotNull(message = "请输入性别")  
    @EnumValid(enumClass = SexEnum.class, message = "输入性别不合法")  
    private Integer sex;  

    @NotEmpty(message = "请输入你的兴趣爱好")  
    @Size(max = 5, message = "兴趣爱好最多可以输入5个")  
    private List<String> hobbies;  

    @DecimalMin(value = "50", inclusive = false, message = "体重必须大于50KG")  
    private BigDecimal weight;  
}

指定了分组的校验规则,分别在对应的分组校验中生效,没有指定分组使用默认分组Default,即对所有的校验都生效。

3.在接口中使用分组

使用 @Validated 注解,并指定要执行的验证组。

@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    /**  
    * 创建用户  
    * @param requestVO  
    * @return  
    */  
    @PostMapping("create")  
    public ResultResponse<Void> createUser(@Validated(value = CreateUserGroup.class) @RequestBody UserCreateRequestVO requestVO){  
        return ResultResponse.success(null);  
    }  

    /**  
    * 更新用户  
    * @param requestVO  
    * @return  
    */  
    @PostMapping("update")  
    public ResultResponse<Void> updateUser(@Validated(value = UpdateUserGroup.class) @RequestBody UserCreateRequestVO requestVO){  
        return ResultResponse.success(null);  
    }
}

我们指定create接口指定CreateUserGroup分组,update接口指定UpdateUserGroup

测试接口如下:

  • 创建用户create接口
    因为userId可以不传递,接口可以校验通过
    image.png

  • 更新用户update接口
    因为必须传递userId, 我们不传时校验不通过,提示选择用户
    image.png

传递userId,不传递userName时,校验通过
image.png

处理验证错误

由上述测试结果中,可以看出接口抛出的一场结果并不是很友好,我们需要统一的处理一下异常以及返回结果,给予用户友好提示。具体实现,在这里不再赘述,可以移步:SpringBoot统一异常处理

总结

Spring Boot Validation通过简化验证流程、集成Bean Validation规范、支持分组验证以及提供友好的错误处理,为Java应用开发者提供了强大而灵活的数据验证机制。最佳实践包括在控制器层使用@Validated注解、合理利用各种验证注解、使用自定义验证注解解决特定业务需求,确保代码清晰简洁、符合规范,并提高系统的可维护性和用户体验。


   转载规则


《新来的一个同事,把SpringBoot参数校验玩的那叫一个优雅》 码农Academy 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
SpringBoot统一结果返回,统一异常处理,大牛都这么玩 SpringBoot统一结果返回,统一异常处理,大牛都这么玩
深入解析Spring Boot应用中的异常处理,涵盖全局与局部机制。从统一响应到详细错误日志,提供最佳实践。全局处理确保一致性,局部处理满足个性化需求。通过综合运用,优化应用稳定性和可维护性。
2024-01-10
下一篇 
重温Java基础(三)之Java虚拟机类加载机制探究:生命周期、初始化、使用与验证 重温Java基础(三)之Java虚拟机类加载机制探究:生命周期、初始化、使用与验证
深度解析Java虚拟机中类的生命周期,包括加载、验证、初始化等阶段。强调主动和被动使用触发条件,总结类加载器类型和获取方法。
  目录