Triple 協議支援 Java 異常回傳的設計與實現

ApacheDubbo發表於2022-12-29

作者:Apache Dubbo Contributor 陳景明

背景

在一些業務場景, 往往需要自定義異常來滿足特定的業務, 主流用法是在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 機制預設實現的是 JavassistProxyFactory
JavassistProxyFactory 建立了一個繼承自 AbstractProxyInvoker 類的匿名物件,
並重寫了抽象方法 doInvoke
重寫後的 doInvoke 只是將呼叫請求轉發給了 Wrapper 類的 invokeMethod 方法,
並生成 invokeMethod 方法程式碼和其他一些方法程式碼。

程式碼生成完畢後,透過 Javassist 生成 Class 物件,
最後再透過反射建立 Wrapper 例項,隨後透過 InvokerInvocationHandler -> InvocationUtil -> AbstractInvoker -> 具體實現類傳送請求到Provider端。

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

1.jpeg

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

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

2.jpeg

而當 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 的具體實現類是 TripleInvoker
TripleInvoker 在傳送前會啟動監聽器,監聽來自 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());
        });
    } 
    ...
}

大體流程如下:

3.jpeg

實現版本

瞭解了上述原理,我們就可以進行相應的改造了,
能讓 consumer 端捕獲異常的關鍵在於把異常物件以及異常資訊序列化後再傳送給consumer端
常見的序列化協議很多,例如 Dubbo/HSF 預設的 hessian2 序列化;
還有使用廣泛的 JSON 序列化;以及 gRPC 原生支援的 protobuf(PB) 序列化等等。
Triple協議因為相容grpc的原因,預設採用 Protobuf 進行序列化。
上述提到的這三種典型的序列化方案作用類似,但在實現和開發中略有不同。
PB 不可由序列化後的位元組流直接生成記憶體物件,
而 Hessian 和 JSON 都是可以的。後兩者反序列化的過程不依賴“二方包”,
其序列化和反序列化的程式碼由 proto 檔案相同,只要客戶端和服務端用相同的 proto 檔案進行通訊,
就可以構造出通訊雙方可解析的結構。

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

4.jpeg

上面的實現方案看似非常合理,已經能把 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端進行解析和反序列化,大體流程如下:

5.jpeg

總結

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

歡迎在 https://github.com/apache/dubbo 給 Dubbo Star。
搜尋關注官方微信公眾號:Apache Dubbo,瞭解更多業界最新動態,掌握大廠面試必備 Dubbo 技能

相關文章