Triple協議支持Java異常回傳設計實現詳解

背景

在一些業務場景, 往往需要自定義異常來滿足特定的業務, 主流用法是在catch裡拋出異常, 例如:

public void deal() {
  try{
   //doSomething   
   ...
  } catch(IGreeterException e) {
      ...
      throw e;
  }   
}

或者通過ExceptionBuilder,把相關的異常對象返回給consumer:

provider.send(new ExceptionBuilders.IGreeterExceptionBuilder()
    .setDescription('異常描述信息'); 

在拋出異常後, 通過捕獲和instanceof來判斷特定的異常, 然後做相應的業務處理,例如:

try {
    greeterProxy.echo(REQUEST_MSG);
} catch (IGreeterException e) {
    //做相應的處理
    ...
}

在 Dubbo 2.x 版本,可以通過上述方法來捕獲 Provider 端的異常。 而隨著雲原生時代的到來, Dubbo 也開啟瞭 3.0 的裡程碑。

Dubbo 3.0 的一個很重要的目標就是全面擁抱雲原生, 在 3.0 的許多特性中,很重要的一個改動就是支持新的一代Rpc協議Triple

Triple 協議基於 HTTP 2.0 進行構建,對網關的穿透性強,兼容 gRPC, 提供 Request Response、Request Streaming、Response Streaming、 Bi-directional Streaming 等通信模型; 從 Triple 協議開始,Dubbo 還支持基於 IDL 的服務定義。

采用 Triple 協議的用戶可以在 provider 端生成用戶定義的異常信息, 記錄異常產生的堆棧,triple 協議可保證將用戶在客戶端獲取到異常的message。

Triple 的回傳異常會在 AbstractInvokerwaitForResultIfSync 中把異常信息堆棧統一封裝成 RpcException, 所有來自 Provider 端的異常都會被封裝成 RpcException 類型並拋出, 這會導致用戶無法根據特定的異常類型捕獲來自 Provider 的異常, 隻能通過捕獲 RpcException 異常來返回信息, 且 Provider 攜帶的異常 message 也無法回傳,隻能獲取打印的堆棧信息:

    try {
        greeterProxy.echo(REQUEST_MSG);
    } catch (RpcException e) {
        e.printStackTrace();
    }

自定義異常信息在社區中的呼聲也比較高, 因此本次改動將支持自定義異常的功能, 使得服務端能拋出自定義異常後被客戶端捕獲到。

Dubbo異常處理簡介

我們從Consumer的角度看一下一次Triple協議 Unary請求的大致流程:

Dubbo Consumer 從 Spring 容器中獲取 bean 時獲取到的是一個代理接口, 在調用接口的方法時會通過代理類遠程調用接口並返回結果。

Dubbo提供的代理工廠類是 ProxyFactory,通過 SPI 機制默認實現的是 JavassistProxyFactoryJavassistProxyFactory 創建瞭一個繼承自 AbstractProxyInvoker 類的匿名對象, 並重寫瞭抽象方法 doInvoke。 重寫後的 doInvoke 隻是將調用請求轉發給瞭 Wrapper 類的 invokeMethod 方法, 並生成 invokeMethod 方法代碼和其他一些方法代碼。

代碼生成完畢後,通過 Javassist 生成 Class 對象, 最後再通過反射創建 Wrapper 實例,隨後通過 InvokerInvocationHandler -> InvocationUtil -> AbstractInvoker -> 具體實現類發送請求到Provider端。

Provider 進行相應的業務處理後返回相應的結果給 Consumer 端,來自 Provider 端的結果會被封裝成 AsyncResult ,在 AbstractInvoker 的具體實現類裡, 接受到來自 Provider 的響應之後會調用 appResponserecreate 方法,若 appResponse 裡包含異常, 則會拋出給用戶,大體流程如下:

上述的異常處理相關環節是在 Consumer 端,在 Provider 端則是由 org.apache.dubbo.rpc.filter.ExceptionFilter 進行處理, 它是一系列責任鏈 Filter 中的一環,專門用來處理異常。

Dubbo 在 Provider 端的異常會在封裝進 appResponse 中。下面的流程圖揭示瞭 ExceptionFilter 源碼的異常處理流程:

而當 appResponse 回到瞭 Consumer 端,會在 InvocationUtil 裡調用 AppResponserecreate 方法拋出異常, 最終可以在 Consumer 端捕獲:

public Object recreate() throws Throwable {
    if (exception != null) {
    try {
        Object stackTrace = exception.getStackTrace();
        if (stackTrace == null) {
            exception.setStackTrace(new StackTraceElement[0]);
        }
    } catch (Exception e) {
        // ignore
    }
    throw exception;
}
return result;
}

Triple 通信原理

在上一節中,我們已經介紹瞭 Dubbo 在 Consumer 端大致發送數據的流程, 可以看到最終依靠的是 AbstractInvoker 的實現類來發送數據。 在 Triple 協議中,AbstractInvoker 的具體實現類是 TripleInvokerTripleInvoker 在發送前會啟動監聽器,監聽來自 Provider 端的響應結果, 並調用 ClientCallToObserverAdapteronNext 方法發送消息, 最終會在底層封裝成 Netty 請求發送數據。

在正式的請求發起前,TripleServer 會註冊 TripleHttp2FrameServerHandler, 它繼承自 Netty 的 ChannelDuplexHandler, 其作用是會在 channelRead 方法中不斷讀取 Header 和 Data 信息並解析, 經過層層調用, 會在 AbstractServerCallonMessage 方法裡把來自 consumer 的信息流進行反序列化, 並最終由交由 ServerCallToObserverAdapterinvoke 方法進行處理。

invoke 方法中,根據 consumer 請求的數據調用服務端相應的方法,並異步等待結果;' 若服務端拋出異常,則調用 onError 方法進行處理, 否則,調用 onReturn 方法返回正常的結果,大致代碼邏輯如下:

public void invoke() {
    ...
    try {
        //調用invoke方法請求服務
        final Result response = invoker.invoke(invocation);
        //異步等待結果
        response.whenCompleteWithContext((r, t) -> {
            //若異常不為空
            if (t != null) {
                //調用方法過程出現異常,調用onError方法處理
                responseObserver.onError(t);
                return;
            }
            if (response.hasException()) {
                //調用onReturn方法處理業務異常
                onReturn(response.getException());
                return;
            }
            ...
            //正常返回結果
            onReturn(r.getValue());
        });
    } 
    ...
}

大體流程如下:

實現版本

瞭解瞭上述原理,我們就可以進行相應的改造瞭, 能讓 consumer 端捕獲異常的關鍵在於把異常對象以及異常信息序列化後再發送給consumer端。 常見的序列化協議很多,例如 Dubbo/HSF 默認的 hessian2 序列化; 還有使用廣泛的 JSON 序列化;以及 gRPC 原生支持的 protobuf(PB) 序列化等等。 Triple協議因為兼容grpc的原因,默認采用 Protobuf 進行序列化。 上述提到的這三種典型的序列化方案作用類似,但在實現和開發中略有不同。 PB 不可由序列化後的字節流直接生成內存對象, 而 Hessian 和 JSON 都是可以的。後兩者反序列化的過程不依賴“二方包”, 其序列化和反序列化的代碼由 proto 文件相同,隻要客戶端和服務端用相同的 proto 文件進行通信, 就可以構造出通信雙方可解析的結構。

單一的 protobuf 無法序列化異常信息, 因此我們采用 Wrapper + PB 的形式進行序列化異常信息, 抽象出一個 TripleExceptionWrapperUtils 用於序列化異常, 並在 trailer 中采用 TripleExceptionWrapperUtils 序列化異常,大致代碼流程如下:

上面的實現方案看似非常合理,已經能把 Provider 端的異常對象和信息回傳, 並在 Consumer 端進行捕獲。但仔細想想還是有問題的: 通常在 HTTP2 為基礎的通信協議裡會對 header 大小做一定的限制, 太大的header size 會導致性能退化嚴重,為瞭保證性能, 往往以 HTTP2 為基礎的協議在建立連接的時候是要協商最大 header size 的, 超過後會發送失敗。對於 Triple 協議來說,在設計之初就是基於 HTTP 2.0, 能無縫兼容 Grpc,而 Grpc header 頭部隻有 8KB 大小, 異常對象大小可能超過限制,從而丟失異常信息; 且多一個 header 攜帶序列化的異常信息意味著用戶能加的 header 數量會減少, 擠占瞭其他 header 所能占用的空間。

經過討論,考慮將異常信息放置在 Body,將序列化後的異常從 trailer 挪至 body, 采用 TripleWrapper + protobuf 進行序列化,把相關的異常信息序列化後回傳。 社區圍繞這個問題進行瞭一系列的爭論,讀者也可嘗試先思考一下:

1.在 body 中攜帶回傳的異常信息,其對應HTTP header狀態碼該設置為多少?

2.基於 http2 構建的協議,按照主流的 grpc 實現方案,相關的錯誤信息放在 trailer,理論上不存在body,上層協議也需要保持語義一致性,若此時在payload回傳異常對象,且grpc並沒有支持在Body回傳序列化對象的功能, 會不會破壞Http和grpc協議的語義?從這個角度出發,異常信息更應該放在trailer裡。

3.作為開源社區,不能一味滿足用戶的需求,非標準化的用法註定是會被淘汰的,應該盡量避免更改 Protobuf的語義,是否在Wrapper層去支持序列化異常就能滿足需求?

首先回答第二、三個問題:HTTP 協議並沒有約定在狀態碼非 2xx 的時候不能返回 body,返回之後是否讀取取決於用戶。grpc 采用protobuf進行序列化,所以無法返回 exception;且try catch機制為java獨有,其他語言並沒有對應的需求,但Grpc暫時不支持的功能並一定是unimplemented,Dubbo的設計目標之一是希望能和主流協議甚至架構進行對齊,但對於用戶合理的需求也希望能進行一定程度的修改。且從throw本身的語義出發,throw 的數據不隻是一個 error message,序列化的異常信息帶有業務屬性,根據這個角度,更不應該采用類似trailer的設計。至於單一的Wrapper層,也沒辦法和grpc進行互通。至於Http header狀態碼設置為200,因為其返回的異常信息已經帶有一定的業務屬性,不再是單純的error,這個設計也與grpc保持一致,未來考慮網關采集可以增加新的triple-status。

更改後的版本隻需在異常不為空時返回相關的異常信息,采用 TripleWrapper + Protobuf 進行序列化異常信息,並在consumer端進行解析和反序列化,大體流程如下:

總結

通過對 Dubbo 3.0 新增自定義異常的版本迭代中可以看出,盡管隻能新增一個小小的特性,流程下並不復雜,但由於要考慮互通、兼容和協議的設計理念,因此思考和討論的時間可能比寫代碼的時間更多。

以上就是Triple協議支持Java異常回傳設計實現詳解的詳細內容,更多關於Java異常回傳Triple協議的資料請關註WalkonNet其它相關文章!

推薦閱讀: