跳至主要內容

Spring 数据校验

blacklad大约 9 分钟SpringSpring

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定义了JSR303open in new window一套参数校验的标准,通过注解对参数进行校验,减少了代码的耦合。

@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 中定义了切入点 pointcutadvice,通过 pointcutadvice 组成一个切面。

  1. AnnotationMatchingPointcut 是一个通过注解来描述切入点的类。

  2. 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 注解的解析,在 AbstractNamedValueMethodArgumentResolverresolveArgument() 中没有相应的校验逻辑。

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 两种校验的注解,用来表明数据需要进行校验:

  1. @Valid 是由 javax.validation 包提供,是 JSR 定义的规范注解,支持在 普通方法、构造方法、方法参数、方法返回、成员变量上使用(不支持类上)。
  2. @Validation 是由 springframework-validation 包提供,可以添加在类、方法参数、普通方法上。

6.1 区别

  1. Validation 是 Spring 的声明式注解。
  2. Validation 支持分组校验。
  3. Valid 支持嵌套校验,即类的成员变量上使用。
  4. Valid 还支持 方法返回和构造方法上使用。

7. 快速失败

Spring validation 默认会一次校验完所有的字段,可以配置快速失败,即当发生一个校验不通过时,就立刻抛出异常,不继续后续校验了。

@Bean
public Validator validator() {
    ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
            .configure()
            // 快速失败模式
            .failFast(true)
            .buildValidatorFactory();
    return validatorFactory.getValidator();
}
上次编辑于:
贡献者: blacklad