Spring常见问题解决 - 对象参数校验失效
一. 对象参数校验失效
我们有时候需要对接口中传入的参数做出校验,我们往往会通过在类对象的属性上添加校验性的注解,来完成快捷的规则校验。下面给出案例。
1.1 案例复现
1.pom
依赖:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2.自定义一个Student
类:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Student{
@Size(max = 10)
private String name;
private Integer age;
}
3.Controller
类:
@RestController
public class MyController {
@PostMapping("/hello")
public Student hello(@RequestBody Student student) {
return student;
}
}
4.请求测试:
我们可以发现,结果并不是我们预想到的,我们对name
传的值的长度很明显超过了10,但是程序并没有进行校验拦截。反而是正常的运行并输出。那么到底是什么原因导致这样的结果呢?
1.2 原理分析
这里就要从Spring
对于HTTP
请求的处理,即请求体-->Java对象
的转换过程来说了。我在Spring常见问题解决 - Body返回体中对应值为null的不输出?这篇文章里面提到过,关于请求体的转换过程,有一段关键的代码,如下:
public class InvocableHandlerMethod extends HandlerMethod {
@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 1.获取参数值
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
// 2.再对参数进行对象转换操作
return doInvoke(args);
}
}
第一步是关于参数值的一个解析的,对于本文案例来说,就是获取Student
实例对象,参数校验也应该发生在这个阶段。因为第二个步骤只是通过反射机制来执行一遍方法而已。我们在第一步中往深入走:
protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
try {
// 通过解析器来解析参数,进行参数绑定
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
}
↓↓↓↓↓↓↓
public class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver {
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
// 这里去遍历所有支持的解析器,去找到合适的解析器
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" +
parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
}
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}
↓↓↓↓↓↓↓
@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
}
那么我们知道,我们在Controller
层中,对于Student
参数的接收,我们加入了@RequestBody
这个注解,那么我们再来debug
看下:
判断条件就是是否添加了@RequestBody
这个注解。关键代码在于:
if (resolver.supportsParameter(parameter))
↓↓↓↓↓↓↓
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(RequestBody.class);
}
}
找到了合适的解析器之后,就应该进行值和对象的装配过程了。
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
↓↓↓↓↓↓↓
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
// 1.消息的转换,读取请求体,转化为对应的Java对象.这里获得的结果是User对象
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
// 2.获取参数名,这里获得的结果是 user
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
// 3.参数的校验过程
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);
}
}
我们看第三步,参数校验validateIfApplicable
:
public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
// 判断是否需要校验
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
// 如果需要校验,再校验。
binder.validate(validationHints);
break;
}
}
}
}
从这里我们看出了一个问题就是,Spring
中,对于请求体的参数校验,是需要满足一定的条件的:
- 代码里需要拥有
@Validated
注解。 - 或者注解的名称需要以
Valid
为开头。
那么知道问题出在哪了,我们的Controller
层代码中,上述的两个条件一个都不满足,那么我们就针对源码来解决。
1.3 问题解决
第一种:添加@Validted
注解。
@RestController
public class MyController {
@PostMapping("/hello")
public Student hello(@Validated @RequestBody Student student) {
return student;
}
}
再次访问接口,结果如下:
控制台输出:可见校验是成功了。
第二种:自定义一个注解,以Valid
开头:
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidName {
}
@PostMapping("/hello")
public Student hello(@ValidName @RequestBody Student student) {
return student;
}
效果是一样的,就不做展示了。
二. 嵌套对象的校验失效
第一个大问题解决好后,我们在其基础上再来看一个问题,我们在Student
对象里面定义一个Teacher
类,然后再Teacher
类里面再进行一个参数校验:
2.1 案例复现
1.Teacher
类:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Teacher {
@Size(max = 5, message = "教师名称长度不能超过5位")
private String teacherName;
}
2.Studeng
类修改:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Student {
@Size(max = 10, message = "姓名长度不能超过10")
private String name;
private Integer age;
private Teacher teacher;
}
3.Controller
代码:
@RestController
public class MyController {
@PostMapping("/hello")
public Student hello(@Validated @RequestBody Student student) {
return student;
}
}
4.发送请求,首先我们来看一个正常的请求:
然后我们给teacher
的名字传的长一点,再看下校验是否通过:
我们可以看到,校验规则失效了。那么这是为什么呢?
2.2 原理分析
我们来看请求体转换过程中,对于参数校验的的这段代码:
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
// 这里我们代码已经满足了对应的条件,添加了@Validted注解
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
// 这一步是执行校验的过程
binder.validate(validationHints);
break;
}
}
}
↓↓↓↓↓↓
public void validate(Object... validationHints) {
Object target = getTarget();
Assert.state(target != null, "No target to validate");
// 首先根据目标的类型定义找出所有的校验点
BindingResult bindingResult = getBindingResult();
// 对每一个校验器执行验证过程
for (Validator validator : getValidators()) {
if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator) {
((SmartValidator) validator).validate(target, bindingResult, validationHints);
}
else if (validator != null) {
validator.validate(target, bindingResult);
}
}
}
我们关注validate()
的逻辑:(注意包名)
package org.hibernate.validator.internal.engine;
public class ValidatorImpl implements Validator, ExecutableValidator {
@Override
public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
sanityCheckGroups( groups );
@SuppressWarnings("unchecked")
Class<T> rootBeanClass = (Class<T>) object.getClass();
// 寻找需要进行校验的元数据信息
BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
// ...
return validateInContext( validationContext, valueContext, validationOrder );
}
}
由于代码比较多,我这里贴出调用栈,看下最终的是如何判断元数据信息需要被校验的:从上述代码beanMetaDataManager.getBeanMetaData( rootBeanClass );
开始:
最终定位到:AnnotationMetaDataProvider .getCascadingMetaData()
:
package org.hibernate.validator.internal.metadata.provider;
public class AnnotationMetaDataProvider implements MetaDataProvider {
private CascadingMetaDataBuilder getCascadingMetaData(JavaBeanAnnotatedElement annotatedElement,
Map<TypeVariable<?>, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) {
return CascadingMetaDataBuilder.annotatedObject( annotatedElement.getType(),
annotatedElement.isAnnotationPresent( Valid.class ),
containerElementTypesCascadingMetaData,
getGroupConversions( annotatedElement.getAnnotatedType() ) );
}
}
意思就是:会根据成员字段是否标记了 @Valid
来决定这个字段以后是否做级联校验(即嵌套类字段)。
2.3 问题解决
很简单,在teacher
字段上添加一个@Valid
注解。表示该字段需要做级联校验。
结果如下:
控制台输出:
2.4 总结
- 进行参数校验的时候,请在
Controller
参数入口处,增加@Validted
注解。 - 倘若有级联属性,类里面嵌套类。需要在对应的属性上增加
@Valid
注解来表示支持级联校验。