valid 和 validated的使用小结

【技术分享】Bean Validation使用篇

2020-07-09马平凡(鲁吉英)

Bean Validation 使用篇

在开始文章之前,先讲一下为什么会写这篇文章,以及阅读后的相应收益。

Bean Validation看起来似乎很简单,很常见,似乎每个学过基于Spring开发web应用的程序员都会用。但今天之所以写下这篇文章是因为我看到项目中有小伙伴误用了Bean Validation,从而导致线上问题。

 

常见的问题有

1. @Valid / @Validated 注解傻傻分不清,两者是否有区别,使用场景是否一致;

2. 参数(字段)上标注了相应约束注解,但是实际情况并不符合预期;

3. 项目抛出

javax.validation.UnexpectedTypeException:HV000030: No validator could be found for constraint;

 

以上大概列举了笔者所在项目组遇到的一些实际问题。作为一个程序猿,追根溯源是一个好的习惯,为此写下这篇文章,一方面是对自己学习的总结,另一方面也希望小伙伴能够正确运用Bean Validation。下文若不特殊说明,参数校验等价于Bean Validation。

 

文章排布:

- 基础使用篇:主要关注如何正确运用、以及如何用好

- 进阶使用篇:主要关注参数校验背后的原理

 

预期收益:

- 知晓如何正确进行参数校验

- 知晓参数校验的最佳实践

 

一、概述

Bean Validation源于JSR-303 ,而JSR303是 Java EE 6 中的一项子规范。JSR349、JSR380是其升级版,添加了一些新的特性。

Oracle公司传统艺能,一流公司定标准,它们只定义了一些校验注解(Constraint),如@Null/@NotNull/@Pattern,位于javax.validation.constraints包下,只提供规范不提供实现。

Hibernate Validator是对这个规范的实现(不要和数据库ORM框架Hibernate联系在一起),并增加了一些自定义校验注解,如@Email/@Length/@Range,位于org.hibernate.validator.constraints包下。

 

1.1 基础使用篇

Bean Validation并非要结合Spring一起使用,对于不依赖的 Spring  的项目,可以手动导入如下依赖:

valid 和 validated的使用小结_第1张图片

在实际的web项目开发中,我们无需手动引入依赖。当依赖spring-boot-starter-web这个starter时,会自动传递相应的Bean Validation依赖。但有一点需要注意,在更新版本的SpringBoot中,默认移除了Bean Validtion相关依赖。具体的对应关系可以参照如下表格:

valid 和 validated的使用小结_第2张图片

 

Controller层校验

假设我们实现了一个Spring REST控制器,想要验证由客户端传入的参数。根据请求方式、携带的内容以及实际应用场景,一般有三类:

- POST Request Body;

- GET PathVariable (如/foos/{id});

- GET Query Param(如url?q=param)

上面三种基本覆盖了大部分的开发场景

 

1.验证Request Body

示例:

valid 和 validated的使用小结_第3张图片

valid 和 validated的使用小结_第4张图片注意此时注解标注的位置,必须放在方法参数上,放在类上会导致校验不生效,行为不符合预期。此外,针对这种情形@Valid 和@Validated两个注解可以混用。

如果校验失败,会抛出一个MethodArgumentNotValidException异常,Spring默认会把这个转为400(Bad Request)请求。

在实际项目开发中,通常会用 ExceptionHandler处理该异常,包裹返回一个更友好的提示:

valid 和 validated的使用小结_第5张图片

通过一个简单的单元测试来验证校验是否生效,是否符合预期行为:

valid 和 validated的使用小结_第6张图片

2. 校验PathVariable/RequestParam

开发中,如果参数个数小于三个,倾向于不写Java Bean来封装参数,而是平铺写到方法入参中。

对于这种情况,需要在入参上直接声明约束注解(如@Min),类上标注@Validated注解。

示例:

valid 和 validated的使用小结_第7张图片

注意:在类级别上标注@Validated注解告诉Spring需要校验方法参数上的约束。

 

在这种场景里@Validated注解修饰类。不同于针对Request Body的校验,此处失败会触发ConstraintViolationException 异常。

valid 和 validated的使用小结_第8张图片

然后通过一个简单的单测来验证下:

valid 和 validated的使用小结_第9张图片

Service层校验

除了在控制器级别验证输入之外,通常我们还习惯在Service层进行验证。此处 Service层是一个笼统的概念,也有称之为的 Business层、Loigc层,本文统称为 Service层,不做详细区分。

 

复杂对象示例

valid 和 validated的使用小结_第10张图片

上面给出了5种注解标注位置,只有第一种标注是正确的,其余全部是误用。这会导致标注在RequestParam类字段上的校验全部没有生效。校验没有生效会导致Service层拿着无效的数据进行计算,也可能导致DAO层存储了脏数据。

注意在 Service层进行校验,需要组合两个注解一起使用。

下面通过一个单元测试来验证下:

valid 和 validated的使用小结_第11张图片

简单参数示例

valid 和 validated的使用小结_第12张图片

同样需要注意标注的位置。

还是通过单元测试来验证下

valid 和 validated的使用小结_第13张图片

小结

最后以一张图小结。至于为什么凡是在Class上标注@Validated注解的看起来用法都一样,这个疑问留到下一篇文章中解答。

valid 和 validated的使用小结_第14张图片

1.2 进阶使用篇

从这里开始将会介绍一些高级特性:

- 嵌套校验

- 自定义校验器

- 分组校验

- 手动校验

- Fail Fast

 

嵌套校验

上文提到过针对Java Bean的校验,里面的字段都是非嵌套。实际的业务场景中,对象内字段类型也是对象的场景并不罕见。

示例:

可以看到此处的 Input有一个 person字段,该字段指向另一个Java Bean。针对这种场景,需要在person字段上标注@Valid注解,并且该字段指向的类同样需要标注约束注解。

代码中经常会看到有的同学这样写:

valid 和 validated的使用小结_第15张图片

此时person字段上只会校验非空,Person类中标注的注解并不会生效,从而不符合预期。

通过一个简单的单元测试验证一下:

valid 和 validated的使用小结_第16张图片

 

valid 和 validated的使用小结_第17张图片

自定义校验

有时候,官方提供的约束注解并不能满足所有业务需求场景,这个时候可以通过自定义注解来满足自定义需求。整个过程的核心是定义约束注解,并实现 ConstraintValidator接口,提供对应校验注解的处理类。

以一个具体的例子来看:

valid 和 validated的使用小结_第18张图片

 

 

 

 

valid 和 validated的使用小结_第19张图片

valid 和 validated的使用小结_第20张图片valid 和 validated的使用小结_第21张图片valid 和 validated的使用小结_第22张图片分组校验

通常,某些Java Bean在不同的请求之间共享。以典型的CRUD操作为例:Create请求和Update请求很可能都采用相同的对象类型作为输入。但是,在不同的情况下可能会触发不同的验证。

valid 和 validated的使用小结_第23张图片

valid 和 validated的使用小结_第24张图片

valid 和 validated的使用小结_第25张图片

此处提供的示例都是在Controller层完成,如果想要在Service层完成分组校验,需要注意@Validated注解标注的位置。下面简单提供一个示例:

valid 和 validated的使用小结_第26张图片

密切注意此处注解标注的位置,以及使用了三个注解。

 

手动校验

在某些情况下,我们可能希望以编程方式(注解对应声明式)调用验证,并且不依赖于Spring内置的Bean Validation支持。在这种情况下,我们可以手动创建一个验证器,调用它来触发验证:

valid 和 validated的使用小结_第27张图片

或者有时候我们想偷点懒,不想写一些样板代码,此外上述手动获取到的validator并非单例,借助下Spring的能力也是可以接受的,那么可以这样做:

valid 和 validated的使用小结_第28张图片

Fail Fast

Bean Validation默认会校验完所有字段,然后才抛出异常。可通过一些简单的配置,开启Fali Fast模式,一旦校验失败就立即返回。

参考如下配置:

valid 和 validated的使用小结_第29张图片

最佳实践

Bean Validation只要用对了,符合预期就是没有问题的。但是实际生产实践中,经常会遇到一些不优雅的做法,此处试图给出一些通用的最佳实践。

 

不要在持久化层进行校验

根据上面的知识其实可以知道,其实在任何一层进行Bean Validation都是可以的,但是上面举例特意避开了持久化层的校验。

这是为什么呢?

在一个常见的web应用程序中,持久层是最下面的,上面通常还有业务层和表示层。数据传到表示层,通过业务层,最后到达持久层。如果我们只在持久层进行验证,上面两层就会使用无效、非法的数据进行运算,这些计算可能大部分场景都是无意义的。

问题越早暴露越好,对于校验来说也是如此,这一点也契合Fail Fast Principle。

 

不要散弹枪式校验

有时候校验太少会有问题,但实际开发中,校验太多同样会导致问题。

数据在通过表示层进入系统之前已经使用Bean Validation进行验证。表示层将传入的数据转换为可以传递给业务层的对象。但是经常会遇到业务层不信任上一层,因此它使用再次验证该对象。在实际项目中,经常可以看到在执行实际的业务逻辑之前,业务层又以编程方式检查我们能想到的每个约束,以此来能保证业务层安全。更极端的是,持久层在数据存储到数据库之前再次验证数据。

上面的流程似乎很好地践行了防御式编程,看起来似乎很美好。但与之而来的问题是校验逻辑散落四处,既增加维护难度,又浪费了CPU。很多校验是冗余的,这种防御式编的确程能让我们写出安全的代码,但不能写出优雅的代码。

简而言之,我们应该有一个清晰且集中的验证策略,而不是在任何地方验证所有的东西。

最后解答下标题,何为散弹枪式校验。

散弹枪可以同时射向多个角度,对应我们代码散落四处的冗余校验。对于用枪高手,可能一把精致的手枪同样可以一击毙敌;作为程序员是否能够做到一处校验,四处安全呢?

有意识的校验

Bean Validation是一个很好的工具。但是有了好的工具,能用好也同样重要。

我们应该有一个清晰的验证策略,告诉我们在哪里验证,什么时候使用哪个工具进行验证,而不是对所有的东西都使用Bean Validation。

一般来说,对于跟业务耦合较小的字段(如字段判空,手机号,IP地址,Email地址)尽可能前移到Controller层。基于声明式注解来完成这种校验是相对比较优雅且合适的。

对于需要结合具体业务才能完成的校验(如判断房源编码是否存在)统一收口到业务代码中。

 

总结

 

行文至此,简单回顾下开头提出的三个问题:

1. @Valid / @Validated 注解傻傻分不清,两者是否有区别,使用场景是否一致;

2. 参数(字段)上标注了相应注解,但是实际情况并不符合预期;

3. 项目抛出

javax.validation.UnexpectedTypeException:HV000030: No validator could be found for constraint

可以看到,本篇文章中只解答了第二个问题,对于第一个、第三个并未过多涉及。这篇文章的侧重点是如何正确使用,而不是探究原理性的知识。在下一篇文章中,笔者将会对剩下的疑问给出答案,敬请期待。

 

参考文献

1. All You Need To Know About Bean Validation With Spring Boot

2. 概述 Bean Validation 规范

3. 官方文档

4. Bean Validation 技术规范特性概述

可点击原文链接查看

 

【技术分享】Bean Validation进阶篇

2020-07-15马平凡(鲁吉英)

Bean Validation 进阶篇

在上一篇文章中,主要讲述了如何正确使用Bean Validation,回答第一篇文章开头提出的第二个问题。

本篇文章会对剩余的问题进行解答,并深入下Bean Validation的原理。

 

本篇文章主要解答如下问题:

- @Valid、@Validated 注解异同,使用场景

- UnexpectedTypeException:HV000030: No validator could be found for constraint问题分析

- Hibernate Validator原理分析,主要想回答如何进行校验,如何根据约束注解查找到对应的校验器,校验器和约束注解的关系

- Spring Validation的原理分析,主要想回答什么时候发生了校验,Java Bean校验与方法级别校验的区别,Spring在这种之中又替我们做了什么

 

文章主要有三部分,结构如下:

- @Valid/@Validated注解的异同;

- 结合常见问题分析Hibernate Validator的原理;

- Spring在Bean Validation中做了什么;

 

预期收益:

- 熟悉Hibernate Validator相关源码,熟悉常见问题排查思路;

- 加深对Spring中高阶特性(如参数封装、参数校验、AOP实现原理、后置处理器)的理解;

 

@Valid/@Validated注解

@Validated是Spring提供的校验注解,而**@Valid**是JSR303提供的。对于两者的差异,通过一个表格呈现给大家。

valid 和 validated的使用小结_第30张图片

通过上面的表格,可以很清楚的看到二者的主要差别在于两点。

- @Valid不支持分组校验,而**@Validted支持。如果想要使用Bean Validation**的一些高级特性,必然需要依赖Spring;

- @Valid可以标在字段上,但是**@Validted不能。如果想要使用嵌套校验,必须要借助@Valid**。

深入Hibernate Validator原理

我们通过HV000030: No validator could be found for constraint问题为引子,深入分析一下Hibernate Validator的原理。

HV000030问题经常出现在如下场景:

- 注解使用不恰当,int类型参数使用 @NotBlank进行校验;

- validation-api 与hibernate-validator 版本不匹配;

对于api与实现类版本不一致,导致冲突的问题,可以参照这个表格来解决

valid 和 validated的使用小结_第31张图片

下面着重分析下第一个问题:

依赖

valid 和 validated的使用小结_第32张图片

代码准备

valid 和 validated的使用小结_第33张图片

分析

 

Hibernate Validator的实现较为复杂,此处我们直接循着报错的堆栈直接定位到问题方法处:

ConstraintValidatorManagerImpl#getInitializedValidator,代码如下:

valid 和 validated的使用小结_第34张图片

下面跟进

AbstractConstraintValidatorManagerImpl#createAndInitializeValidator方法,具体看下如何查找Validator:

valid 和 validated的使用小结_第35张图片

 

在跟进findMatchingValidatorDescriptor方法之前,必须要了解一下什么是Descriptor。

其实根据笔者的理解,Descriptor这个东西类似编程语言的元数据。

此处一共涉及两个Descriptor,一个是ConstraintDescriptorImpl,另一个是ConstraintValidatorDescriptor。我们窥测一下类对应的对象里面存储的内容:

ConstraintDescriptorImpl:这个对象里面主要存储的是关于校验的一些元信息,例如约束注解是什么,约束注解的属性。

valid 和 validated的使用小结_第36张图片

ConstraintValidatorDescriptor:这个对象里面主要是一些关注Validator的元信息。根据这些信息可以构建出具体的Validator。

valid 和 validated的使用小结_第37张图片

下面这个是根据

findMatchingValidatorDescriptor传入待校验字段的校验描述、待校验字段的类型获取到的。最核心的就是validatorClass,后续跟根据这个类信息做反射,从而获取具体的validator。

理清楚这里的关系,直接深入到findMatchingValidatorDescriptor方法:

valid 和 validated的使用小结_第38张图片

到这里就可以结合具体的参数来看了。当我们校验了这样一个参数:

valid 和 validated的使用小结_第39张图片

当我们校验第一个参数时,约束为@Min(12)时,TypeHelper#getValidatorTypes方法返回一个Map,其中Key为校验器能校验的类型,value为校验器对应的元数据(即描述文件)。

valid 和 validated的使用小结_第40张图片

valid 和 validated的使用小结_第41张图片

这个Map中存在多种类型,我们需要针对我们的传入的参数类型(如String)获取到合适的ConstraintValidatorDescriptor。于是有了下面的:

valid 和 validated的使用小结_第42张图片

我们待校验的参数是String类型,最后返回的值为:

valid 和 validated的使用小结_第43张图片

最后根据java.lang.CharSequence获取对应的ConstraintValidatorDescriptor,即

最后一步就是反射、初始化并返回

valid 和 validated的使用小结_第44张图片

上面的分析是针对p.setName("abc"); //<---约束@Min(12),name类型为String时的校验,这个时候是能找到具体的校验器MinValidatorForCharSequence。

下面看一下对参数p.setAge(12); //<---约束@NotBlank,age类型为int的校验。这个时候找不到合适的校验器,会抛出异常。

对于NotBlank,内置了一个校验器NotBlankValidator。该校验器能校验的类型为CharSequence。但是我们传入的是int类型,最终的结果就是找不到合适的ConstraintValidatorDescriptor。

valid 和 validated的使用小结_第45张图片

valid 和 validated的使用小结_第46张图片

最终会执行到

ConstraintTree#getInitializedConstraintValidator方法:

至此分析结束,下面简单总结下上述流程。

valid 和 validated的使用小结_第47张图片

宏观来看,整个解析过程就根据类型获取对应的ConstraintValidatorDescriptor,而后根据Descriptor构建出对应的Validator。如果单纯从思录来看,其实就是计算机科学中的 表驱动法。关于表驱动的更多内容,可自行查阅相关资料。

在本节头部也讲过,如果validation-api 与hibernate-validator 版本不匹配也会抛出同样的问题。譬如:validation-api2.x已经收录了

@javax.validation.constraints.NotEmpty,但hibernate-validator 5.x中还没有相应的Validator。

这样还是回到上面的流程,找不到合适的Validator,最终抛出异常,此处就不对同类问题做详细分析。

至此,我们解答了两个问题:

- HV000030异常

- 查找合适Validator的流程

 

 

下一个步骤就是看下具体是如何校验的,直接进入如下方法:

SimpleConstraintTree#validateConstraints

valid 和 validated的使用小结_第48张图片

ConstraintTree#validateSingleConstraint方法:

valid 和 validated的使用小结_第49张图片

当进入到 validator#isValid方法后,后续流程就跟自定义校验器是一样的,此处也不在赘述。

在这过程还可以发现,一个约束注解可能会对应多validator。

至此,对于Hibernate Validator的探索也就暂时告一段落。分析源码过程中,时刻提醒自己此刻的目标是什么,这样才不会乱花渐欲迷人眼。

Spring Validation

术语解释

- 方法级别的数据校验

- SpringMVC支持的Java Bean校验

 

在上一篇文章中经常可以在Controller层看到这样的代码:

valid 和 validated的使用小结_第50张图片

并且还告诉大家,这个时候**@Valid**、@Validated可以混用,但是没有告诉大家为啥可以混用。

此外还能经常看见这样的请求:

valid 和 validated的使用小结_第51张图片

这个时候**@Validated**必须标注在类上,否则校验不生效。

对于前者请求,其实是SpringMVC支持的Java Bean验证。为了使用上SpringMVC的能力,我们必须提供一个Java Bean。但是实际开发中,对于少量参数,我们会习惯性地的平铺展开。对于后者请求使用的校验其实使用的是方法级别的校验

 

SpringMVC支持的Java Bean校验

案例

valid 和 validated的使用小结_第52张图片

原理分析

在Controller层,根据已有的知识,对于标注了**@RequestBody**注解的方法参数,SpringMVC会提供相应的HandlerMethodArgumentResolver进行参数解析。具体到这个案例就是

RequestResponseBodyMethodProcessor 。同时还可以留意到,这个处理器还可以充当HandlerMethodReturnValueHandler,用于处理返回值。

valid 和 validated的使用小结_第53张图片

下面深入到

RequestResponseBodyMethodProcessor 方法:

valid 和 validated的使用小结_第54张图片

valid 和 validated的使用小结_第55张图片

最终跟下去,会遇到:

valid 和 validated的使用小结_第56张图片

通过上面的分析,我们知道为啥@Validated、@Valid两个注解为什么可以混用,也知道了校验发生的场景。兜兜转转,最终还是委托给Hibernate Validator来完成最底层的校验工作。

这种由Spring MVC支持的JavaBean校验,天然需要一个Java Bean。但是实际场景中我们经常会平铺展开参数,并不想写一个Java Bean来封装参数。对于后者,Spring也提供了支持。

 

方法级别的数据校验

案例

valid 和 validated的使用小结_第57张图片

 

原理分析

对于Service层代码,没有SpringMVC的加持,我们需要借助方法级别的参数校验。这些工作都由MethodValidationPostProcessor完成。

PostProcessor、InitializingBean都是Spring提供的一个拓展点,可以在Spring容器完成实例化、配置和初始化bean之后,插入一些自定义逻辑。关于拓展点的知识,这部分不是今天的重点,感兴趣的可以自行查阅资料。

对于熟悉Spring的同学,估计看到PostProcessor和案例代码可能会会心一笑,联想到代理和AOP机制。参数前标注了注解,必然需要相应的注解处理器。由此可以推测方法级别的校验的原理跟AOP类似。

valid 和 validated的使用小结_第58张图片

接着看一下MethodValidationInterceptor:

valid 和 validated的使用小结_第59张图片

其实可以看到这个MethodInterceptor并不复杂,只是里面混合了AOP的相关知识让其看起来很复杂。

通过上面的分析,可以看到Sping在整个Bean Validation做了什么工作。其实不管是Java Bean校验还是方法级别的校验,最终都都得回归到Hibernate Validator。Spring只是在其上做了一层封装,使得可以更好跟Spring框架结合,更佳易用,方便开发。

 

  总结

 

最后,对上下两篇文章做一个整体的总结。

上篇文章中,花了许多笔墨介绍如何优雅地进行参数校验,并试图给出了一些可以覆盖绝大部分场景的最佳实践。最佳实践一定要结合具体应用场景做具体分析,切不可盲目套用。

通读上篇文章,应该可以学会如何进行校验,以及识别哪些是错误的运用。但如果想更进一步,了解背后原理,那么下篇就是为你准备的。

这篇文章,循着问题粗粒度地介绍了Hibernate Validator的部分原理。基于此,又研究了Spring在整个Bean Validation过程中做了什么工作,其中最重要的是要区分Java Bean校验与方法级别校验,明白各自的实现路径。

行文将此,Bean Validation的话题就要告一段落了。其实这一块的内容很庞杂,很多地方的分析也是点到为止,并未做过多深入。笔者也只是找了几个自己感兴趣的问题,以此为契机深入探索一二。带着问题去找答案效率远高于为了看源码而看。期待大家都能有所收获,不虚此行。


 

参考文献

1.JSR303、349 -Bean Validation 数据校验规范使用说明和验证流程源码分析

2.Jakarta Bean Validation specification

3.No validator could be found for constraint

4.官方文档

你可能感兴趣的:(Java基础)