数据校验在处理应用业务逻辑中是我们必须考虑和面对的问题,我们需要确保输入在一定条件下是正确的,在Spring
应用中我们通常使用Bean Validation
做数据校验。本文主要介绍在使用Bean Validation
过程中遇到的一个奇怪的问题并进行了一定程度上的源码分析。
Bean Validation
首先介绍一下Bean Validation
规范,该规范主要有3
个发展历程:
2009-11-16
初始的1.0
版本主要基于注解定义了JavaBean
验证的元数据模型和API
(这个版本的规范为JSR303)2013
年标准化了Java
平台的约束定义、声明和验证,是1.1
版本(对应的规范为JSR349)- 随着
Java8
的发行,2017
年基于Java8
做了改进优化,是2.0
版本(对应的规范为JSR380)
具体演进流程可以参考下图:
一般我们使用Hibernate Validator
作为Bean Validation
的规范实现。规范与实现的最新Maven
依赖如下:
<dependency> <groupId>javax.validationgroupId> <artifactId>validation-apiartifactId> <version>2.0.1.Finalversion>dependency><dependency> <groupId>org.hibernate.validatorgroupId> <artifactId>hibernate-validatorartifactId> <version>6.1.5.Finalversion>dependency>
通常在SpringBoot
项目中都是直接引入spring-boot-stater-web
,由SpringBoot
自动引入校验依赖。
各个不同版本的Bean Validation
规范对应不同的Hibernate Validator
实现,确保实现与规范版本匹配。
2.0提供的常用约束
javax.validation:validation-api:2.0.1.Final
提供的Constraint
约束主要有:
注解 | 约束说明 |
---|---|
@Null | 注解标注的元素必须为null |
@NotNull | 注解标注的元素必须非null |
@AssertFalse | 注解标注的元素必须为false【只能标注在boolean及其包装类上】 |
@AssertTrue | 注解标注的元素必须为true【只能标注在boolean及其包装类上】 |
@Digits(integer, fraction) | 注解标注的元素整数部分必须不大于integer位,小数部分位数必须不大于fraction位【只能标注在BigInteger、BigDecimal、CharSequence或者byte、short、int、long及其包装类上】 |
@Email(regexp) | 注解标注的元素必须符合邮箱命名格式,可以额外指定必须满足指定正则规则【只能标注在CharSequence】 |
@Size(min,max) | 注解标注的元素长度必须在指定范围内【只能标注在CharSequence、Collection、Map或者Array上】 |
@Pattern(regexp) | 注解标注的元素必须满足指定的正则规则【只能标注在CharSequence上】 |
@NotBlank | 注解标注的字符序列必须非null并且至少包含一个非空字符【只能标注在CharSequence上】 |
@NotEmpty | 注解标注的元素必须非null并且长度非0【只能标注在CharSequence、Collection、Map或者Array上】 |
@Min(value) | 注解标注的元素必须是数字并且大于等于指定值【只能标注在BigInteger、BigDecimal或者byte、short、int、long及其包装类上】 |
@Max(value) | 注解标注的元素必须是数字并且小于等于指定值【只能标注在BigInteger、BigDecimal或者byte、short、int、long及其包装类上】 |
@Negative | 注解标注的元素必须是负数【只能标注在BigInteger、BigDecimal或者byte、short、int、long、float、double及其包装类上】 |
@NegativeOrZero | 注解标注的元素必须是非正数(0或者负数)【只能标注在BigInteger、BigDecimal或者byte、short、int、long、float、double及其包装类上】 |
@Positive | 注解标注的元素必须是正数【只能标注在BigInteger、BigDecimal或者byte、short、int、long、float、double及其包装类上】 |
@PositiveOrZero | 注解标注的元素必须是非负数(0或者正数)【只能标注在BigInteger、BigDecimal或者byte、short、int、long、float、double及其包装类上】 |
@Past | 注解标注的元素必须表示过去的时间【常用于Date、JSR310规范的Instant、LocalDateTime、LocalDate、LocalTime、ZonedDateTime】 |
@PastOrPresent | 注解标注的元素必须表示当前时间或者过去的时间【常用于Date、JSR310规范的Instant、LocalDateTime、LocalDate、LocalTime、ZonedDateTime】 |
@Future | 注解标注的元素必须表示未来的时间【常用于Date、JSR310规范的Instant、LocalDateTime、LocalDate、LocalTime、ZonedDateTime】 |
@FutureOrPresent | 注解标注的元素必须表示当前时间或者未来的时间【常用于Date、JSR310规范的Instant、LocalDateTime、LocalDate、LocalTime、ZonedDateTime】 |
Bean Validation
提供的数据校验支持级联校验、分组校验、自定义校验等功能,具体如何使用在这里就不展开篇幅了。下面让我们看看具体的问题是什么。
方法入参为集合容器对象的非空校验
这里的集合容器指的是List
、Set
、Map
以及Array
,即必须符合@NotEmpty
的注解标注元素约束并且是容器类型。这里以List
为例,先看下面这段代码:
@Slf4j@RestController@AllArgsConstructor@RequestMapping("/valid")public class ValidController { @PostMapping("/list") public boolean list(@RequestBody @Valid @NotEmpty List items) { return true; }}
当以[]
空请求体请求/valid/list
时,你认为@NotEmpty
会不会生效,是否会抛出MethodArgumentNotValidException
?
直接公布答案:不会。
实际上看这里的代码,应该是有两层校验的,一层是@RequestBody
的请求体必须存在校验,另一层才是我们使用@NotEmpty
约束的集合成员数必须非空校验。现在这个请求没有抛出任何异常,但是给这个类标注@Validated
注解后,再次执行却抛出了ConstraintViolationException
异常。这是为什么呢?
源码分析
通过断点调试一步步追踪,我们发现,当为类添加了@Validated
注解后,请求最终会被Spring
的org.springframework.validation.beanvalidation.MethodValidationInterceptor
拦截下来,该拦截器会对方法入参和出参做相应的校验,如果校验失败,那么就抛出ConstraintViolationException
。如下图所示,List
的非空校验失败了。
那么为什么添加了@Validated
注解后,MethodValidationInterceptor
就会拦截并对方法进行参数校验呢?
继续分析源码,发现原来在ValidationAutoConfiguration
自动配置类中会启动一个MethodValidationPostProcessor
方法校验后置处理器。而MethodValidationPostProcessor
在自身实例化后会创建一个匹配@Validated
注解的切入点AnnotationMatchingPointcut
(注解匹配时支持继承获得的@Validated
注解),同时会创建一个方法校验通知/增强MethodValidationInterceptor
,并将两者构建成一个切点切面DefaultPointcutAdvisor
。
同时,由于MethodValidationPostProcessor
是一个BeanPostProcessor
,当SpringBean
实例化后,在postProcessAfterInitialization
方法中,会对符合条件(自身或者父类/接口标注了@Validated
注解)的SpringBean
进行切面增强然后返回代理类。
这样,当代理对象被调用时,在经过RequestResponseBodyMethodProcessor#resolveArgument
方法进行的参数校验后(校验失败将抛出MethodArgumentNotValidException
异常,但这一步识别不了List
的非空校验)后,由MethodValidationInterceptor
进行校验并最终抛出ConstraintViolationException
异常。
RequestResponseBodyMethodProcessor#resolveArgument
里的校验走ValidatorImpl#validate
(由于入参List
被当做Bean
校验,而List
并没有需要校验的成员,所以认为不需要校验),而MethodValidationInterceptor
里的校验走ValidatorImpl#validateParameters
(直接将List
当做参数进行校验)。
到此,我们基本清楚了为什么添加了@Validated
之后,List
上的@NotEmpty
校验会生效。这个问题不太容易发现,而且挺有意思的。
延伸扩展
这里扩展一个问题,在使用OpenFeign
的时候也是可以使用数据校验的。我的问题是:如果将@Validated
与@FeignClient
一起标注在接口上(该服务为B
),那么当服务调用方(该服务为A
)进行远程方法调用时会触发MethodValidationInterceptor
的方法校验吗?还是说只在服务提供方会校验。这个问题的答案在文本有提及,细心的朋友应该知道答案了。