Spring方法级别数据校验:@Validated + MethodValidationPostProcessor

每篇一句

在《深度工作》中作者提出这么一个公式:高质量产出=时间*专注度。所以高质量的产出不是靠时间熬出来的,而是效率为王

相关阅读

【小家Java】深入了解数据校验:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Java】深入了解数据校验(Bean Validation):基础类打点(ValidationProvider、ConstraintDescriptor、ConstraintValidator)
【小家Spring】详述Spring对Bean Validation支持的核心API:Validator、SmartValidator、LocalValidatorFactoryBean...


<center>对Spring感兴趣可扫码加入wx群:Java高工、架构师3群(文末有二维码)</center>


前言

你在书写业务逻辑的时候,是否会经常书写大量的判空校验。比如Service层或者Dao层的方法入参、入参对象、出参中你是否都有自己的一套校验规则?比如有些字段必传,有的非必传;返回值中有些字段必须有值,有的非必须等等~

如上描述的校验逻辑,窥探一下你的代码,估摸里面有大量的if else吧。此部分逻辑简单(因为和业务关系不大)却看起来眼花缭乱(赶紧偷偷去喵一下你自己的代码吧,哈哈)。在攻城主键变大的时候,你会发现会有大量的重复代码出现,这部分就是你入职一个新公司的吐槽点之一:垃圾代码

若你追求干净的代码,甚至有代码洁癖,如上众多if else的重复无意义劳动无疑是你的痛点,那么本文应该能够帮到你。
Bean Validation校验其实是基于DDD思想设计的,我们虽然可以不完全的遵从这种思考方式编程,但是其优雅的优点还是可取的,本文将介绍Spring为此提供的解决方案~

效果示例

在讲解之前,首先就来体验一把吧~

@Validated(Default.class)
public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

// 实现类如下
@Slf4j
@Service
public class HelloServiceImpl implements HelloService {
    @Override
    public Object hello(Integer id, String name) {
        return null;
    }
}

向容器里注册一个处理器:

@Configuration
public class RootConfig {
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        return new MethodValidationPostProcessor();
    }
}

测试:

@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {RootConfig.class})
public class TestSpringBean {
    @Autowired
    private HelloService helloService;

    @Test
    public void test1() {
        System.out.println(helloService.getClass());
        helloService.hello(1, null);
    }
}

结果如图:



完美的校验住了方法入参。

注意此处的一个小细节:若你自己运行这个案例你得到的参数名称可能是hello.args0等,而我此处是形参名。是因为我使用Java8的编译参数:-parameters(此处说一点:若你的逻辑中强依赖于此参数,务必在你的maven中加入编译插件并且配置好此编译参数

若需要校验方法返回值,改写如下:

    @NotNull
    Object hello(Integer id);

    // 此种写法效果同上
    //@NotNull Object hello(Integer id);

运行:

javax.validation.ConstraintViolationException: hello.<return value>: 不能为null
...

校验完成。就这样借助Spring+JSR相关约束注解,就非常简单明了,语义清晰的优雅的完成了方法级别(入参校验、返回值校验)的校验。
校验不通过的错误信息,再来个全局统一的异常处理,就能让整个工程都能尽显完美之势。(错误消息可以从异常ConstraintViolationExceptiongetConstraintViolations()方法里获得的~)


MethodValidationPostProcessor

它是Spring提供的来实现基于方法MethodJSR校验的核心处理器~它能让约束作用在方法入参、返回值上,如:

public @NotNull Object myValidMethod(@NotNull String arg1, @Max(10) int arg2)

官方说明:方法里写有JSR校验注解要想其生效的话,要求类型级别上必须使用@Validated标注(还能指定验证的Group)

另外提示一点:这个处理器同处理@Async的处理器AsyncAnnotationBeanPostProcessor非常相似,都是继承自AbstractBeanFactoryAwareAdvisingPostProcessor的,所以若有兴趣再次也推荐@Async的分析博文,可以对比着观看和记忆:【小家Spring】Spring异步处理@Async的使用以及原理、源码分析(@EnableAsync)

// @since 3.1
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
    // 备注:此处你标注@Valid是无用的~~~Spring可不提供识别
    // 当然你也可以自定义注解(下面提供了set方法~~~)
    // 但是注意:若自定义注解的话,此注解只决定了是否要代理,并不能指定分组哦  so,没啥事别给自己找麻烦吧
    private Class<? extends Annotation> validatedAnnotationType = Validated.class;
    // 这个是javax.validation.Validator
    @Nullable
    private Validator validator;

    // 可以自定义生效的注解
    public void setValidatedAnnotationType(Class<? extends Annotation> validatedAnnotationType) {
        Assert.notNull(validatedAnnotationType, "'validatedAnnotationType' must not be null");
        this.validatedAnnotationType = validatedAnnotationType;
    }

    // 这个方法注意了:你可以自己传入一个Validator,并且可以是定制化的LocalValidatorFactoryBean哦~(推荐)
    public void setValidator(Validator validator) {
        // 建议传入LocalValidatorFactoryBean功能强大,从它里面生成一个验证器出来靠谱
        if (validator instanceof LocalValidatorFactoryBean) {
            this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
        } else if (validator instanceof SpringValidatorAdapter) {
            this.validator = validator.unwrap(Validator.class);
        } else {
            this.validator = validator;
        }
    }
    // 当然,你也可以简单粗暴的直接提供一个ValidatorFactory即可~
    public void setValidatorFactory(ValidatorFactory validatorFactory) {
        this.validator = validatorFactory.getValidator();
    }


    // 毫无疑问,Pointcut使用AnnotationMatchingPointcut,并且支持内部类哦~
    // 说明@Aysnc使用的也是AnnotationMatchingPointcut,只不过因为它支持标注在类上和方法上,所以最终是组合的ComposablePointcut
    
    // 至于Advice通知,此处一样的是个`MethodValidationInterceptor`~~~~
    @Override
    public void afterPropertiesSet() {
        Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }
    
    // 这个advice就是给@Validation的类进行增强的~  说明:子类可以覆盖哦~
    // @since 4.2
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
        return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }
}

它是个普通的BeanPostProcessor,为Bean创建的代理的时机是postProcessAfterInitialization(),也就是在Bean完成初始化后有必要的话用一个代理对象返回进而交给Spring容器管理~(同@Aysnc
容易想到,关于校验方面的逻辑不在于它,而在于切面的通知:MethodValidationInterceptor

MethodValidationInterceptor

它是AOP联盟类型的通知,此处专门用于处理方法级别的数据校验

注意理解方法级别:方法级别的入参有可能是各种平铺的参数、也可能是一个或者多个对象

// @since 3.1  因为它校验Method  所以它使用的是javax.validation.executable.ExecutableValidator
public class MethodValidationInterceptor implements MethodInterceptor {

    // javax.validation.Validator
    private final Validator validator;

    // 如果没有指定校验器,那使用的就是默认的校验器
    public MethodValidationInterceptor() {
        this(Validation.buildDefaultValidatorFactory());
    }
    public MethodValidationInterceptor(ValidatorFactory validatorFactory) {
        this(validatorFactory.getValidator());
    }
    public MethodValidationInterceptor(Validator validator) {
        this.validator = validator;
    }


    @Override
    @SuppressWarnings("unchecked")
    public Object invoke(MethodInvocation invocation) throws Throwable {
        // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
        // 如果是FactoryBean.getObject() 方法  就不要去校验了~
        if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
            return invocation.proceed();
        }

        Class<?>[] groups = determineValidationGroups(invocation);

        // Standard Bean Validation 1.1 API  ExecutableValidator是1.1提供的
        ExecutableValidator execVal = this.validator.forExecutables();
        Method methodToValidate = invocation.getMethod();
        Set<ConstraintViolation<Object>> result; // 错误消息result  若存在最终都会ConstraintViolationException异常形式抛出

        try {
            // 先校验方法入参
            result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        } catch (IllegalArgumentException ex) {
            // 此处回退了异步:找到bridged method方法再来一次
            methodToValidate = BridgeMethodResolver.findBridgedMethod(ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));
            result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        }
        if (!result.isEmpty()) { // 有错误就抛异常抛出去
            throw new ConstraintViolationException(result);
        }
        // 执行目标方法  拿到返回值后  再去校验这个返回值
        Object returnValue = invocation.proceed();
        result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }

        return returnValue;
    }


    // 找到这个方法上面是否有标注@Validated注解  从里面拿到分组信息
    // 备注:虽然代理只能标注在类上,但是分组可以标注在类上和方法上哦~~~~ 
    protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
        Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
        if (validatedAnn == null) {
            validatedAnn = AnnotationUtils.findAnnotation(invocation.getThis().getClass(), Validated.class);
        }
        return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
    }
}

这个Advice的实现,简单到不能再简单了,稍微有点基础的应该都能很容易看懂吧(据我不完全估计这个应该是最简单的)。


==使用细节==(重要)

文首虽然已经给了一个使用示例,但是那毕竟只是局部。在实际生产使用中,比如上面理论更重要的是一些使用细节(细节往往是区分你是不是高手的地方),这里从我使用的经验中,总结如下几点供给大家参考(基本算是分享我躺过的坑)

使用@Validated去校验方法Method,不管从使用上还是原理上,都是非常简单和简约的,建议大家在企业应用中多多使用。

1、约束注解(如@NotNull)不能放在实体类上

一般情况下,我们对于Service层验证(Controller层一般都不给接口),大都是面向接口编程和使用,那么这种@NotNull放置的位置应该怎么放置呢?

看这个例子:

public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

@Validated(Default.class)
@Slf4j
@Service
public class HelloServiceImpl implements HelloService {
    @Override
    public Object hello(Integer id, String name) {
        return null;
    }
}

约束条件都写在实现类上,按照我们所谓的经验,应该是不成问题的。但运行:

javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method HelloServiceImpl#hello(Integer) redefines the configuration of HelloService#hello(Integer).

    at org.hibernate.validator.internal.metadata.aggregated.rule.OverridingMethodMustNotAlterParameterConstraints.apply(OverridingMethodMustNotAlterParameterConstraints.java:24)
...

重说三:请务必注意请务必注意请务必注意这个异常是javax.validation.ConstraintDeclarationException,而不是错误校验错误异常javax.validation.ConstraintViolationException。请在做全局异常捕获的时候一定要区分开来~

异常信息是说parameter constraint configuration在校验方法入参的约束时,若是@Override父类/接口的方法,那么这个入参约束只能写在父类/接口上面~~~

至于为什么只能写在接口处,这个具体原因其实是和Bean Validation的实现产品有关的,比如使用的Hibernate校验,原因可参考它的此类:OverridingMethodMustNotAlterParameterConstraints


还需注意一点:若实现类写的约束和接口一模一样,那也是没问题的。比如上面若实现类这么写是没有问题能够完成正常校验的:

    @Override
    public Object hello(@NotNull @Min(10) Integer id, @NotNull String name) {
        return null;
    }

虽然能正常work完成校验,但需要深刻理解一模一样这四个字。简单的说把10改成9都会报ConstraintDeclarationException异常,更别谈移除某个注解了(不管多少字段多少注解,但凡只要写了一个就必须保证一模一样)。


关于@Override方法校验返回值方面:即使写在实现类里也不会抛ConstraintDeclarationException
另外@Validated注解它写在实现类/接口上均可~

最后你应该自己领悟到:若入参校验失败了,方法体是不会执行的。但倘若是返回值校验执行了(即使是失败了),方法体也肯定被执行了~~

2、@NotEmpty/@NotBlank只能哪些类型上?

提出这个细节的目的是:约束注解并不是能用在所有类型上的。比如若你把@NotEmpty让它去验证Object类型,它会报错如下:

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.NotEmpty' validating type 'java.lang.Object'. Check configuration for 'hello.<return value>'

需要强调的是:若标注在方法上是验证返回值的,这个时候方法体是已经执行了的,这个和ConstraintDeclarationException不一样~

对这两个注解依照官方文档做如下简要说明。@NotEmpty只能标注在如下类型

  1. CharSequence
  2. Collection
  3. Map
  4. Array

注意:""它是空的,但是" "就不是了

@NotBlank只能使用在CharSequence上,它是Bean Validation 2.0新增的注解~

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

这个问题有个隐含条件:只有校验方法返回值时才有这种可能性。

public interface HelloService {
    @NotEmpty String hello(@NotNull @Min(10) Integer id, @NotNull String name);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {
    @Override
    public @NotNull String hello(Integer id, String name) {
        return "";
    }
}

运行案例,helloService.hello(18, "fsx");打印如下:

javax.validation.ConstraintViolationException: hello.<return value>: 不能为空
...

到这里,可能有小伙伴就会早早下结论:当同时存在时,以接口的约束为准
那么,我只把返回值稍稍修改,你再看一下呢???

    @Override
    public @NotNull String hello(Integer id, String name) {
        return null; // 返回值改为null
    }

再运行:

javax.validation.ConstraintViolationException: hello.<return value>: 不能为空, hello.<return value>: 不能为null
...

透过打印的信息,结论就自然不必我多。但是有个道理此处可说明:大胆猜测,小心求证

4、如何校验级联属性

在实际开发中,其实大多数情况下我们方法入参是个对象(甚至对象里面有对象),而不是单单平铺的参数,因此就介绍一个级联属性校验的例子

@Getter
@Setter
@ToString
public class Person {

    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;

    @Valid // 让InnerChild的属性也参与校验
    @NotNull
    private InnerChild child;

    @Getter
    @Setter
    @ToString
    public static class InnerChild {
        @NotNull
        private String name;
        @NotNull
        @Positive
        private Integer age;
    }

}

public interface HelloService {
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {
    @Override
    public String cascade(Person father, Person mother) {
        return "hello cascade...";
    }
}

运行测试用例:

    @Test
    public void test1() {
        helloService.cascade(null, null);
    }

输出如下:

cascade.father: 不能为null, cascade.mother: 不能为null

此处说明一点:若你father前面没加@NotNull,那打印的消息只有:cascade.mother: 不能为null

我把测试用例改造如下,你继续感受一把:

    @Test
    public void test1() {
        Person father = new Person();
        father.setName("fsx");
        Person.InnerChild innerChild = new Person.InnerChild();
        innerChild.setAge(-1);
        father.setChild(innerChild);

        helloService.cascade(father, new Person());
    }

错误消息如下(请小伙伴仔细观察和分析缘由):

cascade.father.age: 不能为null, cascade.father.child.name: 不能为null, cascade.father.child.age: 必须是正数

思考:为何mother的相关属性以及子属性为何全都没有校验呢?

5、循环依赖问题

上面说了Spring对@Validated的处理和对@Aysnc的代理逻辑是差不多的,有了之前的经验,很容易想到它也存在着如题的问题:比如HelloService的A方法想调用本类的B方法,但是很显然我是希望B方法的方法校验是能生效的,因此其中一个做法就是注入自己,使用自己的代理对象来调用:

public interface HelloService {
    Object hello(@NotNull @Min(10) Integer id, @NotNull String name);
    String cascade(@NotNull @Valid Person father, @NotNull Person mother);
}

@Slf4j
@Service
@Validated(Default.class)
public class HelloServiceImpl implements HelloService {

    @Autowired
    private HelloService helloService;

    @Override
    public Object hello(@NotNull @Min(10) Integer id, @NotNull String name) {
        helloService.cascade(null, null); // 调用本类方法
        return null;
    }

    @Override
    public String cascade(Person father, Person mother) {
        return "hello cascade...";
    }
}

运行测试用例:

    @Test
    public void test1() {
        helloService.hello(18, "fsx"); // 入口方法校验通过,内部调用cascade方法希望继续得到校验
    }

运行报错:

Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'helloServiceImpl': Bean with name 'helloServiceImpl' has been injected into other beans [helloServiceImpl] in its raw version as part of a circular reference, but has eventually been wrapped. This means that said other beans do not use the final version of the bean. This is often the result of over-eager type matching - consider using 'getBeanNamesOfType' with the 'allowEagerInit' flag turned off, for example.
    at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean
...

这个报错消息不可为不熟悉。关于此现象,之前做过非常非常详细的说明并且提供了多种解决方案,所以此处略过。

若关于此问的原因和解决方案不明白的,请移步此处:【小家Spring】使用@Async异步注解导致该Bean在循环依赖时启动报BeanCurrentlyInCreationException异常的根本原因分析,以及提供解决方案

虽然我此处不说解决方案,但我提供问题解决后运行的打印输出情况,供给小伙伴调试参考,此举很暖心有木有:

javax.validation.ConstraintViolationException: cascade.mother: 不能为null, cascade.father: 不能为null
...

总结

本文介绍了Spring提供给我们方法级别校验的能力,在企业应用中使用此种方式完成绝大部分的基本校验工作,能够让我们的代码更加简洁、可控并且可扩展,因此我是推荐使用和扩散的~

在文末有必要强调一点:关于上面级联属性的校验时使用的@Valid注解你使用@Validated可替代不了,不会有效果的。
至于有小伙伴私信我疑问的问题:为何他Controller方法中使用@Valid@Validated均可,并且网上同意给的答案都是都可用,差不多???还是那句话:这是下篇文章的重点,请持续关注~

稍稍说一下它的弊端:因为校验失败它最终采用的是抛异常方式来中断,因此效率上有那么一丢丢的损耗。but,你的应用真的需要考虑这种极致性能问题吗?这才是你该思考的~

知识交流

若文章格式混乱,可点击原文链接-原文链接-原文链接-原文链接-原文链接

==The last:如果觉得本文对你有帮助,不妨点个赞呗。当然分享到你的朋友圈让更多小伙伴看到也是被作者本人许可的~==

若对技术内容感兴趣可以加入wx群交流:Java高工、架构师3群
若群二维码失效,请加wx号:fsx641385712(或者扫描下方wx二维码)。并且备注:"java入群" 字样,会手动邀请入群

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

推荐阅读更多精彩内容