lanicc

Spring的坑——Valid

2021-07-23

由于Spring提供的方法级的参数校验MethodValidationInterceptor无法对参数属性进行校验,于是想要自己实现参数校验逻辑,自定义了一个注解@Validated(和Spring的注解@Validated重名),可是却因此发现了一个Spring的坑…

背景

本人负责在公司的基础组件部,需要开发一些通用的组件提供给其他业务部门使用,开发组件的过程中,发现很多地方都需要用到参数校验,本来是想使用Spring提供的,但是发现不满足需求,于是乎踩了这个坑

先定一下说法

引用hibernate中的一句话

As of Bean Validation 1.1, constraints can not only be applied to JavaBeans and their properties, but also to the parameters and return values of the methods and constructors of any Java type. That way Jakarta Bean Validation constraints can be used to specify

  • 参数校验:本文中指方法入参、返回值校验
  • 参数属性校验:本文中指对方法入参的属性校验

看一下Spring方法级参数校验的实现

使用BeanPostProcessor提供方法校验的拦截器

核心类:
org.springframework.validation.beanvalidation.MethodValidationPostProcessor

该类实现了InitializingBean,在afterPropertiesSet方法中会根据注解@Validated匹配需要拦截的Bean,创建拦截器MethodValidationInterceptor,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

private Class<? extends Annotation> validatedAnnotationType = Validated.class;

@Override
public void afterPropertiesSet() {
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}


protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
}

使用MethodValidationInterceptor触发校验

核心类:
org.springframework.validation.beanvalidation.MethodValidationInterceptor

MethodValidationInterceptororg.aopalliance.intercept.MethodInterceptor的一个实现,看一下核心实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
// Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
}

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

// Standard Bean Validation 1.1 API
ExecutableValidator execVal = this.validator.forExecutables();
Method methodToValidate = invocation.getMethod();
Set<ConstraintViolation<Object>> result;

Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");

try {
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
catch (IllegalArgumentException ex) {
// Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
// Let's try to find the bridged method on the implementation class...
methodToValidate = BridgeMethodResolver.findBridgedMethod(
ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}

Object returnValue = invocation.proceed();

result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}

return returnValue;
}
  • 第20行,校验方法参数
  • 第35行,校验返回值

没有校验参数属性
无法满足我的需求,怎么办,造轮子啊

自己实现

定义注解

类注解-Validated

该注解用来标识需要参数校验的类,和Spring的注解org.springframework.validation.annotation.Validated重名

1
2
3
4
5
6
7
8
9
10
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {

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

Class<? extends ConstraintViolationHandler> handler() default DefaultConstraintViolationHandler.class;

}

参数注解-Valid

该注解用来标识需要进行校验属性的参数,和javax.validation.Valid重名

1
2
3
4
5
6
@Target({ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Valid {

}

BeanPostProcessor实现

org.springframework.validation.beanvalidation.MethodValidationPostProcessor类似,只是把validatedAnnotationType换成了自己定义的Validated注解

MethodValidationInterceptor实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
}

Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
Method methodToValidate = findBridgedMethod(target, invocation.getMethod());

SoValidated soValidated = determineValidatedAnnotation(target, methodToValidate);
Class<?>[] groups = soValidated.value();
Class<? extends ConstraintViolationHandler> handler = soValidated.handler();
ConstraintViolationHandler violationHandler = handler.newInstance();

// Standard Bean Validation 1.1 API
ExecutableValidator execVal = this.validator.forExecutables();
Set<ConstraintViolation<Object>> result;

Object[] arguments = invocation.getArguments();

//validateParameters
try {
result = execVal.validateParameters(target, methodToValidate, arguments, groups);
} catch (IllegalArgumentException | ConstraintDeclarationException ex) {
// Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
// Let's try to find the bridged method on the implementation class...
methodToValidate = BridgeMethodResolver.findBridgedMethod(
ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));

soValidated = determineValidatedAnnotation(target, methodToValidate);
groups = soValidated.value();
handler = soValidated.handler();
violationHandler = handler.newInstance();

result = execVal.validateParameters(target, methodToValidate, arguments, groups);
}
if (!result.isEmpty()) {
violationHandler.handle(result);
}

//Parameter object
Parameter[] parameters = methodToValidate.getParameters();
for (int i = 0; i < parameters.length; i++) {
if (parameters[i].isAnnotationPresent(Valid.class)) {
result = validator.validate(arguments[i], groups);
if (!result.isEmpty()) {
violationHandler.handle(result);
}
}
}

Object returnValue = invocation.proceed();

result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
violationHandler.handle(result);
}

return returnValue;
}

  • 第25行,对参数校验
  • 第47行,对参数属性校验
  • 第56行,对返回值校验

和Spring提供的基本一致,只是加了一个对参数属性的校验

完事,写了个测试,跑了一下,结果很完美

然后就发包了

一切似乎都很完美

一不小心地上一个坑

当我把发的包用在了SpringMVC环境。。。

其实我这个包本来是用在接口上的(service接口),并不提供controller入口,但是为了让测试同学测试方便,于是我又写了个将service接口暴露为http接口的实现,然后就踩坑了。。

坑就是,校验走的不是我自己写的校验,而是跑到了SpringMVC的校验中

发生在RequestResponseBodyMethodProcessor

看一下这个类的说明

1
2
Resolves method arguments annotated with @RequestBody and handles return values from methods annotated with @ResponseBody by reading and writing to the body of the request or response with an HttpMessageConverter.
An @RequestBody method argument is also validated if it is annotated with @javax.validation.Valid. In case of validation failure, MethodArgumentNotValidException is raised and results in an HTTP 400 response status code if DefaultHandlerExceptionResolver is configured.

大致意思就是说,这个类是用来处理加了@RequestBody注解的参数和返回值,如果参数带有@javax.validation.Valid注解,还会对参数进行校验

从这个说明来看,没有任何问题,和我写的实现并不冲突,可是!!!实际上不是这样的阿姨

看一下实现

参数处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

parameter = parameter.nestedIfOptional();
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);

if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}

return adaptArgumentIfNecessary(arg, parameter);
}
  • 第13行,会对参数进行校验

参数校验

1
2
3
4
5
6
7
8
9
10
11
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
if (validationHints != null) {
binder.validate(validationHints);
break;
}
}
}

  • 第4行,根据注解获取校验的hints(我翻译不好这个词。。)

这里看上去也没有任何问题,继续往下

根据注解获取校验的hints

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

/**
* Determine any validation hints by the given annotation.
* <p>This implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
* @param ann the annotation (potentially a validation annotation)
* @return the validation hints to apply (possibly an empty array),
* or {@code null} if this annotation does not trigger any validation
*/
@Nullable
public static Object[] determineValidationHints(Annotation ann) {
Class<? extends Annotation> annotationType = ann.annotationType();
String annotationName = annotationType.getName();
if ("javax.validation.Valid".equals(annotationName)) {
return EMPTY_OBJECT_ARRAY;
}
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null) {
Object hints = validatedAnn.value();
return convertValidationHints(hints);
}
if (annotationType.getSimpleName().startsWith("Valid")) {
Object hints = AnnotationUtils.getValue(ann);
return convertValidationHints(hints);
}
return null;
}

问题来了呀

  • 第23行,竟然用了个startsWith!!!

这个方法上虽然注明了这点custom annotations whose name starts with "Valid",但是很坑呀,于是我提了个issue

issue - AbstractMessageConverterMethodArgumentResolver#validateIfApplicable do more than expected

意思是,AbstractMessageConverterMethodArgumentResolver#validateIfApplicable做的太多

这个issue提了之后,还有个哥哥以为是我不会自定义校验参数的注解…然后给我评论让我google

不过后来官方的人给了回复

说这个已经存在很久了,会在5.3.9版本的文档中添加说明

5.3.9的发行纪要中确实看到了这点

spring_bump539.png

我认为

我还是觉得Spring在这里做的有点多,使用startsWith让我觉得很奇葩,坏味道

Spring改不了,只能是我改,后来我把自己定义的注解,都加了个前缀SO,哈哈哈