java spring validation自定义校验求解

java spring validation自定义校验如何比对两个字段呢?比如密码和确认密码字段必须相等

阅读 6.5k
3 个回答

说来真巧,最近写一个项目,也是要马上写到用户模块了,也要涉及密码一致校验问题,起初也想着前端做了验证,后端对于这点不做也行,就算做了,单独写在业务校验里确实也可以

不过后面越想越觉得不得劲,因为说起校验,除了咱们@NotNull, @Max, @Digits等在javax.validation包中也就是Jakarta Bean Validation规范(后续简称JBV规范)中的校验,还有就是咱们所说的业务校验了,比如查看用户是否存在这种可能跟其它业务或者数据库打交道的,但是自己写也是可以不用@NotNull, @Max等注解,直接在业务校验中校验非空和最大值等,这看个人代码风格习惯了

那我个人习惯肯定是尽量分开了,相当于

  • javax.validation包中的属于简单校验
  • 后面的业务校验属于复杂校验

那这么一说来,这个密码一致校验是属于什么校验呢?我当然认为他是简单校验了。

既然是简单校验那就没有必要写在后续的复杂校验逻辑中,虽然其实实现起来肯定也是简单两三行代码就解决了,但是我还是渴望着类似做到利用JBV规范的机制提供简单自定义注解达到最终校验效果,这样的复用性肯定是最好的

一般咱们肯定要引spring-boot-starter-validation,而其JBV规范的实现其实是Hibernate Validator,所以我跑去看了一下其文档,关于自定义的地方(我用的Hibernate Validator版本是6.2.0.Final哈)

Custom Constraints也就是自定义约束的时候,这里是支持Class-level constraints也就是类级别的约束,看翻译吧,主要就是处理对象中多个属性的关联校验
image.png

而类级别的自定义约束,实现也很简单,写一个检查一致性的注解@CheckConsistency放在需要被校验的类UserRequest

@Data
@CheckConsistency
public class UserRequest {

    private String password;
    private String passwordAgain;
}

这个注解@CheckConsistency定义的时候要被JBV规范中的@Constraint注解注释,并且提供一个怎么校验的校验器填充在其validatedBy中,比如

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { CheckConsistencyValidator.class })
public @interface CheckConsistency {

    // 下面是三个属性是JVB规范的必带属性,没有会报错
    String message() default "不一致";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

而这个自定义校验器CheckConsistencyValidator需要实现JBV规范中的接口ConstraintValidator<CheckConsistency, UserRequest>,其中的两个泛型分别就是自定义的注解@CheckConsistency以及被校验的对象UserRequest

public class CheckConsistencyValidator implements ConstraintValidator<CheckConsistency, UserRequest> {

    @Override
    public boolean isValid(UserRequest request, ConstraintValidatorContext context) {
        return Objects.equals(request.getPassword(), request.getPasswordAgain());
    }
}

基本实现都完成了,简单测试一下是可以的
image.png


V2版本

但是稍加注意,就会发现,现在的实现其实只是针对了UserRequest这一个被校验对象,假设还有个OrderRequest对象,里面也有两个属性namenameAgain要一致性校验,岂不是
这上面的一套注解+Validator就失效了

因此@CheckConsistencyConstraintValidator需要第二版,也就是升级为
@CheckConsistencyV2ConstraintValidatorV2

结合刚才的问题,抽象出共同的,再把不同的作为配置配起来即可,那我们@CheckConsistencyV2应该变为

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { CheckConsistencyValidatorV2.class })
public @interface CheckConsistencyV2 {

    String fieldName1();
    String fieldName2();

    // 下面是三个属性是JVB规范的必带属性,没有会报错
    String message() default "不一致v2";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

可以看到注解中提供两个属性名字fieldName1()fieldName2()来配置这次需要一次性校验的字段名

ConstraintValidatorV2实现的接口ConstraintValidator的泛型就不能再是UserRequest了,而应该是Object了,代码如下

public class CheckConsistencyValidatorV2 implements ConstraintValidator<CheckConsistencyV2, Object> {

    private String fieldName1;
    private String fieldName2;

    /**
     * 这个方法也是{@link ConstraintValidator}中的方法
     * 因为咱们注解中有额外的属性,所以需要这个方法先来初始化我们需要的值
     */
    @Override
    public void initialize(CheckConsistencyV2 constraintAnnotationV2) {
        this.fieldName1 = constraintAnnotationV2.fieldName1();
        this.fieldName2 = constraintAnnotationV2.fieldName2();
    }

    @Override
    public boolean isValid(Object object, ConstraintValidatorContext context) {
        // 这里BeanWrapper只是spring的一种内部接入Java Bean的方式
        // 你也可以选自己喜欢的方式,比如Apache的BeanUtils,我这里就懒得引包了
        BeanWrapper beanWrapper = new BeanWrapperImpl(object);
        Object value1 = beanWrapper.getPropertyValue(this.fieldName1);
        Object value2 = beanWrapper.getPropertyValue(this.fieldName2);
        return Objects.equals(value1, value2);
    }
}

最后咱们使用也还是和之前一样,这次我们UserRequest也升级为UserRequestV2。以示区别

@Data
@CheckConsistencyV2(fieldName1 = "password", fieldName2 = "passwordAgain")
public class UserRequestV2 {

    private String password;
    private String passwordAgain;
}

再来测试一把,一切还是ok的哈
image.png

到这里了,就如刚才所说,假如有个OrderRequest对象,里面也有两个属性namenameAgain要一致性校验,那只是改改属性值,也很简单就复用了

@CheckConsistencyV2(fieldName1 = "name", fieldName2 = "nameAgain")

V3版本

看似一切都差不多了,但是我想到这时,我很不满意,为啥,因为我最讨厌的东西就在上面的实现中,也就是fieldName1 = "password", fieldName2 = "passwordAgain"中的"password""passwordAgain"这两个字符串

很简单,假设某天UserRequestV2password属性名改为pw,但是@CheckConsistencyV2中的fieldName1 = "password"却忘了改,而且打包也都不会报编译错误,可能最终造成现网问题(测试漏测的前提下哈),别问,问就是每次发现这种问题,都要查看代码提交记录,满嘴飘香了,真的是,吃"别人这种屎"也吃太多回了(这也是为啥我后面专门要整一个小工具AutoConstants,扯远了哈)

也就是这种编译都不不会发现的错误,因此我决定再想起他办法,把@CheckConsistencyV2CheckConsistencyValidatorV2升级到@CheckConsistencyV3CheckConsistencyValidatorV3

来到V3,这次主要解决的问题就是声明哪些字段需要一致性校验时最好不要出现字符串这种以后可能出"雷"的做法,那怎样方法最好呢?当然是用注解啦,注解直接标在对应的字段上即可,这样以后改啥名字都无所谓了。

所以我们需要一个标记的注解@ConsistencyGroup来标注哪些字段需要做一致性校验

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConsistencyGroup {
}

当然我们的@CheckConsistencyV3就不需要什么fieldName1()fieldName2()这些方法了,空的就行

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { CheckConsistencyValidatorV3.class })
public @interface CheckConsistencyV3 {

    // 下面是三个属性是JVB规范的必带属性,没有会报错
    String message() default "不一致v3";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

此时UserRequestV3就需要刚才新加的@ConsistencyGroup注解做标注了

@Data
@CheckConsistencyV3
public class UserRequestV3 {

    @ConsistencyGroup
    private String password;
    @ConsistencyGroup
    private String passwordAgain;
}

最后,重要的实现方式是在CheckConsistencyValidatorV3中,处理也很简单,就是扫出有@ConsistencyGroup注解的Field,然后再做取值对比

public class CheckConsistencyValidatorV3 implements ConstraintValidator<CheckConsistencyV3, Object> {

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

        List<Field> consistencyFields = new ArrayList<>();
        ReflectionUtils.doWithFields(object.getClass(), consistencyFields::add,
                field -> field.isAnnotationPresent(ConsistencyGroup.class));
        // 若这里为空或者有@ConsistencyGroup注解的字段不是2个,那就不处理直接返回true
        if (consistencyFields.isEmpty() || consistencyFields.size() != 2) return true;

        List<Object> valueList = consistencyFields.stream()
                                                  .peek(ReflectionUtils::makeAccessible)
                                                  .map(field -> ReflectionUtils.getField(field, object))
                                                  .collect(Collectors.toList());
        return Objects.equals(valueList.get(0), valueList.get(1));
    }
}

还是简单测试一下,很ok
image.png


V4版本

写到这里,我本以为我会很满意,结果可达鸭突然眉头一皱,觉得还是不行,为啥呢?因为假设被校验对象中有两组需要分别校验一致性的字段时,比如下面的UserRequestTemp

@Data
public class UserRequestTemp {

    private String password;
    private String passwordAgain;
    private String name;
    private String nameAgain;
}

其中passwordpasswordAgainnamenameAgain分别做一致性校验,此时我们V3版本就不管用了,这个问题我其实没有在V2中提到,但是V2是可以很简单就解决的,利用@Repeatable注解即可,再新增一个@CheckConsistencyV2List注解作为@CheckConsistencyV2注解的复数容器

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckConsistencyV2List {

    CheckConsistencyV2[] value();
}

从而UserRequestTemp就变为

@Data
@CheckConsistencyV2List({
        @CheckConsistencyV2(fieldName1 = "password", fieldName2 = "passwordAgain"),
        @CheckConsistencyV2(fieldName1 = "name", fieldName2 = "nameAgain")
})
public class UserRequestTemp {

    private String password;
    private String passwordAgain;
    private String name;
    private String nameAgain;
}

最后简单改改CheckConsistencyValidatorV2即可,我这里就不再赘述,回到我们V3的版本,我们是不能简单使用@Repeatable做扩展的,因为我们的@ConsistencyGroup注解只是一个标记注解,里面没有任何属性的

此时其实我们可以直接想到的有两种办法

  • 再增加一个@ConsistencyGroup2的类似@ConsistencyGroup的分组注解

    -> 这样可以临时解决问题,但肯定不是可扩展的长期之道,毕竟增加一个分组注解,就需要修
    改对应的Validator这是不太符合开闭原则的

  • @ConsistencyGroup中新增一个方法,String key(),这样不同分组通过key的方式做区分

    -> 但是这种使用String来控制代码逻辑,又是一个不可控的雷点,前面我也提到了,当然也
    可以改成枚举XXXEnum key(),这样以后需要一组新的分组,去添加一个新的枚举即可,
    只是我还是很不喜欢,毕竟还是不太满足开闭原则

因此我理想的模式其实是可以新增东西,但是不要修改以前已经写好的任何逻辑,真正做到开闭原则,做到可扩展

为了达到可扩展的效果,我们以UserRequestV4举例

@Data
public class UserRequestV4 {
    
    private String password;
    private String passwordAgain;
    private String name;
    private String nameAgain;
}

理想状态下

  • 我想验证passwordpasswordAgain一致性了,我新增一个@PasswordConsistency注解即可达到一致性验证的效果
  • 我想验证namenameAgain一致性了,我新增一个@NameConsistency注解即可达到一致性验证的效果

且上面实现的效果,是不需要我修改以前任何逻辑,只需要新增分组注解即可,即

@Data
@CheckConsistencyV4
public class UserRequestV4 {
    
    @PasswordConsistency
    private String password;
    @PasswordConsistency
    private String passwordAgain;
    @NameConsistency
    private String name;
    @NameConsistency
    private String nameAgain;
}

好,为了实现上面效果,我们就必须引入元注解,也就是注解的注解才可以达到效果

那显然@PasswordConsistency@NameConsistency都是和之前的@ConsistencyGroup的空注解,唯一不同的是它们都被新增的一个元注解@ConsistencyTag标记(注意其@Target此时是ElementType.ANNOTATION_TYPE

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@ConsistencyTag
public @interface NameConsistency {
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@ConsistencyTag
public @interface PasswordConsistency {
}

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConsistencyTag {
}

这样@ConsistencyTag相当于是注解的抽象,类似实现类和接口的关系,@ConsistencyTag这里就是一个"接口",我们的CheckConsistencyValidatorV4只需要去关心处理@ConsistencyTag这个"接口",不用关心它的实现了(也就是@PasswordConsistency@NameConsistency),从而实现了可扩展,实现了开闭原则

那我们来看关键的类CheckConsistencyValidatorV4,如下:

public class CheckConsistencyValidatorV4 implements ConstraintValidator<CheckConsistencyV4, Object> {

    // 这是一个小缓存,因为Class中哪些Field有ConsistencyTag注解是固定的
    private static final Map<Class, List<Field>> cache = new HashMap<>();

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

        List<Field> consistencyFields = cache.computeIfAbsent(object.getClass(), this::getAllConsistencyFields);

        // 若这里为空或者有@ConsistencyTag注解的注解的字段不是偶数个,那就不处理直接返回true
        if (consistencyFields.isEmpty() || consistencyFields.size() % 2 != 0) return true;

        Map<Annotation, List<ConsistencyContext>> annotationListMap = consistencyFields.stream()
                .map(field -> this.toContext(field, object))
                .collect(Collectors.groupingBy(ConsistencyContext::getAnnotation));

        // 这里是展示所有的报错的写法
        List<Annotation> annotations = annotationListMap.entrySet().stream()
                .filter(entry -> this.doNotEqual(entry.getValue()))
                .map(Map.Entry::getKey)
                .collect(Collectors.toList());

        boolean isValid = annotations.isEmpty();
        if (!isValid) {
            context.disableDefaultConstraintViolation();

            annotations.stream()
                       .map(AnnotationUtils::getAnnotationAttributes)
                       .map(map -> map.get(ConsistencyTag.METHOD_KEY).toString())
                       .forEach(message -> context.buildConstraintViolationWithTemplate(message).addConstraintViolation());
        }

        // 这里是展示只返回其中一个报错的写法
//        Optional<Annotation> annotationOptional = annotationListMap.entrySet().stream()
//                .filter(entry -> this.doNotEqual(entry.getValue()))
//                .map(Map.Entry::getKey)
//                .findFirst();
//
//        boolean isValid = !annotationOptional.isPresent();
//        if (!isValid) {
//            context.disableDefaultConstraintViolation();
//
//            Annotation annotation = annotationOptional.get();
//            Map<String, Object> annotationAttributes = AnnotationUtils.getAnnotationAttributes(annotation);
//            String message = annotationAttributes.get(ConsistencyTag.METHOD_KEY).toString();
//            context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
//        }
        return isValid;
    }

    private List<Field> getAllConsistencyFields(Class tClass) {
        List<Field> consistencyFields = new ArrayList<>();
        ReflectionUtils.doWithFields(tClass, consistencyFields::add,
                field -> AnnotatedElementUtils.isAnnotated(field, ConsistencyTag.class));
        return consistencyFields;
    }

    private boolean doNotEqual(List<ConsistencyContext> contexts) {
        return !(contexts.size() == 2 && Objects.equals(contexts.get(0).getValue(), contexts.get(1).getValue()));
    }

    private ConsistencyContext toContext(Field field, Object object) {
        Annotation[] annotations = field.getAnnotations();

        Annotation consistencyTagAnnotation = Stream.of(annotations)
                .filter(annotation -> AnnotatedElementUtils.isAnnotated(
                        AnnotatedElementUtils.forAnnotations(annotation), ConsistencyTag.class))
                .findFirst()
                .get();
        ReflectionUtils.makeAccessible(field);
        Object value = ReflectionUtils.getField(field, object);
        return ConsistencyContext.builder()
                                 .field(field)
                                 .value(value)
                                 .annotation(consistencyTagAnnotation)
                                 .build();

    }

    @Data
    @Builder
    static class ConsistencyContext {
        private Field field;
        private Object value;
        private Annotation annotation;
    }
}

可以看到,实现稍微复杂了一点点,但是思路都是刚才我讲的,那是不变的,还是来测试一下,检查一下效果

image.png

最后看到报错的描述是不同,就是相对之前,需要小小修改一下分组注解,以便可以区分报错,也就是每个分组注解都要加一个message方法,来指明报错

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@ConsistencyTag
public @interface NameConsistency {

    String message() default "name不一致";
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@ConsistencyTag
public @interface PasswordConsistency {

    String message() default "password不一致";
}

以及这个方法的关键字名字定义在@ConsistencyTag

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConsistencyTag {

    String METHOD_KEY = "message";
}

小结

好了,写到这,其实呢,基本已经满足了题主问题的答案了,并且相关思路和代码都罗列在上面(若是代码不是很清楚的,可以到github上查看

但是嗷...说实在话,我还不想去睡的,因为都写到元注解这里了,我们可以说都已经在Hibernate Validator的基础上再搭建了一层关于一致性校验的元注解,也可说是一种规范,涉及的业务是对象多属性校验,那都到这地步了,为何不更近一步,去实现一个更加通用的对象多属性校验的元注解,不仅仅是针对一致性校验,还有很多其他校验,类似如

public class UserRequestOther {
    @ConditionNotNullSource(max = 24)
    private Integer age;
    @ConditionNotNullTarget
    private String companyName;
}

也就是age超过24时,companyName属性不能空,类似这种多属性的校验,提供一种能力,方便这种模式的扩展,当然为了这么做是希望这一套注解@ConditionNotNullSource@ConditionNotNullTarget能复用,正因为是多属性,由于业务不同,不同属性还可以组合,所以这种复用情况确实很少见的,但是这种造轮子对于编程技能还是有些帮助的

V1到V4看似版本迭代,但是实际V1,V2,V3都是造轮子给别人使用,但是V4其实是两个任务

  • 一个给造轮子的人造轮子使用(对应@ConsistencyTag@CheckConsistencyV4CheckConsistencyValidatorV4
  • 还有个就是造轮子给别人使用(对应@PasswordConsistency@NameConsistency

当然V4只是比较局限的给造轮子的人造轮子,想要写成更通用的达到V5,还是需要更多精力

但今晚因为时间关系,狗命要紧( ╯▽╰),还是早点休息吧,以后有机会再说了,当然题主你也可以自己试试咯~那就拜拜了~

默认题主说的是类似 @NotNull 这种校验
可以通过自定义注解+自定义校验规则实现,但是为了这么简单的场景写那么多不太值得

多个字段之间的比较一般有业务属性,已经不是简单的“校验”了,做在校验里反而不灵活
另外确认密码这样的操作,前端校验即可,后端不需要校验

楼上说得对,业务参数校验就自己业务实现;
你非要自己做一套也行,那就自己用aop去做拦截,自己定义校验规则,

推荐问题
宣传栏