前言
Spring Validation 验证框架提供了非常便利的参数验证功能,只需要@Validated
或者@Valid
以及一些规则注解即可校验参数。
本人看网上很多 SpringBoot 参数校验教程以 "单个参数校验" 和 "实体类参数校验" 这两个角度来分类(或者"Get 方法"和"Post 方法"分类,实际上也是一样的,甚至这种更容易让人产生误解)。
这种分类很容易让人觉得混乱:注解 @Validated
一会要标在类上面,一会又要标在参数前;异常又要处理BindException
,又要处理ConstraintViolationException
。
刚看的时候可能还记得住,过一段时间就容易记混了,特别是当两种方式同时在一个类里,就不记得到底怎么用,最后可能干脆全部都加上@Validated
注解了。
本文就从校验机制的角度进行分类,SpringBoot 的参数校验有两套机制,执行的时候会同时被两套机制控制。 两套机制除了控制各自的部份外,有部分是重叠的,这部分又会涉及优先级之类的问题。 但是只要知道了两个机制是什么,且了解
Spring 流程,就再也不会搞混了。
校验机制
这两套校验机制,第一种由 SpringMVC 控制。这种校验只能在"Controller"层使用,需要在被校验的对象前标注@Valid
,@Validated
,或者自定义的名称以'Valid'开头的注解,如:
@Slfj
@RestController
@RequestMapping
public class ValidController {
@GetMapping("get1")
public void get1(@Validated ValidParam param) {
log.info("param: {}", param);
}
}
@Data
public class ValidParam {
@NotEmpty
private String name;
@Min(1)
private int age;
}
另一种被 AOP 控制。这种只要是 Spring 管理的 Bean 就能生效,所以"Controller","Service","Dao"层等都可以用这种参数校验。 需要在被校验的类上标注@Validated
注解,然后如果校验单个类型的参数,直接在参数前标注@NotEmpty
之类的校验规则注解;如果校验对象,则在对象前标注@Valid
注解(这里只能用@Valid
,其他都无法生效,原因后面说明),如:
@Slf4j
@Validated
@RestController
@RequestMapping
public class ValidController {
/**
* 校验对象
*/
@GetMapping("get2")
public void get2(@Valid ValidParam param) {
log.info("param: {}", param);
}
/**
* 校验参数
*/
@GetMapping("get3")
public void get3(@NotEmpty String name, @Max(1) int age) {
log.info("name: {}, age: {}", name, age);
}
}
@Data
public class ValidParam {
@NotEmpty
private String name;
@Min(1)
private int age;
}
SpringMVC 校验机制详解
首先大致了解一下 SpringMVC 执行流程:
- 通过 DispatcherServlet 接收所有的前端发起的请求
- 通过配置获取对应的 HandlerMapping,将请求映射到处理器。即根据解析 url, http 协议,请求参数等找到对应的 Controller 的对应 Method 的信息。
- 通过配置获取对应的 HandlerAdapter,用于实际处理和调用 HandlerMapping。即实际上是 HandlerAdapter 调用到用户自己写的 Controller 的 Method。
- 通过配置获取对应的 ViewResolver,处理上一步调用获取的返回数据。
参数校验的功能是在步骤 3 做的,客户端请求一般通过RequestMappingHandlerAdapter
一系列配置信息和封装,最终调用到ServletInvocableHandlerMethod.invokeHandlerMethod()
方法。
HandlerMethod
这个ServletInvocableHandlerMethod
继承了InvocableHandlerMethod
,作用就是负责调用HandlerMethod
。
HandlerMethod
是 SpringMVC 中非常重要的一个类,大家最常接触的地方就是在拦截器HandlerInterceptor
中的第三个入参Object handler
,虽然这个入参是Object
类型的,但通常都会强转成HandlerMethod
。 它用于封装“Controller”,几乎所有在调用时可能用到的信息,如方法、方法参数、方法上的注解、所属类,都会被提前处理好放到这个类里。
HandlerMethod
本身只封装存储数据,不提供具体的使用方法,所以InvocableHandlerMethod
就出现了,它负责去执行HandlerMethod
,而ServletInvocableHandlerMethod
在其基础上增加了返回值和响应状态码的处理。
这里贴一下源码作者对这两个类的注释:
InvocableHandlerMethod
调用HandlerMethod
的代码:
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
}
第一行getMethodArgumentValues()
就是把请求参数映射到 Java 对象的方法,来看看这个方法:
protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 1. 获取 Method 方法中的入参信息
MethodParameter[] parameters = getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
}
Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
// 2. 初始化参数名的查找方式或框架,如反射,AspectJ、Kotlin 等
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
// 3. 如果 getMethodArgumentValues() 方法第三个传参提供了一个参数,则这里用这个参数。(正常请求不会有这个参数,SpringMVC 处理异常的时候内部自己生成的)
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (!this.resolvers.supportsParameter(parameter)) {
throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
}
try {
// 4. 用对应的 HandlerMethodArgumentResolver 转换参数
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
} catch (Exception ex) {
// Leave stack trace for later, exception may actually be resolved and handled...
if (logger.isDebugEnabled()) {
String exMsg = ex.getMessage();
if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) {
logger.debug(formatArgumentError(parameter, exMsg));
}
}
throw ex;
}
}
return args;
}
方法里最主要的是this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
,调用HandlerMethodArgumentResolver
接口的实现类处理参数。
HandlerMethodArgumentResolver
HandlerMethodArgumentResolver
也是 SpringMVC 中非常重要的一个组件部分,用于将方法参数解析为参数值的策略接口,我们常说的自定义参数解析器。接口有两个方法:
supportsParameter
方法用户判定该 MethodParameter 是否由这个 Resolver 处理
resolveArgument
方法用于解析参数成方法的入参对象。
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}
SpringMVC 自身提供了非常多的HandlerMethodArgumentResolver
实现类,如:
RequestResponseBodyMethodProcessor
(@RequestBody
注解的参数)
RequestParamMethodArgumentResolver
(@RequestParam
注解的参数,或者没其他 Resolver 匹配的 Java 基本数据类型)
RequestHeaderMethodArgumentResolver
(@RequestHeaderMethodArgumentResolver
注解的参数)
ServletModelAttributeMethodProcessor
(@ModelAttribute
注解的参数,或者没其他 Resolver 匹配的自定义对象) 等等。
我们以ServletModelAttributeMethodProcessor
为例,看看其resolveArgument
是怎么样的:
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
// ...
// 获取参数名称以及异常处理等,这里省略。..
if (bindingResult == null) { // bindingResult 为空表示没有异常
// 1. binderFactory 创建对应的 DataBinder
WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);
if (binder.getTarget() != null) {
if (!mavContainer.isBindingDisabled(name)) {
// 2. 绑定数据,即实际注入数据到入参对象里
bindRequestParameters(binder, webRequest);
}
// 3. 校验数据,即 SpringMVC 参数校验的入口
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
// 4. 检查是否有 BindException 数据校验异常
throw new BindException(binder.getBindingResult());
}
}
if (!parameter.getParameterType().isInstance(attribute)) {
// 如果入参对象为 Optional 类型,SpringMVC 会帮忙转一下
attribute = binder.convertIfNecessary(binder.getTarget(), parameter.getParameterType(), parameter);
}
bindingResult = binder.getBindingResult();
}
// 添加绑定结果到 mavContainer 中
Map<String, Object> bindingResultModel = bindingResult.getModel();
mavContainer.removeAttributes(bindingResultModel);
mavContainer.addAllAttributes(bindingResultModel);
return attribute;
}
在代码中步骤 4 调用validateIfApplicable
方法看名字就是校验的,看看代码:
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
for (Annotation ann : parameter.getParameterAnnotations()) {
// 判定是否要做校验,同时获取 Validated 的分组信息
Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
if (validationHints != null) {
// 调用校验
binder.validate(validationHints);
break;
}
}
}
ValidationAnnotationUtils.determineValidationHints(ann)
方法用于判定这个参数对象是否有满足参数校验条件的注释,并且返回对应的分组信息 (@Validated
的分组功能)。
public static Object[] determineValidationHints(Annotation ann) {
Class<? extends Annotation> annotationType = ann.annotationType();
String annotationName = annotationType.getName();
// @Valid 注解
if ("javax.validation.Valid".equals(annotationName)) {
return EMPTY_OBJECT_ARRAY;
}
// @Validated 注解
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null) {
Object hints = validatedAnn.value();
return convertValidationHints(hints);
}
// 用户自定义的以 "Valid" 开头的注解
if (annotationType.getSimpleName().startsWith("Valid")) {
Object hints = AnnotationUtils.getValue(ann);
return convertValidationHints(hints);
}
return null;
}
这里就是开头说的『这种校验只能在"Controller"层使用,需要在被校验的对象前标注@Valid
,@Validated
,或者自定义的名称以'Valid'开头的注解』的 SpringMVC 判定是否要做校验的代码。
如果是@Validated
则返回@Validated
里的分组数据,否则返回空数据,如果没有符合条件的注解,则返回 null。
判定完校验条件,接着binder.validate(validationHints);
会调用到SmartValidator
处理分组信息,最终调用到org.hibernate.validator.internal.engine.ValidatorImpl.validateValue
方法去做实际的校验逻辑。
总结一下:
SpringMVC 的校验是在
HandlerMethodArgumentResolver
的实现类中,resolveArgument 方法实现的代码中编写相应的校验规则,是否校验的判定是由ValidationAnnotationUtils.determineValidationHints(ann)
来决定。然而只有
ModelAttributeMethodProcessor
、AbstractMessageConverterMethodArgumentResolver
这两个抽象类的 resolveArgument 方法编写了校验逻辑,实现类分别为:ServletModelAttributeMethodProcessor(
@ModelAttribute
注解的参数,或者没其他 Resolver 匹配的自定义对象)HttpEntityMethodProcessor(
HttpEntity
或RequestEntity
对象)RequestPartMethodArgumentResolver(
@RequestPart
注解的参数或MultipartFile
类)RequestResponseBodyMethodProcessor(
@RequestBody
注解的对象)开发中经常使用的
@RequestParam
注解的参数或者说单个参数的 Resolver 并没有实现校验逻辑,但是这部分在使用中也能被校验,那是因为这部分校验是交给 AOP 机制的校验规则处理的。
AOP 校验机制详解
在上面『SpringMVC 校验机制详解』部分提到在 DispatcherServlet 的流程中,会有InvocableHandlerMethod
调用HandlerMethod
的代码,这里再回顾一下:
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
}
这个getMethodArgumentValues
方法在上面分析了,会获取到 request 中的参数并校验组装成 Method 需要的参数,这一节看看doInvoke(args)
方法做了什么。
protected Object doInvoke(Object... args) throws Exception {
Method method = getBridgedMethod();
ReflectionUtils.makeAccessible(method);
try {
if (KotlinDetector.isSuspendingFunction(method)) {
return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
}
return method.invoke(getBean(), args);
} catch (IllegalArgumentException ex) {
// ...
// 一堆异常的处理,这里省略
}
}
doInvoke
获取到HandlerMethod
里的 Method 和 Bean 对象,然后通过 java 原生反射功能调用到我们编写的 Controller 里的业务代码。
MethodValidationInterceptor
既然这里获取的是 Spring 管理的 Bean 对象,那么肯定是被"代理"过的,要代理肯定就要有切点切面,那就看看@Validated
注解被什么类调用过。发现有个名叫MethodValidationInterceptor
的类调用到了,
这名字一看就和校验功能有关,且是个拦截器,看看这个类的注释。
注释写的很直接,第一句就说这是 AOP 的MethodInterceptor
的实现类,提供了方法级的校验功能。
MethodValidationInterceptor
算是 AOP 机制中的通知(Advice)部分,由MethodValidationPostProcessor
类注册到 Spring 的 AOP 管理中:
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
implements InitializingBean {
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
// ...
// 省略一部分 set 代码。..
@Override
public void afterPropertiesSet() {
// 切点判定是否由 Validated 注解
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());
}
}
afterPropertiesSet
初始化 Bean的时候,Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
创建了一个AnnotationMatchingPointcut
的切点类,会把类上有Validated
注解的做 AOP 代理。
所以只要是被 Spring 管理的 Bean 就可以用 AOP 机制做参数校验,并且被校验的方法所在的类或接口上要有Validated
注解。。
现在来看一下MethodValidationInterceptor
里的代码逻辑:
public class MethodValidationInterceptor implements MethodInterceptor {
// ...
// 省略构造方法和 set 代码。..
@Override
@Nullable
public Object invoke(MethodInvocation invocation) throws Throwable {
// 跳过 FactoryBean 类的一些关键方法不校验
if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
}
// 1. 获取 Validated 里的 Group 分组信息
Class<?>[] groups = determineValidationGroups(invocation);
// 2. 获取校验器类
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 {
// 3. 调用校验方法校验入参
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
} catch (IllegalArgumentException ex) {
// 处理对象里的泛型信息
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();
// 4. 调用校验方法校验返回值
result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
return returnValue;
}
protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
if (validatedAnn == null) {
Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class);
}
return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
}
}
这里invoke
代理方法主要做了几个步骤:
- 调用
determineValidationGroups
方法获取 Validated 里的 Group 分组信息。优先查找方法上的Validated
注解来获取分组信息,如果没有则用类上的Validated
注解的分组信息。 - 获取校验器类,通常为
ValidatorImpl
- 调用校验方法
ExecutableValidator.validateParameters
校验入参,如果抛出IllegalArgumentException
异常,尝试获取其泛型信息再次校验。如果参数校验不通过会抛出ConstraintViolationException
异常 - 调用校验方法
ExecutableValidator.validateReturnValue
校验返回值。如果参数校验不通过会抛出ConstraintViolationException
异常
总结一下:
SpringMVC 会通过反射调用到 Controller 对应的业务代码,被调用的类就是被 Spring AOP 代理的类,会走 AOP 机制。
校验功能是在MethodValidationInterceptor
类中调用的,调用ExecutableValidator.validateParameters
方法校验入参,调用ExecutableValidator.validateReturnValue
方法校验返回值
SpringMVC 和 AOP 校验机制总结与比对
- SpringMVC 只有方法入参对象前有
@Valid
,@Validated
,或者自定义的名称以'Valid'开头的注解才生效;AOP 需要先在类上标注@Validated
, 然后方法入参前标注校验规则注解(如:@NotBlank
),或者校验对象前标注@Valid
。 - SpringMVC 在
HandlerMethodArgumentResolver
实现类中做参数校验,所以只能在 Controller 层校验生效,并且只有部分HandlerMethodArgumentResolver
实现类有校验功能(如RequestParamMethodArgumentResolver
就没有);AOP 是 Spring 的代理机制,所以只要 Spring 代理的 Bean
即可做校验。 - 目前 SpringMVC 校验只能校验自定义对象的入参,无法校验返回值(现在 Spring 提供的
HandlerMethodArgumentResolver
没有做这个功能,可以通过自己实现 Resolver 来实现);AOP 可以校验基本数据类型,可以校验返回值。 - SpringMVC 在校验不通过时会抛出
BindException
异常(MethodArgumentNotValidException
在 Spring5.3 版本也变为BindException
的子类);AOP
校验在校验不通过时抛出ConstraintViolationException
异常。(Tip: 所以可以通过抛出的异常来判定走的哪个校验流程,方便定位问题)。 - 在 Controller 层校验时会先走 SpringMVC 流程,然后再走 AOP 校验流程。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。