Dubbo服務如何優雅的校驗參數

一、背景

服務端在向外提供介面服務時,不管是對前端提供HTTP介面,還是面向內部其他服務端提供的RPC介面,常常會面對這樣一個問題,就是如何優雅的解決各種介面參數校驗問題?

早期大家在做面向前端提供的HTTP介面時,對參數的校驗可能都會經歷這幾個階段:每個介面每個參數都寫訂製校驗程式碼、提煉公共校驗邏輯、自定義切面進行校驗、通用標準的校驗邏輯。

這邊提到的通用標準的校驗邏輯指的就是基於JSR303的Java Bean Validation,其中官方指定的具體實現就是 Hibernate Validator,在Web項目中結合Spring可以做到很優雅的去進行參數校驗。

本文主要也是想給大家介紹下如何在使用Dubbo時做好優雅的參數校驗。

二、解決方案

Dubbo框架本身是支援參數校驗的,同時也是基於JSR303去實現的,我們來看下具體是怎麼實現的。

2.1 maven依賴

<!-- 定義在facade介面模組的pom文件找那個 -->
<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
<!-- 如果不想facade包有多餘的依賴,此處scope設為provided,否則可以刪除 -->
    <scope>provided</scope>
</dependency>
 
<!-- 下面依賴通常加在Facade介面實現模組的pom文件中 -->
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.2.0.Final</version>
</dependency>


2.2 介面定義

facade介面定義:

public interface UserFacade {
    FacadeResult<Boolean> updateUser(UpdateUserParam param);
}


參數定義

public class UpdateUserParam implements Serializable {
    private static final long serialVersionUID = 2476922055212727973L;
 
    @NotNull(message = "用戶標識不能為空")
    private Long id;
    @NotBlank(message = "用戶名不能為空")
    private String name;
    @NotBlank(message = "用戶手機號不能為空")
    @Size(min = 8, max = 16, message="電話號碼長度介於8~16位")
    private String phone;
 
    // getter and setter ignored
}


公共返回定義

/**
 * Facade介面統一返回結果
 */
public class FacadeResult<T> implements Serializable {
    private static final long serialVersionUID = 8570359747128577687L;
 
    private int code;
    private T data;
    private String msg;
    // getter and setter ignored
}


2.3 Dubbo服務提供者端配置

Dubbo服務提供者端必須作這個validation=”true”的配置,具體示例配置如下:

Dubbo介面服務端配置

<bean class="com.xxx.demo.UserFacadeImpl" id="userFacade"/>
<dubbo:service interface="com.xxx.demo.UserFacade" ref="userFacade" validation="true" />


2.4 Dubbo服務消費者端配置

這個根據業務方使用習慣不作強制要求,但建議配置上都加上validation=”true”,示例配置如下:

<dubbo:reference id="userFacade" interface="com.xxx.demo.UserFacade" validation="true" />


2.5 驗證參數校驗

前面幾步完成以後,驗證這一步就比較簡單了,消費者調用該約定介面,介面入參傳入UpdateUserParam對象,其中欄位不用賦值,然後調用服務端介面就會得到如下的參數異常提示:

Dubbo介面服務端配置

javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用戶名不能為空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶名不能為空'}, ConstraintViolationImpl{interpolatedMessage='用戶手機號不能為空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶手機號不能為空'}, ConstraintViolationImpl{interpolatedMessage='用戶標識不能為空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶標識不能為空'}]
javax.validation.ValidationException: Failed to validate service: com.xxx.demo.UserFacade, method: updateUser, cause: [ConstraintViolationImpl{interpolatedMessage='用戶名不能為空', propertyPath=name, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶名不能為空'}, ConstraintViolationImpl{interpolatedMessage='用戶手機號不能為空', propertyPath=phone, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶手機號不能為空'}, ConstraintViolationImpl{interpolatedMessage='用戶標識不能為空', propertyPath=id, rootBeanClass=class com.xxx.demo.UpdateUserParam, messageTemplate='用戶標識不能為空'}]
    at org.apache.dubbo.validation.filter.ValidationFilter.invoke(ValidationFilter.java:96)
    at org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:83)
    ....
    at org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.received(HeaderExchangeHandler.java:175)
    at org.apache.dubbo.remoting.transport.DecodeHandler.received(DecodeHandler.java:51)
    at org.apache.dubbo.remoting.transport.dispatcher.ChannelEventRunnable.run(ChannelEventRunnable.java:57)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)


三、訂製Dubbo參數校驗異常返回

從前面內容我們可以很輕鬆的驗證,當消費端調用Dubbo服務時,參數如果不合法就會拋出相關異常資訊,消費端調用時也能識別出異常資訊,似乎這樣就沒有問題了。

但從前面所定義的服務介面來看,一般業務開發會定義統一的返回對象格式(如前文示例中的FacadeResult),對於業務異常情況,會約定相關異常碼並結合相關性資訊提示。因此對於參數校驗不合法的情況,服務調用方自然不希望服務端拋出一大段包含堆棧資訊的異常資訊,而是希望還保持這種統一的返回形式,就如下面這種返回所示:

Dubbo介面服務端配置:

{ 
  "code": 1001,
  "msg": "用戶名不能為空",
  "data": null
}


3.1 ValidationFilter & JValidator

想要做到返回格式的統一,我們先來看下前面所拋出的異常是如何來的?

從異常堆棧內容我們可以看出這個異常資訊返回是由ValidationFilter拋出的,從名字我們可以猜到這個是採用Dubbo的Filter擴展機制的一個內置實現,當我們對Dubbo服務介面啟用參數校驗時(即前文Dubbo服務配置中的validation=”true”),該Filter就會真正起作用,我們來看下其中的關鍵實現邏輯:

@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
    if (validation != null && !invocation.getMethodName().startsWith("$")
            && ConfigUtils.isNotEmpty(invoker.getUrl().getMethodParameter(invocation.getMethodName(), VALIDATION_KEY))) {
        try {
            Validator validator = validation.getValidator(invoker.getUrl());
            if (validator != null) {
                // 注1
                validator.validate(invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());
            }
        } catch (RpcException e) {
            throw e;
        } catch (ValidationException e) {
            // 注2
            return AsyncRpcResult.newDefaultAsyncResult(new ValidationException(e.getMessage()), invocation);
        } catch (Throwable t) {
            return AsyncRpcResult.newDefaultAsyncResult(t, invocation);
        }
    }
    return invoker.invoke(invocation);
}


從前文的異常堆棧資訊我們可以知道異常資訊是由上述程式碼「注2」處所產生,這邊是因為捕獲了ValidationException,通過走讀程式碼或者調試可以得知,該異常是由「注1」處valiator.validate方法所產生。

而Validator介面在Dubbo框架中實現只有JValidator,這個通過idea工具顯示Validator所有實現的UML類圖可以看出(如下圖所示),當然調試程式碼也可以很輕鬆定位到。

既然定位到JValidator了,我們就繼續看下它裡面validate方法的具體實現,關鍵程式碼如下所示:

@Override
public void validate(String methodName, Class<?>[] parameterTypes, Object[] arguments) throws Exception {
    List<Class<?>> groups = new ArrayList<>();
    Class<?> methodClass = methodClass(methodName);
    if (methodClass != null) {
        groups.add(methodClass);
    }
    Set<ConstraintViolation<?>> violations = new HashSet<>();
    Method method = clazz.getMethod(methodName, parameterTypes);
    Class<?>[] methodClasses;
    if (method.isAnnotationPresent(MethodValidated.class)){
        methodClasses = method.getAnnotation(MethodValidated.class).value();
        groups.addAll(Arrays.asList(methodClasses));
    }
    groups.add(0, Default.class);
    groups.add(1, clazz);
 
    Class<?>[] classgroups = groups.toArray(new Class[groups.size()]);
 
    Object parameterBean = getMethodParameterBean(clazz, method, arguments);
    if (parameterBean != null) {
        // 注1
        violations.addAll(validator.validate(parameterBean, classgroups ));
    }
 
    for (Object arg : arguments) {
        // 注2
        validate(violations, arg, classgroups);
    }
 
    if (!violations.isEmpty()) {
        // 注3
        logger.error("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations);
        throw new ConstraintViolationException("Failed to validate service: " + clazz.getName() + ", method: " + methodName + ", cause: " + violations, violations);
    }
}


從上述程式碼中可以看出當「注1」和注「2」兩處程式碼進行參數校驗時所得到的「違反約束」的資訊都被加入到violations集合中,而在「注3」處檢查到「違反約束」不為空時,就會拋出包含「違反約束」資訊的ConstraintViolationException,該異常繼承自ValidationException,這樣也就會被ValidationFilter中方法所捕獲,進而向調用方返回相關異常資訊。

3.2 自定義參數校驗異常返回

從前一小節我們可以很清晰的了解到了為什麼會拋出那樣的異常資訊給調用方,如果想做到我們前面想要的訴求:統一返回格式,我們需要按照下面的步驟去實現。

3.2.1 自定義Filter

@Activate(group = {CONSUMER, PROVIDER}, value = "customValidationFilter", order = 10000)
public class CustomValidationFilter implements Filter {
 
    private Validation validation;
 
    public void setValidation(Validation validation) { this.validation = validation; }
 
    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
        if (validation != null && !invocation.getMethodName().startsWith("$")
                && ConfigUtils.isNotEmpty(invoker.getUrl().getMethodParameter(invocation.getMethodName(), VALIDATION_KEY))) {
            try {
                Validator validator = validation.getValidator(invoker.getUrl());
                if (validator != null) {
                    validator.validate(invocation.getMethodName(), invocation.getParameterTypes(), invocation.getArguments());
                }
            } catch (RpcException e) {
                throw e;
            } catch (ConstraintViolationException e) {// 這邊細化了異常類型
                // 注1
                Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
                if (CollectionUtils.isNotEmpty(violations)) {
                    ConstraintViolation<?> violation = violations.iterator().next();// 取第一個進行提示就行了
                    FacadeResult facadeResult = FacadeResult.fail(ErrorCode.INVALID_PARAM.getCode(), violation.getMessage());
                    return AsyncRpcResult.newDefaultAsyncResult(facadeResult, invocation);
                }
                return AsyncRpcResult.newDefaultAsyncResult(new ValidationException(e.getMessage()), invocation);
            } catch (Throwable t) {
                return AsyncRpcResult.newDefaultAsyncResult(t, invocation);
            }
        }
        return invoker.invoke(invocation);
    }
}


該自定義filter與內置的ValidationFilter唯一不同的地方就在於「注1」處所新增的針對特定異常ConstraintViolationException的處理,從異常對象中獲取包含的「違反約束」資訊,並取其中第一個來構造業務上所定義的通用數據格式FacadeResult對象,作為Dubbo服務介面調用返回的資訊。

3.2.2 自定義Filter的配置

開發過Dubbo自定義filter的同學都知道,要讓它生效需要作一個符合SPI規範的配置,如下所示:

a. 新建兩級目錄分別是META-INF和dubbo,這個需要特別注意,不能直接新建一個目錄名為「META-INFO.dubbo」,否則在初始化啟動的時候會失敗。

b. 新建一個文件名為com.alibaba.dubbo.rpc.Filter,當然也可以是org.apache.dubbo.rpc.Filter,Dubbo開源到Apache社區後,默認支援這兩個名字。

c. 文件中配置內容為:customValidationFilter=com.xxx.demo.dubbo.filter.CustomValidationFilter。

3.3.3 Dubbo服務配置

有了自定義參數校驗的Filter配置後,如果只做到這的話,其實還有一個問題,應用啟動後會有兩個參數校驗Filter生效。當然可以通過指定Filter的order來實現自定義Filter先執行,但很顯然這種方式不穩妥,而且兩個Filter的功能是重複的,因此只需要一個生效就可以了,Dubbo提供了一種機制可以禁用指定的Filter,只需在Dubbo配置文件中作如下配置即可:

<!-- 需要禁用的filter以"-"開頭並加上filter名稱 -->
<!-- 查看源碼,可看到需要禁用的ValidationFilter名為validation-->
<dubbo:provider filter="-validation"/>


但經過上述配置後,發現customValidationFilter並沒有生效,經過調試以及對dubbo相關文檔的學習,對Filter生效機制有了一定的了解。

a. dubbo啟動後,默認會生效框架自帶的一系列Filter;

可以在dubbo框架的資源文件org.apache.dubbo.rpc.Filter中看到具體有哪些,不同版本的內容可能會有些許差別。

cache=org.apache.dubbo.cache.filter.CacheFilter
validation=org.apache.dubbo.validation.filter.ValidationFilter  // 注1
echo=org.apache.dubbo.rpc.filter.EchoFilter
generic=org.apache.dubbo.rpc.filter.GenericFilter
genericimpl=org.apache.dubbo.rpc.filter.GenericImplFilter
token=org.apache.dubbo.rpc.filter.TokenFilter
accesslog=org.apache.dubbo.rpc.filter.AccessLogFilter
activelimit=org.apache.dubbo.rpc.filter.ActiveLimitFilter
classloader=org.apache.dubbo.rpc.filter.ClassLoaderFilter
context=org.apache.dubbo.rpc.filter.ContextFilter
consumercontext=org.apache.dubbo.rpc.filter.ConsumerContextFilter
exception=org.apache.dubbo.rpc.filter.ExceptionFilter
executelimit=org.apache.dubbo.rpc.filter.ExecuteLimitFilter
deprecated=org.apache.dubbo.rpc.filter.DeprecatedFilter
compatible=org.apache.dubbo.rpc.filter.CompatibleFilter
timeout=org.apache.dubbo.rpc.filter.TimeoutFilter
tps=org.apache.dubbo.rpc.filter.TpsLimitFilter
trace=org.apache.dubbo.rpc.protocol.dubbo.filter.TraceFilter
future=org.apache.dubbo.rpc.protocol.dubbo.filter.FutureFilter
monitor=org.apache.dubbo.monitor.support.MonitorFilter
metrics=org.apache.dubbo.monitor.dubbo.MetricsFilter


如上「注1」中的Filter就是我們上一步配置中想要禁用的Filter,因為這些filter都是Dubbo內置的,所以這些filter集合有一個統一的名字,default,因此如果想全部禁用,除了一個一個禁用外,也可以直接用’-default’達到目的,這些默認內置的filter只要沒有全部或單獨禁用,那就會生效。

b. 想要開發的自定義Filter能生效,不並一定要在<dubbo:provider filter=”xxxFitler” >中體現;如果我們沒有在Dubbo相關的配置文件中去配置Filter相關資訊,只要寫好自定義filter程式碼,並在資源文件/META-INF/dubbo/com.alibaba.dubbo.rpc.Filter中按照spi規範定義好即可,這樣所有被載入的Filter都會生效。

c. 如果在Dubbo配置文件中配置了Filter資訊,那自定義Filter只有顯式配置才會生效。

d. Filter配置也可以加在dubbo service配置中(<dubbo:service interface=”…” ref=”…” validation=”true” filter=”xFilter,yFilter”/>)。

當dubbo配置文件中provider 和service部分都配置了Filter資訊,針對service具體生效的Filter取兩者配置的並集。

因此想要自定義的校驗Filter在所有服務中都生效,需要作如下配置:

<dubbo:provider filter="-validation, customValidationFilter"/>


四、如何擴展校驗註解

前面示例中都是利用參數校驗的內置註解去完成,在實際開發中有時候會遇到默認內置的註解無法滿足校驗需求,這時就需要自定義一些校驗註解去滿足需求,方便開發。

假設有這樣一個場景,某參數值需要校驗只能在指定的幾個數值範圍內,類似於白名單一樣,下面就以這個場景來演示下如何擴展校驗註解。

4.1 定義校驗註解

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { })// 注1
// @Constraint(validatedBy = {AllowedValueValidator.class}) 注2
public @interface AllowedValue {
 
    String message() default "參數值不在合法範圍內";
 
    Class<?>[] groups() default { };
 
    Class<? extends Payload>[] payload() default { };
 
    long[] value() default {};
 
}


public class AllowedValueValidator implements ConstraintValidator<AllowedValue, Long> {
 
    private long[] allowedValues;
 
    @Override
    public void initialize(AllowedValue constraintAnnotation) {
        this.allowedValues = constraintAnnotation.value();
    }
 
    @Override
    public boolean isValid(Long value, ConstraintValidatorContext context) {
        if (allowedValues.length == 0) {
            return true;
        }
        return Arrays.stream(allowedValues).anyMatch(o -> Objects.equals(o, value));
    }
}


「注1」中的校驗器(Validator)並沒有指定,當然是可以像「注2」中那樣直接指定校驗器,但考慮到自定義註解有可能是直接暴露在facade包中,而具體的校驗器的實現有時候會包含一些業務依賴,所以不建議直接在此處指定,而是通過Hibernate Validator提供的Validator發現機制去完成關聯。

4.2 配置訂製Validator發現

a. 在resources目錄下新建META-INF/services/javax.validation.ConstraintValidator文件。

b. 文件中只需填入相應Validator的全路徑:com.xxx.demo.validator.AllowedValueValidator,如果有多個的話,每行一個。

五、總結

本文主要介紹了使用Dubbo框架時如何使用優雅點方式完成參數的校驗,首先演示了如何利用Dubbo框架默認支援的校驗實現,然後接著演示了如何配合實際業務開發返回統一的數據格式,最後介紹了下如何進行自定義校驗註解的實現,方便進行後續自行擴展實現,希望能在實際工作中有一定的幫助。

作者:vivo官網商城開發團隊-Wei Fuping