Skip to content

十五、JSR303实现数据校验

1、什么是 JSR-303

JSR-303JAVA EE 6 中的一项子规范,叫做 Bean Validation

Bean ValidationJavaBean 验证定义了相应的 元数据模型API 。缺省的元数据是 Java Annotations,通过使用 XML 可以对原有的元数据信息进行覆盖和扩展。在应用程序中,通过使用Bean Validation或是你自己定义的constraint,例如@NotNull,@Max,@ZipCode,就可以确保数据模型 (JavaBean)的正确性。constraint可以附加到字段,getter方法,类或者接口上面。对于一些特定的需求,用户可以很容易的开发定制化的constraintBean Validation是一个运行时的数据验证架,在验证之后验证的错误信息会被马上返回。

1、添加依赖

Spring Boot整合JSR-303只需要添加一个 starter 即可,如下:

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

2、内嵌的注解

注解详细信息
@Null被注释的元素必须为 null
@NotNull被注释的元素必须不为 null
@AssertTrue被注释的元素必须为 true
@AssertFalse被注释的元素必须为 false
@Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min)被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past被注释的元素必须是一个过去的日期
@Future被注释的元素必须是一个将来的日期
@Pattern(value)被注释的元素必须符合指定的正则表达式

以上是 Bean Validation 的内嵌的注解,但是 Hibernate Validator 在原有的基础上也内嵌了几个注 解,如下。

注解详细信息
@Email被注释的元素必须是电子邮箱地址
@Length被注释的字符串的大小必须在指定的范围内
@NotEmpty被注释的字符串的必须非空
@Range被注释的元素必须在合适的范围内

3、如何使用

参数校验分为 简单校验嵌套校验分组校验

1、简单校验

简单的校验即是没有嵌套属性,直接在需要的元素上标注约束注解即可。如下:

java
@Data
public class ArticleDTO {

    @NotNull(message = "文章id不能为空")
    @Min(value = 1, message = "文章ID不能为负数")
    private Integer id;

    @NotBlank(message ="文章内容不能为空")
    private String content;

    @NotBlank(message = "作者Id不能为空")
    private String authorId;

    @Future(message = "提交时间不能为过去时间")
    private Date submitTime;
}

同一个属性可以指定多个约束,比如 @NotNull@MAX ,其中的 message 属性指定了约束条件不满足 时的提示信息。

以上约束标记完成之后,要想完成校验,需要在 controller 层的接口标注 @Valid 注解以及声明一个 BindingResult 类型的参数来接收校验的结果。

下面简单的演示下添加文章的接口,如下:

java
/**
     * 添加文章
     */
@PostMapping("/add")
public ResultData add(@Valid @RequestBody ArticleDTO articleDTO, BindingResult bindingResult) throws JsonProcessingException {
    //如果有错误提示信息
    if (bindingResult.hasErrors()) {
        Map<String, String> map = new HashMap<>();
        bindingResult.getFieldErrors().forEach((item) -> {
            String message = item.getDefaultMessage();
            String field = item.getField();
            map.put(field, message);
        });
        //返回提示信息
        return new ResultData<>(map);
    }
    return new ResultData();
}

仅仅在属性上添加了约束注解还不行,还需在接口参数上标注 @Valid 注解并且声明一个 BindingResult 类型的参数来接收校验结果。

2、分组校验

上传文章不需要传文章 ID ,但是修改文章需要上传文章 ID ,并且用的都是同一个 DTO 接收 参数,此时的约束条件该如何写呢?

此时就需要对这个文章 ID 进行分组校验,上传文章接口是一个分组,不需要执行 @NotNull 校验,修改 文章的接口是一个分组,需要执行 @NotNull 的校验。

所有的校验注解都有一个 groups 属性用来指定分组, Class<?>[] 类型,没有实际意义,因此只需 要定义一个或者多个接口用来区分即可。

java
/**
 * @Author: xueqimiao
 * @Date: 2022/5/5 10:48
 */
public interface UpdateGroup {
}

/**
 * @Author: xueqimiao
 * @Date: 2022/5/5 10:48
 */
public interface AddGroup {
}
java
@Data
public class ArticleDTO {

    /**
     * 文章ID只在修改的时候需要检验,因此指定groups为修改的分组
     */
    @NotNull(message = "文章id不能为空", groups = UpdateGroup.class)
    @Min(value = 1, message = "文章ID不能为负数", groups = UpdateGroup.class)
    private Integer id;
    
    /**
     * 文章内容添加和修改都是必须校验的,groups需要指定两个分组
     */
    @NotBlank(message = "文章内容不能为空", groups = {AddGroup.class, UpdateGroup.class})
    private String content;
    
    @NotBlank(message = "作者Id不能为空", groups = AddGroup.class)
    private String authorId;
    
    /**
     * 提交时间是添加和修改都需要校验的,因此指定groups两个
     */
    @Future(message = "提交时间不能为过去时间", groups = {AddGroup.class, UpdateGroup.class})
    private Date submitTime;

}

JSR303本身的@Valid并不支持分组校验,但是Spring在其基础提供了一个注解 @Validated 支持 分组校验。 @Validated这个注解 value 属性指定需要校验的分组。

java
/**
     * 添加文章
     */
@PostMapping("/add")
public ResultData add(@Validated(value = AddGroup.class) @RequestBody ArticleDTO articleDTO, BindingResult bindingResult) throws JsonProcessingException {
    //如果有错误提示信息
    if (bindingResult.hasErrors()) {
        Map<String, String> map = new HashMap<>();
        bindingResult.getFieldErrors().forEach((item) -> {
            String message = item.getDefaultMessage();
            String field = item.getField();
            map.put(field, message);
        });
        //返回提示信息
        return new ResultData<>(map);
    }
    return new ResultData();
}

3、嵌套校验

嵌套校验简单的解释就是一个实体中包含另外一个实体,并且这两个或者多个实体都需要校验。

文章可以有一个或者多个分类,作者在提交文章的时候必须指定文章分类,而分类是单独一个实体,有 分类ID名称等等。大致的结构如下:

java
@Data
public class ArticleDTO {

    /**
     * 文章ID只在修改的时候需要检验,因此指定groups为修改的分组
     */
    @NotNull(message = "文章id不能为空", groups = UpdateGroup.class)
    @Min(value = 1, message = "文章ID不能为负数", groups = UpdateGroup.class)
    private Integer id;

    /**
     * 文章内容添加和修改都是必须校验的,groups需要指定两个分组
     */
    @NotBlank(message = "文章内容不能为空", groups = {AddGroup.class, UpdateGroup.class})
    private String content;

    @NotBlank(message = "作者Id不能为空", groups = AddGroup.class)
    private String authorId;

    /**
     * 提交时间是添加和修改都需要校验的,因此指定groups两个
     */
    @Future(message = "提交时间不能为过去时间", groups = {AddGroup.class, UpdateGroup.class})
    private Date submitTime;

    //分类的信息
    private CategoryDTO categoryDTO;

}

此时文章和分类的属性都需要校验,这种就叫做嵌套校验。

嵌套校验很简单,只需要在嵌套的实体属性标注 @Valid 注解,则其中的属性也将会得到校验,否则不会校验。

如下文章分类实体类校验 :

java
/**
 * 文章分类
 */
@Data
public class CategoryDTO {
    
    @NotNull(message = "分类ID不能为空")
    @Min(value = 1, message = "分类ID不能为负数")
    private Integer id;
    
    @NotBlank(message = "分类名称不能为空")
    private String name;
}

文章的实体类中有个嵌套的文章分类 CategoryDTO 属性,需要使用 @Valid 标注才能嵌套校验,如下:

java
@Data
public class ArticleDTO {

    /**
     * 文章ID只在修改的时候需要检验,因此指定groups为修改的分组
     */
    @NotNull(message = "文章id不能为空", groups = UpdateGroup.class)
    @Min(value = 1, message = "文章ID不能为负数", groups = UpdateGroup.class)
    private Integer id;

    /**
     * 文章内容添加和修改都是必须校验的,groups需要指定两个分组
     */
    @NotBlank(message = "文章内容不能为空", groups = {AddGroup.class, UpdateGroup.class})
    private String content;

    @NotBlank(message = "作者Id不能为空", groups = AddGroup.class)
    private String authorId;

    /**
     * 提交时间是添加和修改都需要校验的,因此指定groups两个
     */
    @Future(message = "提交时间不能为过去时间", groups = {AddGroup.class, UpdateGroup.class})
    private Date submitTime;

    //分类的信息
    @Valid
    @NotNull(message = "分类不能为空")
    private CategoryDTO categoryDTO;

}

Controller层的添加文章的接口同上,需要使用@Valid或者@Validated标注入参,同时需要定义一个BindingResult的参数接收校验结果。

嵌套校验针对分组查询仍然生效,如果嵌套的实体类(比如 CategoryDTO )中的校验的属性和接口 中 @Validated 注解指定的分组不同,则不会校验。

JSR-303 针对 集合 的嵌套校验也是可行的,比如 List 的嵌套校验,同样需要在属性上标注一个 @Valid 注解才会生效,如下:

java
/**
  * @Valid这个注解标注在集合上,将会针对集合中每个元素进行校验
  */
@Valid
@Size(min = 1, message = "至少一个分类")
@NotNull(message = "分类不能为空")
private List<CategoryDTO> categoryDTOS;

总结:嵌套校验只需要在需要校验的元素(单个或者集合)上添加 @Valid 注解,接口层需要使用 @Valid 或者 @Validated注解标注入参。

4、如何接收校验结果

接收校验的结果的方式很多,不过实际开发中最好选择一个优雅的方式,下面介绍常见的两种方式。

1、BindingResult 接收

这种方式需要在 Controller 层的每个接口方法参数中指定,Validator会将校验的信息自动封装到其中。 这也是上面例子中一直用的方式。如下:

java
@PostMapping("/add")
public String add(@Valid @RequestBody ArticleDTO articleDTO, BindingResult bindingResult){}

这种方式的弊端很明显,每个接口方法参数都要声明,同时每个方法都要处理校验信息,显然不现实,舍弃。

此种方式还有一个优化的方案:使用 AOP ,在 Controller 接口方法执行之前处理 BindingResult 的 消息提示,不过这种方案仍然 不推荐使用

2、全局异常捕捉

参数在校验失败的时候会抛出的 MethodArgumentNotValidException 或者 BindException 两种异常,可以在全局的异常处理器中捕捉到这两种异常,将提示信息或者自定义信息返回给客户端。

java
/**
  * 参数校验异常步骤
  */
@ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class})
public ResultData onException(Exception e) throws JsonProcessingException {
    BindingResult bindingResult = null;
    if (e instanceof MethodArgumentNotValidException) {
        bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
    } else if (e instanceof BindException) {
        bindingResult = ((BindException) e).getBindingResult();
    }
    Map<String, String> errorMap = new HashMap<>(16);
    bindingResult.getFieldErrors().forEach((fieldError) ->
                                           errorMap.put(fieldError.getField(), fieldError.getDefaultMessage()));
    return new ResultData(errorMap);
}

5、spring-boot-starter-validation做了什么

这个启动器的自动配置类是 ValidationAutoConfiguration ,最重要的代码就是注入了一个 Validator (校验器)的实现类,代码如下:

java
@Bean
@Role(2)
@ConditionalOnMissingBean({Validator.class})
public static LocalValidatorFactoryBean defaultValidator(ApplicationContext applicationContext) {
    LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
    MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory(applicationContext);
    factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
    return factoryBean;
}

这个有什么用呢? Validator 这个接口定义了校验的方法,如下:

java
public interface Validator {
    <T> Set<ConstraintViolation<T>> validate(T var1, Class<?>... var2);

    <T> Set<ConstraintViolation<T>> validateProperty(T var1, String var2, Class<?>... var3);

    <T> Set<ConstraintViolation<T>> validateValue(Class<T> var1, String var2, Object var3, Class<?>... var4);

    BeanDescriptor getConstraintsForClass(Class<?> var1);

    <T> T unwrap(Class<T> var1);

    ExecutableValidator forExecutables();
}

这个 Validator 可以用来自定义实现自己的校验逻辑,有些大公司完全不用JSR-303提供的 @Valid 注解,而是有一套自己的实现,其实本质就是利用 Validator 这个接口的实现。

6、如何自定义校验

虽说在日常的开发中内置的约束注解已经够用了,但是仍然有些时候不能满足需求,需要自定义一些校验约束。

有这样一个例子,传入的数字要在列举的值范围中,否则校验失败。

1、自定义校验注解

首先需要自定义一个校验注解,如下:

java
/**
 * @Author: xueqimiao
 * @Date: 2022/4/7 11:15
 */
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {ConstantEnumValueValidator.class})
@NotNull(message = "不能为空")
public @interface ConstantEnumValue {

    // 默认错误消息
    String message() default "必须为指定值";

    String[] strValues() default {};

    int[] intValues() default {};

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

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

    // 指定多个时使用
    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        EnumValue[] value();
    }
}

根据 Bean Validation API 规范的要求有如下三个属性是必须的:

message:定义消息模板,校验失败时输出

groups :用于校验分组

payload : Bean Validation API 的使用者可以通过此属性来给约束条件指定严重级别. 这个属性 并不被API自身所使用。

除了以上三个必须要的属性,添加了一个 values 属性用来接收限制的范围。 该校验注解头上标注的如下一行代码:

java
@Constraint(validatedBy = {ConstantEnumValueValidator.class})

这个 @Constraint 注解指定了通过哪个校验器去校验。

自定义校验注解可以复用内嵌的注解,比如 @EnumValues 注解头上标注了一个 @NotNull 注解,这样 @ConstantEnumValue 就兼具了 @NotNull 的功能。

2、自定义校验器

@Constraint 注解指定了校验器为 EnumValuesConstraintValidator ,因此需要自定义一个。 自定义校验器需要实现e ConstraintValidator<A extends Annotation, T>这个接口,第一个泛型是校验注解 ,第二个是 参数类型。代码如下:

java
/**
 * @Author: xueqimiao
 * @Date: 2022/4/7 11:16
 */
public class ConstantEnumValueValidator implements ConstraintValidator<ConstantEnumValue, Object> {

    private String[] strValues;
    private int[] intValues;

    @Override
    public void initialize(ConstantEnumValue constraintAnnotation) {
        strValues = constraintAnnotation.strValues();
        intValues = constraintAnnotation.intValues();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value instanceof String) {
            for (String s : strValues) {
                if (s.equals(value)) {
                    return true;
                }
            }
        } else if (value instanceof Integer) {
            for (int s : intValues) {
                if (s == ((Integer) value).intValue()) {
                    return true;
                }
            }
        }
        return false;
    }
}

如果约束注解需要对其他数据类型进行校验,则可以的自定义对应数据类型的校验器,然后在约束 注解头上的 @Constraint 注解中指定其他的校验器。

java
@Data
public class AuthorDTO {
	@EnumValues(values = {1,2},message = "性别只能传入1或者2") 
    private Integer gender;
}