Spring 数据校验
Spring 数据校验
1 问题
在Controller的方法里,需要对请求的参数做校验,加了@Validated、@NotNull
两个注解,@NotNull的字面含义是非空,@Validated是开启校验,如下面的代码。
@GetMapping("/blog")
@ResponseBody
public Result<Blog> getBlog(@Validated @NotNull Integer id) {
Blog blog = blogService.findOne(id);
return new Result<>(ResultEnum.SUCCESS, blog);
}
本来很简单的用法,但是实际结果是校验并没有生效,当id为null时,依旧没有抛出异常,带着这个疑问,便对这块的原理进行了大概的分析,并总结一下。
2 校验框架
2.1 JSR
为了保证程序的健壮性,参数校验是必不可少的,简单的做法是在代码里面进行多个if校验,但是这样会造成大量的和业务代码耦合和冗余,为了简化这种情况,Java定义了JSR303一套参数校验的标准,通过注解对参数进行校验,减少了代码的耦合。

@NotNull注解
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(NotNull.List.class)
@Documented
@Constraint(
validatedBy = {}
)
public @interface NotNull {
String message() default "{javax.validation.constraints.NotNull.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface List {
NotNull[] value();
}
}
可以看出这个包只是校验类型的注解定义,并没有校验的实际代码。
2.2 hibernate-validator
hibernate-validator
是 JSR303 规范的一种实现,内部对规范中的每种注解实现了对应的 validator
(校验的逻辑)。

2.3 springframework-validation
springframework-validation
封装了 hibernate-validator
,通过 @Validated
注解,实现声明式校验。
3 原理解析
Spring 框架通过 hibernate-validator
,使用不同的方法对方法参数、请求参数进行了校验。
3.1 方法级别校验
首先通过 MethodValidationPostProcessor
注册AOP切面,使用 MethodValidationInterceptor
对方法增强,实现校验功能。
3.1.1 InitializingBean
MethodValidationPostProcessor
实现了 InitializingBean
接口。
// org.springframework.validation.beanvalidation.MethodValidationPostProcessor
@Override
public void afterPropertiesSet() {
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
/**
* Create AOP advice for method validation purposes, to be applied
* with a pointcut for the specified 'validated' annotation.
* @param validator the JSR-303 Validator to delegate to
* @return the interceptor to use (typically, but not necessarily,
* a {@link MethodValidationInterceptor} or subclass thereof)
* @since 4.2
*/
protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
}
在 afterPropertiesSet
中定义了切入点 pointcut
和 advice
,通过 pointcut
和 advice
组成一个切面。
AnnotationMatchingPointcut
是一个通过注解来描述切入点的类。createMethodValidationAdvice
返回了一个advice
,描述了切面何时执行以及如何执行增强处理。
MethodValidationInterceptor
作为一个拦截器,内部保存了一个 validator
,默认是通过 hibernate-validator
生成,在 invoke()
方法中,会委托给 validator
对方法参数进行了校验,如果校验不通过会抛出 ConstraintViolationException
异常。
// org.springframework.validation.beanvalidation.MethodValidationInterceptor
@Override
@SuppressWarnings("unchecked")
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;
try {
// 函数参数校验
result = execVal.validateParameters(
invocation.getThis(), 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(), invocation.getThis().getClass()));
result = execVal.validateParameters(
invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
// 不通过抛出异常
throw new ConstraintViolationException(result);
}
// 真正的方法调用
Object returnValue = invocation.proceed();
// 函数返回值校验
result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
// 不通过抛出异常
throw new ConstraintViolationException(result);
}
return returnValue;
}
3.1.2 BeanPostProcessor
MethodValidationPostProcessor
实现了 BeanPostProcessor
接口.
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (this.advisor == null || bean instanceof AopInfrastructureBean) {
// Ignore AOP infrastructure such as scoped proxies.
return bean;
}
// 添加advisor
if (bean instanceof Advised) {
Advised advised = (Advised) bean;
if (!advised.isFrozen() && isEligible(AopUtils.getTargetClass(bean))) {
// Add our local Advisor to the existing proxy's Advisor chain...
if (this.beforeExistingAdvisors) {
advised.addAdvisor(0, this.advisor);
}
else {
advised.addAdvisor(this.advisor);
}
return bean;
}
}
// 创建代理类
if (isEligible(bean, beanName)) {
ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName);
if (!proxyFactory.isProxyTargetClass()) {
evaluateProxyInterfaces(bean.getClass(), proxyFactory);
}
proxyFactory.addAdvisor(this.advisor);
customizeProxyFactory(proxyFactory);
return proxyFactory.getProxy(getProxyClassLoader());
}
// No proxy needed.
return bean;
}
当bean被初始化后,Spring会执行postProcessAfterInitialization
方法,在isEligible()
中会判断当前bean是否包含@validation
注解,符合条件的话,首先判断当前对象是否是代理类,如果代理了Advised 接口,就会把当前类的切面类增加到代理类的切面列表里面,否则会生成代理类。
isEligible()
通过AopUtils
中的canApply()
进行了判断。
// org.springframework.aop.support.AopUtils
/**
* Can the given pointcut apply at all on the given class?
* <p>This is an important test as it can be used to optimize
* out a pointcut for a class.
* @param pc the static or dynamic pointcut to check
* @param targetClass the class to test
* @param hasIntroductions whether or not the advisor chain
* for this bean includes any introductions
* @return whether the pointcut can apply on any method
*/
public static boolean canApply(Pointcut pc, Class<?> targetClass, boolean hasIntroductions) {
Assert.notNull(pc, "Pointcut must not be null");
// 判断类上是否有validation注解
if (!pc.getClassFilter().matches(targetClass)) {
return false;
}
// 创建Pointcut时,methodMatcher默认为MethodMatcher.TRUE,所以会返回true
MethodMatcher methodMatcher = pc.getMethodMatcher();
if (methodMatcher == MethodMatcher.TRUE) {
// No need to iterate the methods if we're matching any method anyway...
return true;
}
IntroductionAwareMethodMatcher introductionAwareMethodMatcher = null;
if (methodMatcher instanceof IntroductionAwareMethodMatcher) {
introductionAwareMethodMatcher = (IntroductionAwareMethodMatcher) methodMatcher;
}
Set<Class<?>> classes = new LinkedHashSet<>();
if (!Proxy.isProxyClass(targetClass)) {
classes.add(ClassUtils.getUserClass(targetClass));
}
classes.addAll(ClassUtils.getAllInterfacesForClassAsSet(targetClass));
for (Class<?> clazz : classes) {
Method[] methods = ReflectionUtils.getAllDeclaredMethods(clazz);
for (Method method : methods) {
if (introductionAwareMethodMatcher != null ?
introductionAwareMethodMatcher.matches(method, targetClass, hasIntroductions) :
methodMatcher.matches(method, targetClass)) {
return true;
}
}
}
return false;
}
到这里就能了解到Spring是通过注解+拦截器对方法参数进行校验的。
3.2 spring-web参数校验
web请求在参数解析时也会进行校验,具体校验流程如下。
其中RequestResponseBodyMethodProcessor
是用来解析用@RequestBody
标注的参数和用@ResponseBody
标注的返回值。
// org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor
@Override
public boolean supportsParameter(MethodParameter parameter) {
// 判断是否包含 RequestBody 注解
return parameter.hasParameterAnnotation(RequestBody.class);
}
@Override
public boolean supportsReturnType(MethodParameter returnType) {
// 判断是否包含 ResponseBody 注解
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
@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);
}
/**
* Validate the binding target if applicable.
* <p>The default implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
* @param binder the DataBinder to be used
* @param parameter the method parameter descriptor
* @since 4.1.5
* @see #isBindExceptionRequired
*/
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
// 此处会直接查找参数的注解
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
// Validated 注解 或者 Valid 开头的注解
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); // 通过binder校验
break;
}
}
}
所以对于这种方式用 @Validated
或者 @Valid
都可以生效。
binder.validate()
方法内最后也是使用了 hibernate-validator
实现的校验。
但是对于 @RequestParam
、@PathVariable
注解的解析,在 AbstractNamedValueMethodArgumentResolver
的 resolveArgument()
中没有相应的校验逻辑。
4 使用方式
了解完Spring对整个校验原理,也就明白开头 bug 的原因了,对于 RequestParam
类型的参数,只能通过方法级别校验方式,但是方法级别的校验只会检查类上有没有 @Validated
注解,不会管方法参数上的注解,所以只要在类上加上 @Validated
注解即可解决。
最后总结一下 Spring 中参数校验使用方式。
4.1 pom依赖
hibernate-validator
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>*</version>
</dependency>
对于 Spring boot 版本低于 2.3.x 时,spring-boot-starter-web 会自动依赖 hibernate-validator,无需手动引入。
4.2 使用场景
参数校验一般用在 web 服务中,对用户输入数据进行校验。
4.2.1 ResquestBody
一般当请求的参数过多,后端会通过一个 DTO 对象接收参数,只需要给 DTO 的方法参数前加上 @Validated
注解,对 DTO 的属性上加上需要校验的注解即可实现校验功能。
// dto
public class BlogDto {
@NotEmpty
@Max(20)
private String title;
@NotNull
private Integer id;
@Max(1000)
private String content;
}
// controller
@GetMapping("/saveBlog")
public Result<Blog> saveBlog(@Validated BlogDto blogDto) {
return new Result<>(ResultEnum.SUCCESS, blogDto);
}
4.2.2 ResquestParam/PathVariable
一般用在 GET 请求中,参数比较少的情况下使用,需要在 Controller 类上加入 @Validated
,然后在参数上添加校验约束注解即可。
@RestController
@Validated
class BlogController {
@GetMapping("/blog")
public Result<Blog> getBlog( @NotNull Integer id) {
Blog blog = blogService.findOne(id);
return new Result<>(ResultEnum.SUCCESS, blog);
}
}
4.3 统一异常
从前面两种校验方式上可以看到,当校验不通过时,会抛出不同的异常,可以定义一个 Spring 的全局异常处理器,使返回的内容更加友好。
/**
* 全局异常处理器
*/
@RestControllerAdvice
@Order(-1)
public class GlobalExceptionHandler
{
/**
* 自定义验证异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Object validExceptionHandler(MethodArgumentNotValidException e)
{
List<String> allErrors = e.getBindingResult().getAllErrors().stream().map(x-> {
if(x instanceof FieldError){
return ((FieldError) x).getField()+" "+x.getDefaultMessage();
}
return x.getDefaultMessage();
}).collect(Collectors.toList());
return RestResult.error(Arrays.toString(allErrors.toArray()));
}
/**
* 自定义验证异常
*/
@ExceptionHandler({ConstraintViolationException.class})
public Object handleConstraintViolationException(ConstraintViolationException e) {
return RestResult.error(e.getMessage());
}
}
4.4 分组校验
对于同一个 DTO 在不同的接口中,可能会出现不同的校验规则,spring-validation
支持分组校验的功能。
public class BlogDto {
@NotNull(groups = Update.class)
private Integer id;
@NotEmpty
@Max(20)
private String title;
@Max(1000)
private String content;
// 保存
public interface Save {
}
// 更新
public interface Update {
}
}
// controller
@PostMapping
public Result updateBlog(@Validated(BlogDto.Update.class) @RequestBody BlogDto blogDto) {
return new Result(ResultEnum.SUCCESS.getCode());
}
对于 BlogDto
只有在更新的时候才会校验必须传 id。首先在属性的约束注解上设置分组,在使用 @Validation
的时候指定使用的分组,即可实现分组校验的功能。
4.5 嵌套、集合校验
当对象中不仅包括基本类型,还包括自定义类型、或集合的时候,如果需要嵌套校验和对集合中的每一项元素校验,就需要在属性上使用 @Valid注解
,才会进行嵌套的校验
public class BlogDto {
@Valid
private Blog blog;
@Valid
private List<String> labels;
}
5 自定义校验
JSR 定义的注解不一定满足实际的业务需求,也可以自定义一个注解,并实现对应的 Validator 即可使用。
5.1 定义注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Constraint(validatedBy = PasswordValidator.class)
public @interface PasswordEqual {
String message() default "passwords are not equal";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
5.2 实现 Validator
public class PasswordValidator implements ConstraintValidator<PasswordEqual, UserDto> {
@Override
public boolean isValid(UserDto userDto, ConstraintValidatorContext constraintValidatorContext) {
String password1 = userDto.getPassword1();
String password2 = userDto.getPassword2();
boolean match = password1.equals(password2);
return match;
}
}
6 Valid 与 Validation 区别
在使用中可以看到 Valid 和 Validation 两种校验的注解,用来表明数据需要进行校验:
@Valid
是由javax.validation
包提供,是 JSR 定义的规范注解,支持在 普通方法、构造方法、方法参数、方法返回、成员变量上使用(不支持类上)。@Validation
是由springframework-validation
包提供,可以添加在类、方法参数、普通方法上。
6.1 区别
- Validation 是 Spring 的声明式注解。
- Validation 支持分组校验。
- Valid 支持嵌套校验,即类的成员变量上使用。
- Valid 还支持 方法返回和构造方法上使用。
7. 快速失败
Spring validation 默认会一次校验完所有的字段,可以配置快速失败,即当发生一个校验不通过时,就立刻抛出异常,不继续后续校验了。
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
// 快速失败模式
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}