动态

详情 返回 返回

詳解SptingBoot參數校驗機制,使用校驗不再混亂 - 动态 详情

前言

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 執行流程:

  1. 通過 DispatcherServlet 接收所有的前端發起的請求
  2. 通過配置獲取對應的 HandlerMapping,將請求映射到處理器。即根據解析 url, http 協議,請求參數等找到對應的 Controller 的對應 Method 的信息。
  3. 通過配置獲取對應的 HandlerAdapter,用於實際處理和調用 HandlerMapping。即實際上是 HandlerAdapter 調用到用户自己寫的 Controller 的 Method。
  4. 通過配置獲取對應的 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)來決定。

然而只有ModelAttributeMethodProcessorAbstractMessageConverterMethodArgumentResolver這兩個抽象類的 resolveArgument 方法編寫了校驗邏輯,實現類分別為:

ServletModelAttributeMethodProcessor(@ModelAttribute註解的參數,或者沒其他 Resolver 匹配的自定義對象)

HttpEntityMethodProcessor(HttpEntityRequestEntity對象)

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代理方法主要做了幾個步驟:

  1. 調用determineValidationGroups方法獲取 Validated 裏的 Group 分組信息。優先查找方法上的Validated註解來獲取分組信息,如果沒有則用類上的Validated註解的分組信息。
  2. 獲取校驗器類,通常為ValidatorImpl
  3. 調用校驗方法ExecutableValidator.validateParameters校驗入參,如果拋出IllegalArgumentException
    異常,嘗試獲取其泛型信息再次校驗。如果參數校驗不通過會拋出ConstraintViolationException異常
  4. 調用校驗方法ExecutableValidator.validateReturnValue校驗返回值。如果參數校驗不通過會拋出ConstraintViolationException異常
總結一下:
SpringMVC 會通過反射調用到 Controller 對應的業務代碼,被調用的類就是被 Spring AOP 代理的類,會走 AOP 機制。
校驗功能是在MethodValidationInterceptor類中調用的,調用ExecutableValidator.validateParameters方法校驗入參,調用ExecutableValidator.validateReturnValue方法校驗返回值

SpringMVC 和 AOP 校驗機制總結與比對

  1. SpringMVC 只有方法入參對象前有@Valid@Validated,或者自定義的名稱以'Valid'開頭的註解才生效;AOP 需要先在類上標註@Validated
    , 然後方法入參前標註校驗規則註解(如:@NotBlank),或者校驗對象前標註@Valid
  2. SpringMVC 在HandlerMethodArgumentResolver實現類中做參數校驗,所以只能在 Controller 層校驗生效,並且只有部分HandlerMethodArgumentResolver實現類有校驗功能(如RequestParamMethodArgumentResolver就沒有);AOP 是 Spring 的代理機制,所以只要 Spring 代理的 Bean
    即可做校驗。
  3. 目前 SpringMVC 校驗只能校驗自定義對象的入參,無法校驗返回值(現在 Spring 提供的HandlerMethodArgumentResolver沒有做這個功能,可以通過自己實現 Resolver 來實現);AOP 可以校驗基本數據類型,可以校驗返回值。
  4. SpringMVC 在校驗不通過時會拋出BindException異常(MethodArgumentNotValidException在 Spring5.3 版本也變為BindException的子類);AOP
    校驗在校驗不通過時拋出ConstraintViolationException異常。(Tip: 所以可以通過拋出的異常來判定走的哪個校驗流程,方便定位問題)。
  5. 在 Controller 層校驗時會先走 SpringMVC 流程,然後再走 AOP 校驗流程。

原文地址:詳解SptingBoot參數校驗機制,使用校驗不再混亂

Add a new 评论

Some HTML is okay.