Java Bean Validation + Hibernate Validator

Java Bean Validation + Hibernate Validator

官方网站

The Java Community Process(SM) Program - Community Update - View Community Update Information for JSR# 380

beanvalidation

Hibernate Validator

GitHub - hibernate/hibernate-validator: Hibernate Validator - Jakarta Bean Validation Reference Implementation

参考博客

A哥学数据校验 - 随笔分类 - YourBatman - 博客园

背景知识

在实际的业务开发中,从客户端(浏览器)发起请求,到控制层,业务逻辑层,数据访问层,都需要做参数校验,有时候,我们甚至会在多层对同一个参数或者类属性进行校验,这产生了大量的重复代码,这是一个普遍的问题,而且,多人协作的时候,每个人的校验方式不一样,抛出的异常不一样,提示的信息不一样,代码量一多,会非常难以管理,所以,最好的方式是将验证逻辑与相应的域模型(也就是 bean)进行绑定,即:Bean Validation。其实说白了,就是用面向对象的思想,将待验证的信息放到一个类中进行管理,于是,我们有了Java Bean Validation

Java Bean Validation 是一个通过配置注解来验证参数的框架,它包含两部分 Bean Validation API(规范)和 Hibernate Validator(实现)

Java Bean Validation 2.0Jakarta Bean Validation 2.0版本是当下的主流版本,对应 JSR380,JSR 是 Java Specification Requests 的缩写,意思是 Java 规范提案。Hibernate Validator6.x 版本开始对 JSR 380 规范提供完整支持,除了支持标准外,自己也做了相应的优化,比如性能改进、减少内存占用,添加了自己的注解等等。

Jakarta EE 9 发布之后,Jakarta Bean Validation 3.0 也正式发布,只是除了将包名中的 javax.* 换成 jakarta.* 之外,代码实现并没有啥改变,Hibernate Validator 也推出了 7.x 版本用于支持 Jakarta Bean Validation 3.0。跟 Jakarta Bean Validation 3.0 一样,并没有引入新特性,这也是现在的主流版本依然是 Java Bean Validation 2.0**和**Jakarta Bean Validation 2.0 的原因。

Java Bean Validation 2.0 和 Jakarta Bean Validation 2.0 总共 22 的验证注解

除了 JSR 标准提供的这 22 个注解外,Hibernate Validator 还提供了一些非常实用的注解

JavaSE 中的使用

简单配置

引入依赖,当你导入了 hibernate-validator 后,无需再显示导入 javax.validation,因为 hibernate-validator 中已经包含了 validation-api

注意,Bean Validation 需要 El 管理器,用于错误消息动态插值,因此需要自己额外导入 EL 的实现,这里我们选择 tomcat-embed-el,如果是如果是 web 项目,则跟 servlet-api 一样,tomcat 已经提供了,所以 scope 是 provided。

小贴士:EL 也属于 Java EE 标准技术,可认为是一种表达式语言工具,它并不仅仅是只能用于 Web(即使你绝大部分情况下都是用于 web 的 jsp 里),可以用于任意地方(类比 Spring 的 SpEL)

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.17.Final</version>
</dependency>
<!-- 引入 Expression Language 3.0,实现-->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-el</artifactId>
    <version>8.5.29</version>
    <!-- 如果是web项目,则跟servlet-api一样,tomcat已经提供了,所以scope 是provided-->
    <!--<scope>provided</scope> 打包时最用此scope-->
</dependency>
<!-- 测试-->
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.1</version>
    <scope>test</scope>
</dependency>

为了在错误信息中能够打印完整的方法参数名,我们需要在编译的时候加上编译参数 -parameters,当我们使用 Maven 的时候,添加以下配置

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <compilerArgs>
                    <arg>-parameters</arg>
                </compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

注解应用的位置

Bean Validation 共支持四个级别的约束:

  1. 字段约束(Field)
  2. 属性约束(Property)
  3. 容器元素约束(Container Element),也就是 Collection、Map 这类数据。
  4. 类约束(Class)

值得注意的是,并不是所有的约束注解都能够标注在上面四种级别上。现实情况是:Bean Validation 自带的 22 个标准约束全部支持 1/2/3 级别,且全部不支持第 4 级别(类级别)约束。当然喽,作为补充的 Hibernate-Validator 它提供了一些专门用于类级别的约束注解,如 org.hibernate.validator.constraints.@ScriptAssert

什么是字段?什么是属性?他们的区别是什么? Java beans 中字段(field)和属性(property)的区别

Glossary of Terms: https://docs.oracle.com/javase/tutorial/information/glossary.html#P

field: A data member of a class. Unless specified otherwise, a field is not static.
property: Characteristics of an object that users can set, such as the color of a window.
翻译过来就是
field 类的数据成员,默认非 static
property 用户可以设置的实例对象的一种特质

这两个概念有重叠,主要是理解的主体不一样,一般谈到 field,主体都是类,而 property,主体是实例对象
默认类中声明的所有的,都是 field,添加了 setter 方法之后,就变成了一个 property

这个在 IDEA 的 Structure Tool Window 中也有体现,f 就表示 field,p 就表示 property

字段的本质是 Field,属性的本质是 Method
你可以简单地把 field 理解为类内部的变量,而 property 为 set 方法,,这个 set 方法的内部设置一个 field 的值,也可以设置多个,也可以不设置。但是这个 set 方法总归是改变了对象实例的状态

字段约束

Bean Validation 将直接通过反射的方式校验字段值,不通过 get/set 方法。因此字段约束可以应用于任何访问修饰符的字段,

但是注意:

我们优先使用字段约束,但是字段约束无法生效的时候,我们可以使用属性约束。

属性约束

效果跟字段约束类似,有几点需要注意

因为在使用 Lombok 的前提下,要实现属性约束就需要手动写 get/set 方法,所以我们在能用字段约束的时候,还是尽量用属性约束,但是如果对象被字节码增强了,那没办法,就只能老老实实用属性约束了。

容器约束

类约束

约束也可以放在类级别上(也就说注解标注在类上)。在这种情况下,验证的主体不是单个属性,而是整个对象。如果验证依赖于对象的几个属性之间的相关性,那么类级别约束就能搞定这一切。比如后面介绍的@ScriptAssert。

注意

Bean 上的所有地方的所有的约束注解都会执行,不存在短路效果

多个约束之间的执行也是可以排序(有序的),这就涉及到多个约束的执行顺序(序列)问题。

关于注解的短路问题和校验排序问题,看 @GroupSequence 注解和 @GroupSequenceProvider 注解

注解必须配合特定的 Java 类型使用

这些校验注解跟其修饰的类型必须匹配,否则会报错。

简单 Bean 校验 - 字段和属性校验

字段校验

待验证的 baen

@Data
public class User {

    @NonNull
    @NotBlank(message = "名字不能为空")
    private String name;
    @NonNull
    @NotBlank(message = "性别不能为空")
    private String gender;
    @NonNull
    @NotBlank(message = "职位不能为空")
    private String occupation;
    @NonNull
    @Min(value = 18, message = "年龄至少为18岁")
    private int age;

}

进行验证

@Test
public void validation(){
    User user = new User("", "", "", 1);

    // 使用默认的校验器对user进行校验然后拿到结果
    // 1、使用【默认配置】得到一个校验工厂  这个配置可以来自于provider、SPI提供
    ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
    // 2、得到一个校验器
    Validator validator = validatorFactory.getValidator();
    // 3、校验Java Bean(解析注解) 返回校验结果
    Set<ConstraintViolation<User>> result = validator.validate(user);
    // 对结果进行遍历输出
    result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    System.out.println("================================");

    //用hibernateValidator
    HibernateValidatorConfiguration configuration = Validation.byProvider(HibernateValidator.class)
            .configure()
            .failFast(false);
    ValidatorFactory validatorFactory4Hibernate = configuration.buildValidatorFactory();
    Set<ConstraintViolation<User>> sets = validatorFactory4Hibernate.getValidator().validate(user);
    sets.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ":" + v.getInvalidValueprintln;
}

输出日志:

age 年龄至少为18岁: 1
name 名字不能为空: 
gender 性别不能为空: 
occupation 职位不能为空: 
================================
name 名字不能为空:
gender 性别不能为空:
age 年龄至少为18岁:1
occupation 职位不能为空:

注意,将校验注解放到静态字段上是无效的,例如将 name 设置为静态字段

@Data
public class UserTest4StaticField {

    @NonNull
    @NotBlank(message = "名字不能为空")
    private static String name;
    @NonNull
    @NotBlank(message = "性别不能为空")
    private String gender;
    @NonNull
    @NotBlank(message = "职位不能为空")
    private String occupation;
    @NonNull
    @Min(value = 18, message = "年龄至少为18岁")
    private int age;

    /**
     * Lombok 构造函数默认不添加静态字段,因为静态字段属于全体类实例共有,而构造函数是为了创建类实例
     */
    public UserTest4StaticField(String name,@NonNull String gender, @NonNull String occupation, @NonNull int age) {
        this.name = name;
        this.gender = gender;
        this.occupation = occupation;
        this.age = age;
    }
}

跟上面一样的测试类,只是将 User 换成 UserTest4StaticField,输出的日志就是

gender 性别不能为空: 
age 年龄至少为18岁: 1
occupation 职位不能为空: 
================================
gender 性别不能为空:
age 年龄至少为18岁:1
occupation 职位不能为空:

因为 Validator 等校验器是线程安全的,因此一般来说一个应用全局仅需一份即可,因此只需要初始化一次。其实这种情况非常适合用 Spring IOC 容器(默认单例模式)来管理 validator。现在为了方便,我们可以提取出一个单独的工具类。

/**
 * 因为Validator等校验器是线程安全的,因此一般来说一个应用全局仅需一份即可,因此只需要初始化一次。
 * 其实这种情况非常适合用Spring IOC容器(默认单例模式)来管理validator实例
 */
@UtilityClass
public class ValidationUtil {

    public Validator getDefaultValidator() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        Validator validator = validatorFactory.getValidator();
        return validator;
    }

    public ExecutableValidator getDefaultExecutableValidator() {
        return getDefaultValidator().forExecutables();
    }

    public Validator getHibernateValidator() {
        HibernateValidatorConfiguration configuration = Validation.byProvider(HibernateValidator.class)
                .configure()
                .failFast(false);
        ValidatorFactory validatorFactory4Hibernate = configuration.buildValidatorFactory();
        Validator validator = validatorFactory4Hibernate.getValidator();
        return validator;
    }

    public ExecutableValidator getHibernateExecutableValidator() {
        return getHibernateValidator().forExecutables();
    }

}

属性校验

注意,跟字段校验一样,将校验注解放到静态属性(静态字段的静态的 get 方法)上是无效的,比如 name 属性

@Data
public class UserPropertyValidateTest {

    @NonNull
    private static String name;
    @NonNull
    private String gender;
    @NonNull
    private String occupation;
    @NonNull
    private int age;

    @NotBlank(message = "名字不能为空")
    public static String getName() {
        return name;
    }

    @NotBlank(message = "名字不能为空")
    public static void setName(String name) {
        UserPropertyValidateTest.name = name;
    }

    @NotBlank(message = "性别不能为空")
    public String getGender() {
        return gender;
    }

    public void setGender(String gender) {
        this.gender = gender;
    }

    @NotBlank(message = "职位不能为空")
    public String getOccupation() {
        return occupation;
    }

    public void setOccupation(String occupation) {
        this.occupation = occupation;
    }

    @Min(value = 18, message = "年龄至少为18岁")
    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

测试类

public class BeanPropertyValidateTestTest {

    @Test
    public void validation(){
        UserPropertyValidateTest user = new UserPropertyValidateTest( "", "", 1);

        Set<ConstraintViolation<UserPropertyValidateTest>> result = ValidationUtil.getDefaultValidator().validate(user);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }

}

日志

gender 性别不能为空: 
age 年龄至少为18岁: 1
occupation 职位不能为空: 空: 

分组校验

有几点需要注意

分组接口

public interface Simple {
}

校验分组的 bean

@Data
public class UserWithGroup {

    @NonNull
    @NotBlank(message = "名字不能为空",groups = Simple.class)
    private String name;
    @NonNull
    @NotBlank(message = "性别不能为空",groups = Simple.class)
    private String gender;
    @NotBlank(message = "职位不能为空")
    private String occupation;
    @NonNull
    @Min(value = 18, message = "年龄至少为18岁")
    private int age;

}

测试类

public class BeanValidateWithGroupTest {

    @Test
    public void validation(){
        UserWithGroup user = new UserWithGroup( "", "", 1);

        // 默认分组
        Set<ConstraintViolation<UserWithGroup>> defaultResult = ValidationUtil.getDefaultValidator().validate(user);
        System.out.println("--------------------------------");
        defaultResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

        // Simple分组
        Set<ConstraintViolation<UserWithGroup>> SimpleResult = ValidationUtil.getDefaultValidator().validate(user,Simple.class);
        System.out.println("--------------------------------");
        SimpleResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

        // 同时指定默认分组和Simple分组
        Set<ConstraintViolation<UserWithGroup>> multipleResult = ValidationUtil.getDefaultValidator().validate(user,Simple.class, Default.class);
        System.out.println("--------------------------------");
        multipleResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }

}

日志

--------------------------------
age 年龄至少为18岁: 1
occupation 职位不能为空: null
--------------------------------
gender 性别不能为空: 
name 名字不能为空: 
--------------------------------
gender 性别不能为空: 
age 年龄至少为18岁: 1
name 名字不能为空: 
occupation 职位不能为空: null

List 和 Map 类型的 bean 属性和方法的泛型类返回值的校验 - 容器内部元素校验

可直接在 List/Map 类型属性声明属性类型的时候,在类型参数前加校验注解,非常直观,也非常好用。同时,当容器的元素类型是自定义的类型比如 User、Student 之类的类型的时候,也可以使用级联注解@Valid,这真的非常常用

@Data
public class CollectionMapValidation {

    @NotNull
    @Size(min = 1,max = 10)
    private List<@NotBlank String> list;
    @NotNull
    @Size(min = 1,max = 10)
    private Map<@NotBlank String, @Min(10) @Max(20) Integer> map;

    @NotNull
    @Size(min = 1,max = 10)
    private Map<@NotBlank String, @Valid @NotNull User> userMap;

}

测试类

public class CollectionMapValidationTest {

    @Test
    public void test() {
        CollectionMapValidation obj = new CollectionMapValidation();
        @NotNull @Size(min = 1, max = 10) Map<@NotBlank String, @Min(10) @Max(20) Integer> map = new HashMap<@NotBlank String, @Min(20) Integer>();
        map.put("aaa", 112);
        map.put("", 12);
        obj.setMap(map);
        @NotNull @Size(min = 1, max = 10) List<@NotBlank String> list= new ArrayList<>();
        //list.add("ddd");
        //list.add("ccc");
        obj.setList(list);
        User user0 = new User("", "", "", 0);
        User user1 = new User("", "", "", 1);
        @NotNull @Size(min = 1, max = 10) Map<@NotBlank String, @Valid @NotNull User> userMap=new HashMap<>();
        userMap.put("user0",user0);
        userMap.put("user1",user1);
        obj.setUserMap(userMap);
        Validator validator = ValidationUtil.getDefaultValidator();
        Set<ConstraintViolation<CollectionMapValidation>> result = validator.validate(obj);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }
}

输出日志,元素的验证顺序是乱序的,而且奇怪的是,user1 的 age 的验证输出了两次,

userMap[user1].age 年龄至少为18岁: 1
map[aaa].<map value> 最大不能超过20: 112
userMap[user0].occupation 职位不能为空: 
userMap[user1].occupation 职位不能为空: 
userMap[user1].name 名字不能为空: 
list 个数必须在1和10之间: []
map<K>[].<map key> 不能为空: 
userMap[user0].age 年龄至少为18岁: 0
userMap[user0].gender 性别不能为空: 
userMap[user1].gender 性别不能为空: 
userMap[user0].name 名字不能为空: 
userMap[user1].age 年龄至少为18岁: 1

除此之外,还可以为泛型类进行验证

@Data
public class Result<T> {
    public static int STATUS_NORMAL = 1;
    public static int STATUS_EXCEPTION = -1;
    @Min(-1)
    @Max(1)
    @NonNull
    private int status;
    @NotNull
    @NonNull
    private String message;
    @Valid
    private T content;
}

测试代码,Result<T> 一般用于方法返回值。

public class FunctionValidation4CustomerContainer {

    @SneakyThrows
    public @Valid @NotNull Result<User> validateReturnValue() {
        // ... 模拟逻辑执行,得到一个result
        Result<User> result = new Result<User>(3,"");
        result.setContent(new User("","","",0));

        // 在结果返回之前校验
        Method currMethod = this.getClass().getMethod("validateReturnValue", null);
        Set<ConstraintViolation<FunctionValidation4CustomerContainer>> validResult = ValidationUtil.getDefaultExecutableValidator().validateReturnValue(this, currMethod, result);
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("返回结果错误");
        }
        return result;
    }


}

测试方法

public class FunctionValidation4CustomerContainerTest {

    @Test
    public void validateReturnValue() {
        new FunctionValidation4CustomerContainer().validateReturnValue();
    }
}

生成日志

validateReturnValue.<return value>.content.name 名字不能为空: 
validateReturnValue.<return value>.content.occupation 职位不能为空: 
validateReturnValue.<return value>.status 最大不能超过1: 3
validateReturnValue.<return value>.content.gender 性别不能为空: 
validateReturnValue.<return value>.content.age 年龄至少为18岁: 0

可以看到,成功校验

自定义 ValueExtractor

上面的例子之所以可以正常校验,是因为 Hibernate Validator 中默认添加了非常多钟类型的ValueExtractor的实现类帮助我们从容器中取出特定的值来进行校验,这个类的注释也告诉了我们该如何使用 ValueExtractor。

接下来我们自己动手试验一下。

public class ResultValueExtractor implements ValueExtractor<Result<@ExtractedValue ?>> {

        @Override
        public void extractValues(Result<?> originalValue, ValueReceiver receiver) {
            receiver.value(null, originalValue.getContent());
        }

}

测试,注意添加 ValueExtractor,获取新的 Validator,

/**
* 使用自定义的ValueExtractor
* @return
*/
@SneakyThrows
public @Valid @NotNull Result<User> validateReturnValueByMyValueExtractor() {
    // ... 模拟逻辑执行,得到一个result
    Result<User> result = new Result<User>(3,"");
    result.setContent(new User("","","",0));

    // 在结果返回之前校验
    Validator validator = Validation.buildDefaultValidatorFactory().usingContext()
            // 注册 ValueExtractor 然后获取新的 Validator 
            .addValueExtractor(new ResultValueExtractor())
            .getValidator();;
    ExecutableValidator executableValidator = validator.forExecutables();

    Method currMethod = this.getClass().getMethod("validateReturnValueByMyValueExtractor", null);
    Set<ConstraintViolation<FunctionValidation4CustomerContainer>> validResult = executableValidator.validateReturnValue(this, currMethod, result);
    if (!validResult.isEmpty()) {
        // ... 输出错误详情validResult
        validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
        throw new IllegalArgumentException("返回结果错误");
    }
    return result;
}

测试方法

@Test
public void validateReturnValueByMyValueExtractor() {
    new FunctionValidation4CustomerContainer().validateReturnValueByMyValueExtractor();
}

输出日志

validateReturnValueByMyValueExtractor.<return value>.content.age 年龄至少为18岁: 0
validateReturnValueByMyValueExtractor.<return value>.content.occupation 职位不能为空: 
validateReturnValueByMyValueExtractor.<return value>.status 最大不能超过1: 3
validateReturnValueByMyValueExtractor.<return value>.content.name 名字不能为空: 
validateReturnValueByMyValueExtractor.<return value>.content.gender 性别不能为空: 

Bean 级联校验 - @Valid

关于 @Valid 注解的详细源码分析:从深处去掌握数据校验@Valid的作用(级联校验) - YourBatman - 博客园,有需要可以看看。

@Valid 注解的注释:标记需要级联校验的属性、方法参数或方法返回类型。当校验属性、方法参数或方法返回类型时,将验证定义在对象及其属性上的约束。这种行为是递归的,直到在对象内部的属性上找不到约束为止。

所以@Valid 可以在 bean 属性、方法参数/返回值进行级联,这里我们先看 bean 属性的级联校验,方法参数/返回值校验在后面再看

@Data
public class Reservation {

    @NonNull
    @Valid
    private User customer;

    @Size(max = 200)
    private String remarks;

    @NonNull
    @Min(0)
    @Max(100)
    private int roomNumber;

    @NonNull
    @Min(2)
    @Max(12)
    private int customerCount;

}

校验方法

@Test
public void test(){
    User user = new User("", "男","", 16);
    Reservation reservation = new Reservation(user, 20, 15);
    Validator validator = ValidationUtil.getDefaultValidator();
    Set<ConstraintViolation<Reservation>> result = validator.validate(reservation);
    // 对结果进行遍历输出
    result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
}

输出日志

customerCount 最大不能超过12: 15
customer.occupation 职位不能为空: 
customer.age 年龄至少为18岁: 16
customer.name 名字不能为空: 

注意,List 或者 Map 的类型参数也可以级联校验,这里举个例子,就不详细测试了,回看上一小节即可

@Data
public class Hotel {

    private List<@Valid @NotNull User> customer;

}

普通方法参数和返回值校验

很多时候,我们只是想对一些简单的独立参数(比如方法入参 int age,比如方法返回值)进行校验,并不需要大动干戈的弄个 Java Bean 装起来(这个时候用面向对象的思想也会有点笨重),所以我们要实现对方法参数/返回值直接进行校验。

注意,当方法返回值类型为 Void 的时候,不可以对方法返回值使用任何校验注解,否则会报错 Void methods must not be constrained or marked for cascaded validation

注意,为了在错误信息中能够打印完整的方法参数名,我们需要在编译的时候加上编译参数 -parameters,当我们使用 Maven 的时候,添加以下配置

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <compilerArgs>
                    <arg>-parameters</arg>
                </compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

测试代码如下

public class SimpleFunctionValidation {

    @SneakyThrows
    public void validateParameters(@Size(min = 2, max = 4) @NotEmpty String name, @NotNull @Min(0) @Max(100) Integer age, @Past @NotNull Date birthDate) {
        Method currMethod = this.getClass().getMethod("validateParameters", String.class, Integer.class, Date.class);
        Set<ConstraintViolation<SimpleFunctionValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{name, age, birthDate});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
    }

    @SneakyThrows
    public @Valid @NotNull User validateReturnValue() {
        // ... 模拟逻辑执行,得到一个result
        User result = null;

        // 在结果返回之前校验
        Method currMethod = this.getClass().getMethod("validateReturnValue", null);
        Set<ConstraintViolation<SimpleFunctionValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateReturnValue(this, currMethod, result);
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("返回结果错误");
        }
        return result;
    }


}

测试类

public class SimpleFunctionValidationTest {

    @Test
    public void testSimpleParams() {
        new SimpleFunctionValidation().validateParameters("", 0, new Date());
    }

    @Test
    public void testSimpleReturnValue() {
        User user = new SimpleFunctionValidation().validateReturnValue();
    }

}

输出日志

validateParameters.name 不能为空: 
validateParameters.name 个数必须在2和4之间: 

java.lang.IllegalArgumentException: 参数错误

.....

validateReturnValue.<return value> 不能为null: null

java.lang.IllegalArgumentException: 返回结果错误

构造函数参数和返回值校验

针对构造函数的参数和返回值的校验其实不多,尤其是构造器的返回值校验,构造器的执行结果,就是当前对象,即 this ,而类对象其实又不是基本类型,基本上没啥可校验的,要校验,也是校验返回的对象的内部的属性,是否符合配置在其上的规则,这就需要级联校验,所以,用@Valid。

public class ConstructorValidation {
    @NotNull
    private String name;
    @NotNull
    @Min(0)
    private Integer age;

    @SneakyThrows
    public ConstructorValidation(@Size(min = 2, max = 4) @NotEmpty String name, @NotNull @Min(0) @Max(100) Integer age, @Past @NotNull Date birthDate) {
        Constructor constructor = this.getClass().getConstructor( String.class, Integer.class, Date.class);
        Set<ConstraintViolation<ConstructorValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateConstructorParameters(constructor, new Object[]{name, age, birthDate});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("构造函数参数错误");
        }
    }

    @SneakyThrows
    public @Valid ConstructorValidation() {
        // 构造器的执行结果,就是当前对象,即 this ,而类对象其实又不是基本类型,基本上没啥可校验的,要校验,也是校验返回的对象的内部的属性,是否符合配置在其上的规则,这就需要级联校验,所以,用@Valid

        Constructor constructor = this.getClass().getConstructor();
        Set<ConstraintViolation<ConstructorValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateConstructorReturnValue(constructor, this);
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("构造函数返回结果错误");
        }
    }

}

测试类

public class ConstructorValidationTest {

    @Test
    public void testConstructorParameters() {
        new ConstructorValidation("", 0, new Date());
    }

    @Test
    public void testConstructorReturnValue() {
        new ConstructorValidation();
    }

}

日志

ConstructorValidation.name 不能为空: 
ConstructorValidation.name 个数必须在2和4之间: 

java.lang.IllegalArgumentException: 构造函数参数错误

......

ConstructorValidation.<return value>.age 不能为null: null
ConstructorValidation.<return value>.name 不能为null: null

java.lang.IllegalArgumentException: 构造函数返回结果错误

对 JavaBean 类型的参数和返回值进行级联校验 - @Valid

这里我们已普通方法为例,构造函数方法其实是差不多的

public class FunctionCascadeValidation {

    @SneakyThrows
    public void validateParametersCascade(@Valid @NotNull Reservation reservation) {
        Method currMethod = this.getClass().getMethod("validateParametersCascade", Reservation.class);
        Set<ConstraintViolation<FunctionCascadeValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{reservation});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("级联参数错误");
        }
    }

    @SneakyThrows
    public @Valid @NotNull Reservation validateReturnValueCascade() {
        // ... 模拟逻辑执行,得到一个result
        Reservation result = new Reservation(new User("","", "",45),0,100 );

        // 在结果返回之前校验
        Method currMethod = this.getClass().getMethod("validateReturnValueCascade", null);
        Set<ConstraintViolation<FunctionCascadeValidation>> validResult = ValidationUtil.getDefaultExecutableValidator().validateReturnValue(this, currMethod, result);
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("返回结果级联错误");
        }
        return result;
    }
}

测试类

public class FunctionCascadeValidationTest {

    @Test
    public void validateParametersCascade() {
        new FunctionCascadeValidation().validateParametersCascade(new Reservation(new User("","", "",45),0,100 ));
    }

    @Test
    public void validateReturnValueCascade() {
        new FunctionCascadeValidation().validateReturnValueCascade();
    }
}

日志

validateReturnValueCascade.<return value>.roomNumber 最小不能小于1: 0
validateReturnValueCascade.<return value>.customerCount 最大不能超过12: 100
validateReturnValueCascade.<return value>.customer.gender 性别不能为空: 
validateReturnValueCascade.<return value>.customer.occupation 职位不能为空: 
validateReturnValueCascade.<return value>.customer.name 名字不能为空: 

java.lang.IllegalArgumentException: 返回结果级联错误

......

validateParametersCascade.reservation.customer.name 名字不能为空: 
validateParametersCascade.reservation.customerCount 最大不能超过12: 100
validateParametersCascade.reservation.roomNumber 最小不能小于1: 0
validateParametersCascade.reservation.customer.gender 性别不能为空: 
validateParametersCascade.reservation.customer.occupation 职位不能为空: 

java.lang.IllegalArgumentException: 级联参数错误

如果子类继承自他的父类,那么校验子类的时候会校验父类的字段的约束吗?

如果子类继承自他的父类,除了校验子类,同时还会校验父类,这就是约束继承(同样适用于接口)。同时,如果子类覆盖了父类的方法,那么子类和父类的约束都会被校验

校验注解应该写在接口方法还是实现类方法上?

直接说结论,应该写在接口上

看情况一:写在接口上

public interface Function0 {

    @NotNull String getInfo( @Size(min = 2, max = 4) @NotBlank String name, @NotBlank String gender, @Min(0) @Max(100) Integer age);

    @NotNull String getInfo(@Valid @NotNull User user);

}
/**
 * 实现类一个校验注解都没有
 */
public class Impl0 implements Function0 {
    @SneakyThrows
    @Override
    public String getInfo(String name, String gender, Integer age) {
        Method currMethod = this.getClass().getMethod("getInfo", String.class,String.class, Integer.class);
        Set<ConstraintViolation<Impl0>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{name,gender, age});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
        return name + " " + gender + " " + age;
    }
    @SneakyThrows
    @Override
    public String getInfo(User user) {
        Method currMethod = this.getClass().getMethod("getInfo", User.class);
        Set<ConstraintViolation<Impl0>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{user});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
        return user.toString();
    }

}

测试类

public class Function0Test {

    @Test
    public void getInfo() {
        new Impl0().getInfo("", "", 0);
    }

    @Test
    public void getInfo2() {
        User user = new User("", "", "", 0);
        new Impl0().getInfo(user);
    }
}

日志

getInfo.name 个数必须在2和4之间: 
getInfo.gender 不能为空: 
getInfo.name 不能为空: 

java.lang.IllegalArgumentException: 参数错误

......

getInfo.user.age 年龄至少为18岁: 0
getInfo.user.occupation 职位不能为空: 
getInfo.user.gender 性别不能为空: 
getInfo.user.name 名字不能为空: 

java.lang.IllegalArgumentException: 参数错误

情况二:接口方法和实现方法上都写,但是实现方法上注解少一点

public interface Function1 {

    @NotNull String getInfo( @Size(min = 2, max = 4) @NotBlank String name, @NotBlank String gender, @Min(0) @Max(100) Integer age);

    @NotNull String getInfo(@Valid @NotNull User user);

}

实现类

/**
 * 实现类校验注解比接口少
 */
public class Impl1 implements Function1 {
    @SneakyThrows
    @Override
    public @NotNull String getInfo(@Size(min = 2, max = 4)  String name, @NotBlank String gender, @Max(100) Integer age) {
        Method currMethod = this.getClass().getMethod("getInfo", String.class,String.class, Integer.class);
        Set<ConstraintViolation<Impl1>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{name,gender, age});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
        return name + " " + gender + " " + age;
    }
    @SneakyThrows
    @Override
    public @NotNull String getInfo( @NotNull User user) {
        Method currMethod = this.getClass().getMethod("getInfo", User.class);
        Set<ConstraintViolation<Impl1>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{user});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
        return user.toString();
    }

}

测试类

public class Function1Test {
    @Test
    public void getInfo() {
        new Impl1().getInfo("", "", 0);
    }

    @Test
    public void getInfo2() {
        User user = new User("", "", "", 0);
        new Impl1().getInfo(user);
    }
}

日志

javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method Impl1#getInfo(User) redefines the configuration of Function1#getInfo(User).

.....

javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method Impl1#getInfo(User) redefines the configuration of Function1#getInfo(User).

A method overriding another method must not redefine the parameter constraint configuration,

方法重写另一个方法的时候,不能重新定义限定注解,也就是说,子类重写父类方法的时候,实现类实现接口方法的时候,如果父类方法或者接口方法中,已经进行了限定注解的配置,那么子类重写方法或者实现类方法都不应该再重新配置限定注解,即自动使用父类或者接口方法中的限定注解

所以,最好写在接口方法上,这样,只用写一次校验注解,所有实现类将自动使用接口方法中的限定注解。不需要再自己写一遍了。这真的太牛了。

接口和实现类上都有注解,以谁为准?

直接说结论,会同时生效。

此外,如果接口和实现类的返回值都标了@Valid,也会报错。

javax.validation.ConstraintDeclarationException: HV000131: A method return value must not be marked for cascaded validation more than once in a class hierarchy, but the following two methods are marked as such: UserService#addUser(String, String, Integer), UserServiceImpl#addUser(String, String, Integer).

A method return value must not be marked for cascaded validation more than once in a class hierarchy

Bean Validation 常用注解

除了 注解必须配合特定的Java类型使用 小节提到的那几个注解以外,还有

@Pattern - 按照正则表达式匹配字符串 重点

真的非常方便

@Data
public class RegexValidationPattern {

    @NonNull
    @NotBlank(message = "值不能为空")
    @Pattern(regexp = "^[a-zA-Z0-9]+",message = "不符合格式")
    private String value;

}

测试类

public class RegexValidationPatternTest {

    @Test
    public void test() {
        RegexValidationPattern pattern = new RegexValidationPattern("###aaabbb09");
        //RegexValidationPattern pattern = new RegexValidationPattern("");
        //RegexValidationPattern pattern = new RegexValidationPattern("");
        Set<ConstraintViolation<RegexValidationPattern>> result = ValidationUtil.getDefaultValidator().validate(pattern);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }

}

输出日志

value 不符合格式: ###aaabbb09

@AssertFalse/@AssertTrue

只能校验 Boolean 类型的字段

@Future/@FutureOrPresent/@Past/@PastOrPresent - 校验时间

比如生日一定是过去的一个时间,预定入住时间肯定是将来的一个时间

有机会再学习

@Digits - 校验字段必须是数字

有机会再学习

@Positive/@PositiveOrZero/@Negative/@NegativeOrZero - 校验数字大于等于 0 还是小于等于 0

有机会再学习

@Email - 校验邮箱地址

有机会再学习

@GroupSequence - 指定校验组的校验顺序

默认情况下,不同组别的约束验证是无序的
在某些情况下,约束验证的顺序是非常的重要的,比如如下两个场景:

  1. 第二个的约束验证依赖于第一个约束执行完成的结果(必须第一个约束正确了,第二个约束执行才有意义)
  2. 某个 Group 组的校验非常耗时,并且会消耗比较大的 CPU/内存。那么我们的做法应该是把这种校验放到最后,所以对顺序提出了要求

写个例子

校验组

public interface Simple {
}


public interface Advanced {
}

@GroupSequence 用在 JavaBean 上表示此 bean 默认的校验组校验序列,其中可以将当前类放在最前面,表示此 JavaBean 中属于 Default 组的校验,

@Data
@GroupSequence({UserWithGroupSequence.class,Simple.class, Advanced.class})
public class UserWithGroupSequence {

    @NotBlank(message = "名字不能为空")
    private String name;

    @NotBlank(message = "性别不能为空")
    private String gender;

    @NotBlank(message = "职位不能为空", groups = Simple.class)
    private String occupation;

    @Min(value = 18, message = "年龄至少为18岁", groups = Advanced.class)
    private int age;

}

测试类

public class UserWithGroupSequenceTest {

    @Test
    public void testUser() {
        UserWithGroupSequence user = new UserWithGroupSequence();
        Set<ConstraintViolation<UserWithGroupSequence>> result = ValidationUtil.getDefaultValidator().validate(user);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }

    @Test
    public void testUser2() {
        UserWithGroupSequence user = new UserWithGroupSequence();
        user.setName("xiashuo");
        user.setGender("男");
        Set<ConstraintViolation<UserWithGroupSequence>> result = ValidationUtil.getDefaultValidator().validate(user);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }
}

输出日志

testUser 的日志,因为默认分组的校验值没有通过,所以后面的校验组都没有输出

---------------
gender 性别不能为空: null
name 名字不能为空: null

testUser2 的日志,因为默认分组的校验值通过了,开始校验 Simple 分组的,但是 Simple 分组的没通过,所以 Advanced 分组的不会进行校验

---------------
occupation 职位不能为空: null

我们还可以将特定的某一种序列放到接口上,然后将此接口当成一个校验组(包含校验组序列)来传递给 validate 方法,这样,我们就可以 存储 很多不同的序列,想用那种用那种,

校验组

public interface Simple {
}
public interface Advanced {
}

校验组组序列,默认把 Default 分组放到最前面

@GroupSequence({Default.class,Simple.class, Advanced.class})
public interface GroupSequence1 {
}

测试

public class UserWithGroupSequenceInterfaceTest {

    @Test
    public void testUser() {
        UserWithGroupSequenceInterface user = new UserWithGroupSequenceInterface();
        Set<ConstraintViolation<UserWithGroupSequenceInterface>> result = ValidationUtil.getDefaultValidator().validate(user, GroupSequence1.class);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }
    @Test
    public void testUser2() {
        UserWithGroupSequenceInterface user = new UserWithGroupSequenceInterface();
        user.setName("xiashuo");
        user.setGender("男");
        Set<ConstraintViolation<UserWithGroupSequenceInterface>> result = ValidationUtil.getDefaultValidator().validate(user,GroupSequence1.class);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }

}

testUser 的日志,因为默认分组的校验值没有通过,所以后面的校验组都没有输出

---------------
gender 性别不能为空: null
name 名字不能为空: null

testUser2 的日志,因为默认分组的校验值通过了,开始校验 Simple 分组的,但是 Simple 分组的没通过,所以 Advanced 分组的不会进行校验

---------------
occupation 职位不能为空: null

跟直接在 JavaBean 上使用 @GroupSequence({UserWithGroupSequence.class,Simple.class, Advanced.class}) 的效果一模一样。

当然,我们还可以创建别的校验组序列,灵活度大大提升。

注解校验顺序

数据校验注解是可以标注在字段、属性、方法、构造器以及 Class 类级别上的。它们的校验顺序,我们是可控的,当然,顺序只能控制在分组级别,无法控制在约束注解级别。因为一个类内的约束(同一分组内),它的顺序是 Set<MetaConstraint<?>> metaConstraints 来保证的,所以可以认为同一分组内的校验器是木有执行的先后顺序的(不管是类、属性、方法、构造器...)。

Hibernate Validator 实现引入的注解

@ScriptAssert - 类校验 重点

@ScriptAssert 可以实现跨字段逻辑校验,

@ScriptAssert 支持写脚本来完成验证逻辑,这里使用的是 javascript(缺省情况下的唯一选择,也是默认选择)

@ScriptAssert 对 null 值并不免疫,不管咋样它都会执行的,所以如果 customer 或者 ticketType 为 null,调用其方法,就会报空指针错误。所以需要我们自己在脚本里判断。

/**
 * 顾客总数不能超过售票的总数
 * 票的类型数也不能超过票的个数
 */
@Data
//在 lang = "javascript" 时,只能通过 size() 获取Set的长度
@ScriptAssert(lang = "javascript", alias = "_", script = "_.ticketCount >= _.customer.size()")
//在 lang = "javascript" 时,既可以通过 size(),也可以通过 length属性 获取List的长度
@ScriptAssert(lang = "javascript", alias = "_", script = "_.ticketCount >= _.ticketType.length")
public class TicketOffice {

    @NonNull
    @Min(1)
    private Integer ticketCount;

    @NonNull
    @Size(min = 0)
    private Set<@NotNull @Valid User> customer;

    @NonNull
    @UniqueElements
    @Size(min = 0)
    private List<@NotNull String> ticketType;

}

测试类

public class TicketOfficeTest {
    @Test
    public void test() {
        HashSet<@NotNull @Valid User> customer = new HashSet<>();
        User user = new User("", "", "", 2);
        customer.add(user);
        ArrayList<@NotNull String> ticketType = new ArrayList<>();
        ticketType.add("住建部");
        ticketType.add("住建部");
        TicketOffice ticketOffice = new TicketOffice(0, customer, ticketType);
        Set<ConstraintViolation<TicketOffice>> result = ValidationUtil.getDefaultValidator().validate(ticketOffice);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }

}

输入日志

customer[].name 名字不能为空: 
ticketCount 最小不能小于1: 0
customer[].age 年龄至少为18岁: 2
customer[].gender 性别不能为空: 
ticketType must only contain unique elements: [住建部, 住建部]
customer[].age 年龄至少为18岁: 2
customer[].occupation 职位不能为空: 
 执行脚本表达式"_.ticketCount >= _.customer.size()"没有返回期望结果: TicketOffice(ticketCount=0, customer=[User(name=, gender=, occupation=, age=2)], ticketType=[住建部, 住建部])
 执行脚本表达式"_.ticketCount >= _.ticketType.length"没有返回期望结果: TicketOffice(ticketCount=0, customer=[User(name=, gender=, occupation=, age=2)], ticketType=[住建部, 住建部])

@ParameterScriptAssert - 方法校验 重点

@ParameterScriptAssert 可以实现一个方法中跨参数的逻辑校验,重点学习

注意,当我们没有在 pom 中配置编译 -parameters 参数的时候,脚本中只能使用 arg0、arg1 来表示参数,但是配置了编译参数之后,就可以直接在脚本中使用原参数名

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.8.1</version>
            <configuration>
                <compilerArgs>
                    <arg>-parameters</arg>
                </compilerArgs>
            </configuration>
        </plugin>
    </plugins>
</build>

测试如下:

/**
 * 顾客总数不能超过售票的总数
 * 票的类型数也不能超过票的个数
 */
@Data
public class TicketOffice4Sell {

    @NonNull
    @Min(1)
    private Integer ticketCount;


    @SneakyThrows
    @ParameterScriptAssert(lang = "javascript",  script = "remainTicketCount >= ticket2sell")
    public void sellTicket(@NotNull @Min(1) Integer remainTicketCount, @NotNull @Min(1) Integer ticket2sell) {
        Method currMethod = this.getClass().getMethod("sellTicket",  Integer.class,Integer.class);
        Set<ConstraintViolation<TicketOffice4Sell>> validResult = ValidationUtil.getDefaultExecutableValidator().validateParameters(this, currMethod, new Object[]{remainTicketCount, ticket2sell});
        if (!validResult.isEmpty()) {
            // ... 输出错误详情validResult
            validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
            throw new IllegalArgumentException("参数错误");
        }
    }

}

测试类

public class TicketOffice4SellTest {

    @Test
    public void test() {
        TicketOffice4Sell test = new TicketOffice4Sell(10);
        test.sellTicket(test.getTicketCount(), 80);
    }

}

输出日志:

sellTicket.<cross-parameter> 执行脚本表达式"remainTicketCount >= ticket2sell"没有返回期望结果: [Ljava.lang.Object;@29d80d2b

@UniqueElements - 集合中是否有重复元素

有机会再学习

@URL - 是否是合法的 URL

有机会在学习

@DurationMax/@DurationMin - 校验时间段

校验 java.time.DurationMax 对象

有机会再学习

@Range - 数字范围 跟@Min/@Max 类似

应用于数值或数值的字符串

@Length - 字符串长度范围 跟@Size 差不多

验证包含的字符串是否在 min 和 max 之间。

@ISBN - 字符串序列是否是 ISBN 序列

International Standard Book Number (ISBN) 国际书号,任何一本出版的书都有一个 ISBN 编号

有机会再学习

@EAN- 字符串序列是否是 EAN 序列

International Article Number (also known as European Article Number or EAN)

有机会再学习

@Currency - 校验货币单位

有机会再学习

@CreditCardNumber - 校验信用卡卡号

有机会再学习

@GroupSequenceProvider - 根据 bean 的字段值动态确定生效的校验组

根据当前对象实例的状态,动态来决定加载那些校验组进入默认校验组,即在运行时动态确定校验组,非常方便。此注解需要配合 Hibernate Validation 提供给我们的 DefaultGroupSequenceProvider 接口才能完成功能。

DefaultGroupSequenceProvider 接口实现:

public class ReservationGroupSequenceProvider implements DefaultGroupSequenceProvider<ReservationWithGroupSequenceProvider> {
    @Override
    public List<Class<?>> getValidationGroups(ReservationWithGroupSequenceProvider bean) {
        List<Class<?>> defaultGroupSequence = new ArrayList<>();
        // 这一步不能省,否则Default分组都不会执行了,会抛错的
        defaultGroupSequence.add(ReservationWithGroupSequenceProvider.class);

        if (bean != null) { // 这块判空请务必要做
            Integer type = bean.getRoomType();
            if (type >= 1 && type <= 2) {
                defaultGroupSequence.add(RoomTypeSmall.class);
                System.err.println("房间类型为:" + type + ",执行RoomTypeSmall组的校验逻辑");
            } else if (type >= 3 && type <= 4) {
                defaultGroupSequence.add(RoomTypeMiddle.class);
                System.err.println("房间类型为:" + type + ",执行RoomTypeMiddle组的校验逻辑");
            } else if (type >= 5 && type <= 6) {
                defaultGroupSequence.add(RoomTypeMax.class);
                System.err.println("房间类型为:" + type + ",执行RoomTypeMax组的校验逻辑");
            }
        }
        return defaultGroupSequence;
    }
}

被校验的 JavaBean

@Data
@GroupSequenceProvider(ReservationGroupSequenceProvider.class)
public class ReservationWithGroupSequenceProvider {


    @NonNull
    @Min(1)
    @Max(6)
    private int roomType;

    /**
     * 如果roomType 为 1-2  customer2Come 人数应该是在5以下
     * 如果roomType 为 3-4  customer2Come 人数应该是在5-10
     * 如果roomType 为 5-6  customer2Come 人数应该是在10-15
     * 其他的值不校验
     */
    @NonNull
    @UniqueElements
    @Size(max = 5, groups = RoomTypeSmall.class)
    @Size(min = 5, max = 10, groups = RoomTypeMiddle.class)
    @Size(min = 10, max = 15, groups = RoomTypeMax.class)
    private List<@Valid User> customer2Come;

}

分组

public interface RoomTypeSmall {
}

public interface RoomTypeMiddle {
}

public interface RoomTypeMax {
}

测试类

public class ReservationWithGroupSequenceProviderTest {

    @Test
    public void testRoomType1() {
        int roomType = 1;
        List<User> customers = new ArrayList<>();
        customers.add(new User("1", "1", "1", 18));
        customers.add(new User("1", "2", "1", 18));
        customers.add(new User("1", "3", "1", 18));
        customers.add(new User("1", "4", "1", 18));
        customers.add(new User("1", "5", "1", 18));
        customers.add(new User("1", "6", "1", 18));
        ReservationWithGroupSequenceProvider reservation = new ReservationWithGroupSequenceProvider(roomType,customers);

        Set<ConstraintViolation<ReservationWithGroupSequenceProvider>> result = ValidationUtil.getDefaultValidator().validate(reservation);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }

    @Test
    public void testRoomType2() {
        int roomType = 3;
        List<User> customers = new ArrayList<>();
        customers.add(new User("1", "1", "1", 18));
        customers.add(new User("1", "2", "1", 18));
        ReservationWithGroupSequenceProvider reservation = new ReservationWithGroupSequenceProvider(roomType,customers);

        Set<ConstraintViolation<ReservationWithGroupSequenceProvider>> result = ValidationUtil.getDefaultValidator().validate(reservation);
        // 对结果进行遍历输出
        System.out.println("---------------");
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }
}

日志输出:

testRoomType1 的日志,

房间类型为:1,执行RoomTypeSmall组的校验逻辑
房间类型为:1,执行RoomTypeSmall组的校验逻辑
---------------
customer2Come 个数必须在0和5之间: [User(name=1, gender=1, occupation=1, age=18), User(name=1, gender=2, occupation=1, age=18), User(name=1, gender=3, occupation=1, age=18), User(name=1, gender=4, occupation=1, age=18), User(name=1, gender=5, occupation=1, age=18), User(name=1, gender=6, occupation=1, age=18)]

testRoomType2 的日志,

房间类型为:3,执行RoomTypeMiddle组的校验逻辑
房间类型为:3,执行RoomTypeMiddle组的校验逻辑
---------------
customer2Come 个数必须在5和10之间: [User(name=1, gender=1, occupation=1, age=18), User(name=1, gender=2, occupation=1, age=18)]

为什么明明只校验了一次,却输出两次日志?

看此博客 解决多字段联合逻辑校验问题【享学Spring MVC】 - YourBatman - 博客园 的原理解析小节。

自定义校验注解

自定义校验注解,是最终的解决方案

自定义注解 MyAnnotation:

@Target({TYPE, ANNOTATION_TYPE,FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = {MyAnnotationConstraintValidator.class})
public @interface MyAnnotation {
    String name();
    int age();
    String message() default "自定义提示信息";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

注解处理类 MyAnnotationConstraintValidator

public class MyAnnotationConstraintValidator implements ConstraintValidator<MyAnnotation, Object> {

    @Override
    public void initialize(MyAnnotation constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
        //初始化方法中可以获取注解当前的各种属性值
        int age = constraintAnnotation.age();
        String name = constraintAnnotation.name();
        Class<?>[] groups = constraintAnnotation.groups();
        String message = constraintAnnotation.message();
        Class<? extends Payload>[] payload = constraintAnnotation.payload();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {

        //value 被校验的值
        if (value == null) {
            return false;
        }
        Map<String, Object> attributes = ((ConstraintValidatorContextImpl) context).getConstraintDescriptor().getAttributes();
        // 这两个时注解陪着信息,想怎么判断,就怎么判断
        System.out.println(attributes.toString());
        boolean isValid = false;

        return isValid;
    }
}

使用

@Data
public class MyAnnotationObj {

    @NonNull
    @MyAnnotation(name = "aaa", age = 1)
    private String property;

}

测试

public class MyAnnotationObjTest {

    @Test
    public void test() {
        MyAnnotationObj obj = new MyAnnotationObj("bbbbb");
        Set<ConstraintViolation<MyAnnotationObj>> result = ValidationUtil.getDefaultValidator().validate(obj);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;
    }

}

日志

property 自定义提示信息: bbbbb

常见自定义注解

@EnumValue - 约定属性的值未特定的范围(字符串数组、数值数组、枚举)

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

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

    //支持string数组验证
    String[] strValues() default {};

    //支持int数组验证
    int[] intValues() default {};

    //支持枚举列表验证
    Class<?>[] enumValues() default {};

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

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

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

    /**
     * 校验类逻辑定义
     */
    class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {

        //字符串类型数组
        private String[] strValues;
        //int类型数组
        private int[] intValues;
        //枚举类
        private Class<?>[] enumValues;

        /**
         * 初始化方法
         *
         * @param constraintAnnotation
         */
        @Override
        public void initialize(EnumValue constraintAnnotation) {
            strValues = constraintAnnotation.strValues();
            intValues = constraintAnnotation.intValues();
            enumValues = constraintAnnotation.enumValues();
        }

        /**
         * 校验方法
         *
         * @param value
         * @param context
         * @return
         */
        @SneakyThrows
        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            //针对字符串数组的校验匹配
            if (strValues != null && strValues.length > 0) {
                if (value instanceof String) {
                    for (String s : strValues) {//判断值类型是否为Integer类型
                        if (s.equals(value)) {
                            return true;
                        }
                    }
                }
            }
            //针对整型数组的校验匹配
            if (intValues != null && intValues.length > 0) {
                if (value instanceof Integer) {//判断值类型是否为Integer类型
                    for (Integer s : intValues) {
                        if (s == value) {
                            return true;
                        }
                    }
                }
            }
            //针对枚举类型的校验匹配
            if (enumValues != null && enumValues.length > 0) {
                for (Class<?> cl : enumValues) {
                    if (cl.isEnum()) {
                        //枚举类验证
                        Object[] objs = cl.getEnumConstants();
                        //这里需要注意,定义枚举时,枚举值名称统一用value表示
                        Method method = cl.getMethod("getValue");
                        for (Object obj : objs) {
                            Object code = method.invoke(obj, null);
                            if (value.equals(code.toString())) {
                                return true;
                            }
                        }
                    }
                }
            }
            return false;
        }
    }
}

自定义校验 message

每个约束定义中都包含有一个用于提示验证结果的消息模版 message,并且在声明一个约束条件的时候,你可以通过这个约束注解中的message 属性来重写默认的消息模版(这是自定义 message 最简单的一种方式

如果在校验的时候,这个约束条件没有通过,那么你配置的 MessageInterpolator 插值器会被用来当成解析器来解析这个约束中定义的消息模版, 从而得到最终的验证失败提示信息。默认使用的插值器是 org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator,继承自 AbstractMessageInterpolator,ResourceBundleMessageInterpolator 借助 org.hibernate.validator.spi.resourceloading.ResourceBundleLocator 来获取到国际化资源属性文件从而填充模版内容。ResourceBundleLocator 默认使用的实现是 PlatformResourceBundleLocator

MessageInterpolator 会尝试解析模版中的占位符 ( 大括号括起来的字符串,形如这样 {xxx})。解析过程参考 AbstractMessageInterpolator 中的 interpolateMessage 方法和 resolveMessage 方法,大概过程如下:

  1. 若没占位符符号 { 需要处理,直接返回(比如我们自定义 message 属性值全是文字,就直接返回了)
  2. 占位符或者 EL,交给 resolveMessage() 方法从资源文件里拿内容来处理,即我们所说的动态解析
  3. 拿取资源文件,按照如下三个步骤寻找:
    1. userResourceBundleLocator:去用户自己的 classpath 里面去找资源文件(默认名字是 ValidationMessages.properties,当然你也可以使用国际化名
    2. contributorResourceBundleLocator:加载贡献的资源包
    3. defaultResourceBundle:默认的策略。去这里 于/org/hibernate/validator 加载 ValidationMessages.properties
  4. 需要注意的是,如上是加载资源的顺序。无论怎么样,这三处的资源文件都会加载进内存的(并无短路逻辑)。进行占位符匹配的时候,依旧遵守这规律
    1. 最先用自己当前项目 classpath 下的资源去匹配资源占位符,若没匹配上再用下一级别的资源。
    2. 规律同上,依次类推,递归的匹配所有的占位符(若占位符没匹配上,原样输出,并不是输出 null 哦)

简单实践

resources 文件夹下创建自定义注解 message 文件 ValidationMessages.properties,说明:因为我的平台是中文的,因此文件命名为 ValidationMessages_zh_CN.properties 的效果也是一样的,因为 Hibernate Validation 提供了 Locale 国际化的支持。

注意,在 message 信息中可以使用占位符{value}读取注解对应属性上的值。此外,因为 { 在此处是特殊字符,若你就想输出 {,请转义:\{

// 此处可以使用占位符{value}读取注解对应属性上的值
xyz.xiashuo.pojo.User.name.NotBlank = [自定义消息]姓名不能为空,实际值
xyz.xiashuo.pojo.User.gender.NotBlank = [自定义消息]性别不能为空,实际值
xyz.xiashuo.pojo.User.occupation.NotBlank = [自定义消息]职位不能为空,实际值
xyz.xiashuo.pojo.User.age.Min = [自定义消息]年龄最小值不能小于{value},实际值

待校验的 bean

@Data
public class UserWithCustomizeMessage {

    @NonNull
    @NotBlank(message = "{xyz.xiashuo.pojo.User.name.NotBlank}")
    private String name;
    @NonNull
    @NotBlank(message = "{xyz.xiashuo.pojo.User.gender.NotBlank}")
    private String gender;
    @NotBlank(message = "{xyz.xiashuo.pojo.User.occupation.NotBlank}")
    private String occupation;
    @NonNull
    @Min(value = 18, message = "{xyz.xiashuo.pojo.User.age.Min}")
    private int age;

}

测试类

public class UserWithCustomizeMessageTest {

    @Test
    public void getName() {
        UserWithCustomizeMessage user = new UserWithCustomizeMessage( "", "", 1);

        Set<ConstraintViolation<UserWithCustomizeMessage>> result = ValidationUtil.getDefaultValidator().validate(user);
        // 对结果进行遍历输出
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValueprintln;

    }
}

输出日志

gender [自定义消息]性别不能为空,实际值: 
age [自定义消息]年龄最小值不能小于18,实际值: 1
name [自定义消息]姓名不能为空,实际值: 
occupation [自定义消息]职位不能为空,实际值: null

源码结构 - 简单梳理

3. 站在使用层面,Bean Validation这些标准接口你需要烂熟于胸 - YourBatman - 博客园

4. Validator校验器的五大核心组件,一个都不能少 - YourBatman - 博客园

ValidatorContext

校验器上下文,根据此上下文创建 Validator 实例。不同的上下文可以创建出不同实例(这里的不同指的是内部组件不同),满足各种个性化的定制需求。

ValidatorContext 接口提供设置方法可以定制校验器的核心组件,它们就是 Validator 校验器的五大核心组件

messageInterpolator

消息插值器。按字面不太好理解:简单的说就是对 message 内容进行格式化,若有占位符 {} 或者 el 表达式 ${} 就执行替换和计算。对于语法错误应该尽量的宽容。

traversableResolver

遍历解析器。从字面上可以解释为:确定某个属性是否能被 ValidationProvider 访问,当每访问一个属性时都会通过它来判断一下子。

constraintValidatorFactory

约束校验器工厂。ConstraintValidator 约束校验器我们应该不陌生:每个约束注解都得指定一个/多个约束校验器,形如这样:@Constraint(validatedBy = { xxx.class })。ConstraintValidatorFactory 就是工厂:可以根据 Class 生成对象实例,在 自定义校验注解 的时候使用过。

parameterNameProvider

参数名提供器。这个组件和 Spring 的 ParameterNameDiscoverer 作用是一毛一样的:获取方法/构造器的参数名。一样的,若你想要打印出明确的参数名,请在编译参数上加上 -parameters 参数。

clockProvider

时钟提供器。这个接口很简单,就是提供一个 Clock,给 @Past、@Future 等阅读判断提供参考。唯一实现为 DefaultClockProvider

ValueExtractor

值提取器。2.0 版本新增一个比较重要的组件 API,作用:把值从容器内提取出来。这里的容器包括:数组、集合、Map、Optional 等等。在 自定义ValueExtractor 中使用过。