Hibernate Validator -对象基础验证(一)(可能是东半球最全的讲解了)

需求背景

最近在做和Excel导入、导出相关的需求,需求是对用户上传的整个Excel的数据进行验证,如果不符合格式要求,在表头最后增加一列“错误信息”描述此行数据错误原因。例如:代理人列不能为空;代理人手机号列如果填写有值肯定是需要符合手机号格式;结算方式列只允许填写“全保费、净保费”字段...

初始想法

其实这些需求在之前是已经实现过了的,只不过周期比较短,小组内每个童鞋都各自定义了一套自己的规则去实现这些逻辑;我去看了下也都大同小异,无非是将Excel数据全部读进来,转化为List<Map<String, Object>>,多线程去校验每行数据的准确性,将校验结果写入CopyOnWriteArrayList(线程安全),大体实现逻辑如下:

//ImportMessageDetail为自定义的错误类,方便追加Excel错误列使用
List<ImportMessageDetail> resultList =new CopyOnWriteArrayList<>();
   mapList.parallelStream().forEach(map ->{
     ImportMessageDetail detail = validate(map, checkModel.getColumnReference(),
           checkModel.getCheckSet(), rowNoMap, dataValidateType);
     if (detail !=null) {
         resultList.add(detail);
     }
});

Hibernate Validator

简单介绍

RESTful的接口服务中,会有各种各样的入参,我们不可能完全不做任何校验就直接进入到业务处理的环节,通常我们会有一个基础的数据验证的机制,待这些验证过程完毕,结果无误后,参数才会进入到正式的业务处理中。而数据验证又分为两种,一种是无业务关联的规则性验证,一种是根据现有数据进行的联动性数据验证(简单来说,参数的合理性以及需要查数据库验证的逻辑业务)。而Hibernate-Validator则适合做无业务关联的规则性验证,而这类验证的代码大多是可复用的。

简单来说,就是Java规定了一套关于验证器的接口。

项目引入Hibernate Validator

  • maven
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.2</version>
</dependency>
<dependency>
    <groupId>com.sun.xml.bind</groupId>
    <artifactId>jaxb-impl</artifactId>
    <version>2.1.12</version>
</dependency>
  • gradle
compile group: 'javax.el', name: 'javax.el-api', version: '3.0.0'
compile group: 'org.glassfish.web', name: 'javax.el', version: '2.2.6'
compile group: 'org.hibernate', name: 'hibernate-validator', version: '5.4.1.Final'
  • 如果本身是Spring Boot项目,无需单独引入,Spring Boot项目中包含此jar包

Spring boot下配置ValidatorFactory

@Configuration
public class ValidatorFactory {
    @Bean
    @ConditionalOnBean(Validator.class)
    public Validator validator(final AutowireCapableBeanFactory autowireCapableBeanFactory) {
        javax.validation.ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                .configure()
                .constraintValidatorFactory(new SpringConstraintValidatorFactory(autowireCapableBeanFactory))
                //.addProperty("hibernate.validator.fail_fast", "true") // 只要有一个验证失败,则返回
                .buildValidatorFactory();

        return validatorFactory.getValidator();
    }
}

注:Hibernate Validator有两种验证模式
1.普通模式,会校验完所有的属性,然后返回所有的校验结果,默认就是这种方式
2.快速失败返回模式,遇到校验不通过的,直接返回,将上述代码中.addProperty("hibernate.validator.fail_fast", "true")放开即可

简单测试

  • 需要验证的bean
@Data
public class Student {

    private Long id;

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

    @NotNull
    @Min(value = 5, message = "年龄不能低于5岁")
    private int age;

    private String stuNo;

    @NotNull
    @Digits(integer = 10, fraction = 2, message = "请保留小数点后2位")
    private BigDecimal salary;

}
  • 基于Spring boot的测试类
@SpringBootTest(classes = SpringApplicationLauncher)
class StudentValidator extends Specification {

    @Autowired
    private Validator validator

    def testStudent() {
        Student student = new Student();
        student.setName("");
        student.setAge(3)
        student.setSalary(new BigDecimal("26.98765").setScale(4, RoundingMode.HALF_UP))

        //整个对象完全校验
        Set<ConstraintViolation<Student>> result = validator.validate(student);
        printfError(result);
        //只校验某个属性
        Set<ConstraintViolation<Student>> result2 = validator.validateProperty(student, "age");
        printfError(result2);
        //主动校验某个属性值是否合规
        Set<ConstraintViolation<Student>> result3 = validator.validateValue(Student.class, "salary", new BigDecimal("123.3434"));
        printfError(result3);

        expect:
        true
    }

    def printfError(Set<ConstraintViolation<Student>> result) {
        System.out.println("================");
        for (ConstraintViolation it : result) {
            System.out.println(it.message);
        }
    }

}
  • 控制台打印结果
年龄不能低于5岁
姓名不能为空
请保留小数点后2位
================
年龄不能低于5岁
================
请保留小数点后2位

内置的校验注解

内置校验注解的位置.jpg

具体注解如何使用请查看源码注释,养成读源码的习惯
有几个地方需要跟大家阐述一下

  • 除了@Empty要求字符串不能全是空格,其他的字符串校验都是允许空格的
  • message是可以引用常量的,但是如@Size里max不允许引用对象常量,基本类型常量是可以的
  • 注意大部分规则校验都是允许参数为null,即当不存在这个值时,就不进行校验了
    另外有几个注解概念容易混淆:
  • @NotEmpty : 加了@NotEmpty的String类、Collection、Map、数组,是不能为null或者长度为0的(String Collection Map的isEmpty()方法)
  • @NotBlank:只用于String,不能为null且trim()之后size>0
  • @NotNull: 不能为null,但可以为empty,没有Size的约束
某些注解满足不了现有需求,需要自定义注解

自定义注解

  • 创建约束性注解
  • 约束性注解实现类
  • 注解使用在指定的属性或者类上
    需求:在保单类型字段上只允许填写具体的文本,填写其他内容均提示错误
/**
 * author:Java
 * Date:2020/5/28 15:24
 * 校验某个字段只能是固定的某些值
 * 比如:是否有车牌,只能填写“是”或者“否”
 * 如果是某个枚举类型里面的text值,就设置enumClass;如果是自定义的某些值,就设置values数组
 */
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdentifyFieldValueValidator.class)
@Documented
public @interface IdentifyFieldValue {

    String message() default "";

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

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

    /**
     * 某个枚举类型
     * @return
     */
    Class enumClass() default DefaultEnum.class;

    /**
     * 固定的某些值
     */
    String[] values() default {};

    @Target({ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        IdentifyFieldValue[] value();
    }

}

Hibernate Validator 自定义注解需要三个属性,可参考jar包里面内置的注解实现

  • message 提示信息,可写死,比如手机号类型,可直接定义为“非合法的手机号类型”,也可以在实现类验证过程中自定义错误信息
  • groups 分组信息,这个属性很重要!可以实现某个对象的属性的校验顺序
  • payload 有效负载,使用者可以通过此属性来给约束条件指定严重级别(不常使用)
    具体此注解的实现类实现由@Constraint(validatedBy = IdentifyFieldValueValidator.class)定义
    下面来看是实现类逻辑
/**
 * author:Java
 * Date:2020/5/28 15:35
 * 某个字段只能填写固定的几个值
 */
@Component
@Log4j2
public class IdentifyFieldValueValidator implements ConstraintValidator<IdentifyFieldValue, String> {

    private Class enumClass;
    private String[] values;

    @Override
    public void initialize(IdentifyFieldValue constraintAnnotation) {
        this.enumClass = constraintAnnotation.enumClass();
        this.values = constraintAnnotation.values();
    }

    @Override
    public boolean isValid(String objVal, ConstraintValidatorContext context) {
        if (StringUtils.isEmpty(objVal)) {
            return true;
        }

        String[] targetArr;
        //非某个注解类型
        if (enumClass == DefaultEnum.class) {
            targetArr = values;
        } else {
            //获取某个注解里面所有的属性值
            targetArr = getAllText(enumClass);
        }
        
        //判断当前字段值是否在自定义的数组或者枚举里
        String obj = Arrays.stream(targetArr).filter(it -> it.equals(objVal)).findAny().orElse(null);
        if (StringUtils.isEmpty(obj)) {
            //返回自定义提示消息
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("字段值只能为:[" + StringUtils.join(targetArr, ",") + "]").addConstraintViolation();
            return false;
        } else {
            return true;
        }
    }
    
    /**
     * 获取某个枚举类型下所有的text值
     * @param enumClass
     * @return
     */
    private static String[] getAllText(Class enumClass) {
        assert enumClass.isEnum();
        String[] arr = null;
        try {
            Enum[] enumConstants = (Enum[]) enumClass.getEnumConstants();
            arr = new String[enumConstants.length];
            //反射获取枚举类中的toString()方法
            Method method = enumClass.getMethod("toString");
            for (int i = 0; i < enumConstants.length; i++) {
                arr[i] = (String) method.invoke(enumConstants[i]);
            }
        } catch (Exception e) {
            log.error("IdentifyFieldValueValidator getAllText failed! enumClass:{}", enumClass.getName());
            e.printStackTrace();
        }
        return arr;
    }

}
//默认枚举类
public enum  DefaultEnum {
}

在某个Bean属性上增加该注解,还是以Student为例

@IdentifyFieldValue(enumClass = OrderType.class)
private String orderType;
@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum OrderType {

    /**
     * 保单类型
     */
    POLICY(0, "保单"),
    REVERSED_POLICY(1, "被冲正保单"),
    CORRECTION_POLICY(2, "冲正保单"),
    ENDORSEMENT(3, "批单"),
    REVERSED_ENDORSEMENT(4, "被冲正批单"),
    CORRECTION_ENDORSEMENT(5, "冲正批单"),
    CANCEL_POLICY(6, "退保"),
    ;


    private final int code;
    @EnumValue
    private final String text;
    OrderType(int code, String text) {
        this.code = code;
        this.text = text;
    }

    @JsonCreator
    public static OrderType get(int value) {
        return Arrays.stream(values()).filter(it -> it.getCode() == value).findAny().orElse(null);
    }

    public static OrderType get(String text) {
        return Arrays.stream(values()).filter(it -> it.getText().equals(text)).findAny().orElse(null);
    }

    @Override
    public String toString() {
        return text;
    }

}

测试类

Student student = new Student();
student.setOrderType("java is the best language in the world!")

//只校验某个属性
Set<ConstraintViolation<Student>> result2 = validator.validateProperty(student, "orderType");
printfError(result2);

输出结果:
字段值只能为:[保单,被冲正保单,冲正保单,批单,被冲正批单,冲正批单,退保]
当然,这是在属性上增加枚举类型,如果是自定义的String[] values数组,也是可以的

属性之间相互依赖---类级别校验

有这样一种场景,只有在Student对象中age>=18,salary字段必须大于100,这种单独在某个属性上增加注解的方式是无法解决的,下面介绍类级别约束

  • 注解类
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AgeSalaryTypeValidator.class)
@Documented
public @interface AgeSalaryType {

    String message() default "当年龄大于18岁时,每月薪水不得低于100元";

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

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

    @Target({ElementType.TYPE, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        AgeSalaryType[] value();
    }

}
  • 校验实现类
public class AgeSalaryTypeValidator implements ConstraintValidator<AgeSalaryType, Student> {
    @Override
    public boolean isValid(Student stu, ConstraintValidatorContext context) {
        //只有满足age>= 18 并且 薪水低于100时,才会提示默认信息
        //当然这种是很简单的业务场景,一旦业务逻辑变得复杂,比如需要根据当前对象的某几个字段去查询数据库确定某个关系的时候,
        // 就显得颇为受用了
        if (stu.getAge() >= 18 && stu.getSalary().compareTo(new BigDecimal(100)) < 0) {
            return false;
        }
        return true;
    }
}

注:实现类默认就是纳入Sprin容器进行管理的,所以在实现类里面可以直接注入已经纳入Spring容器管理的对象,这样方便直接调取数据库查询

  • 实体类(将校验注解直接加在类上面)
@Data
@AgeSalaryType
public class Student
  • 测试类
def testStudent() {
      Student student = new Student();
      student.setName("Java");
      student.setAge(20);
      student.setSalary(new BigDecimal(50));

      //整个对象完全校验
      Set<ConstraintViolation<Student>> result = validator.validate(student);
      printfError(result);

      expect:
      true
    }
  • 测试结果
当年龄大于18岁时,每月薪水不得低于100元

这样就可以解决类字段里面的依赖问题,我举的例子是比较简单的业务场景,逻辑简单,大家可能觉得没必要这么麻烦,但是如果逻辑复杂,比如我们现有逻辑,根据业务人员上传的Excel表格明细,去确认数据库中是否存在和表格中某几个字段都相同的数据,如果存在,则打回,并追加提示该条数据已经上传,具体的上传时间,操作人等信息,这样使用就是“真香”了吖

关于message

每个约束定义中都包含有一个用于提示验证结果的消息模版, 并且在声明一个约束条件的时候,你可以通过这个约束中的message属性来重写默认的消息模版, 如果在校验的时候,这个约束条件没有通过,那么你配置的MessageInterpolator会被用来当成解析器来解析这个约束中定义的消息模版, 从而得到最终的验证失败提示信息. 这个解析器会尝试解析模版中的占位符( 大括号括起来的字符串 ). 其中, Hibernate Validator中默认的解析器 (MessageInterpolator) 会先在类路径下找名称为ValidationMessages.properties的ResourceBundle, 然后将占位符和这个文件中定义的resource进行匹配,如果匹配不成功的话,那么它会继续匹配Hibernate Validator自带的位于/org/hibernate/validator/ValidationMessages.properties的ResourceBundle, 依次类推,递归的匹配所有的占位符.

简单理解,定义message几种方式

  • @NotNull(message = '***不能为空'),主动声明message
  • @NotNull, 如果不主动声明,提示消息会默认jar包下的提示信息,位置如图所示
    默认提示消息.jpg

    message是支持国际化的,所以也可以设置为中文
设置默认提示消息为中文

1.在application.yml配置文件下增加spring.messages.encoding=UTF-8

spring:
  messages:
    encoding: UTF-8

2.设置Idea关于properties文件的UTF-8编码格式,如图所示

idea下设置properties文件的UTF-8.jpg

其实就是读取的上述图里面的ValidationMessages_zh_CN.properties
3.当然,也是支持自定义的文本

@NotNull
@Size(min=5, max=16, message="{username.size}")
private String username;

在自己项目classpath下增加ValidationMessages_zh_CN.properties

classpath下增加文件

文件中的内容username.size = 用户名长度在5-16之间
在校验不通过是可提示此内容,至此,自定义提示消息就配置好了

关于group属性的讲解,比较重要,且听下回分解


Java is the best language in the world
Tips:整理不易,如有转载,请注明出处 https://www.jianshu.com/p/5dcc50f332d1

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,825评论 4 377
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,887评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,425评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,801评论 0 224
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,252评论 3 299
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,089评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,216评论 2 322
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 31,005评论 0 215
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,747评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,883评论 2 255
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,354评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,694评论 3 265
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,406评论 3 246
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,222评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,996评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,242评论 2 287
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 36,017评论 2 281