Triple 协议支持 Java 异常回传的设计与实现

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
可观测可视化 Grafana 版,10个用户账号 1个月
简介: 通过对 Dubbo 3.0 新增自定义异常的版本迭代中可以看出,尽管只能新增一个小小的特性,流程下并不复杂,但由于要考虑互通、兼容和协议的设计理念,因此思考和讨论的时间可能比写代码的时间更多。

作者:陈景明


背景


在一些业务场景,往往需要自定义异常来满足特定的业务,主流用法是在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) {
    //做相应的处理
    ...
}


在 dubbo2.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 的回传异常会在 AbstractInvoker 的 waitForResultIfSync 中把异常信息堆栈统一封装成 RpcException,所有来自 Provider 端的异常都会被封装成 RpcException 类型并抛出,这会导致用户无法根据特定的异常类型捕获来自 Provider 的异常,只能通过捕获 RpcException 异常来返回信息,且 Provider 携带的异常 message 也无法回传,只能获取打印的堆栈信息:


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


自定义异常信息在社区中的呼声也比较高,因此本次改动将支持自定义异常的功能, 使得服务端能抛出自定义异常后被客户端捕获到,至于 ExceptionBuilder 并不是主流的用法,因此不予支持。


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 端的结果会被封装成 AyncResult,在 AbstractInvoker 的具体实现类里,接受到来自 Provider 的响应之后会调用 appResponse 到 recreate 方法,若 appResponse 里包含异常,则会抛出给用户,大体流程如下:


1.jpeg

上述的异常处理相关环节是在 Consumer 端,在 Provider 端则是由 org.apache.dubbo.rpc.filter.ExceptionFilter 进行处理,它是一系列责任链Filter中的一环,专门用来处理异常。Dubbo 在 Provider 端的异常会在封装进 appResponse 中。下面的流程图揭示了 ExceptionFilter 源码的异常处理流程:


2.jpeg


而当 appResponse 回到了 Consumer 端,会在 InvocationUtil 里调用 AppResponse 的 recreate 方法抛出异常,最终可以在 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 端的响应结果,并调用 ClientCallToObserverAdapter 的 onNext 方法发送消息,最终会在底层封装成 Netty 请求发送数据。


在正式的请求发起前,TripleServer 会注册 TripleHttp2FrameServerHandler,它继承自 Netty 的 ChannelDuplexHandler,其作用是会在 channelRead 方法中不断读取 Header 和 Data 信息并解析,经过层层调用,会在 AbstractServerCall 的 onMessage 方法里把来自 consumer 的信息流进行反序列化,并最终由交由 ServerCallToObserverAdapter 的 invoke 方法进行处理。在 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 新增自定义异常的版本迭代中可以看出,尽管只能新增一个小小的特性,流程下并不复杂,但由于要考虑互通、兼容和协议的设计理念,因此思考和讨论的时间可能比写代码的时间更多。


相关文章
|
1天前
|
前端开发 Java 应用服务中间件
【异常解决】java程序连接MinIO报错The request signature we calculated does not match the signature you provided.
【异常解决】java程序连接MinIO报错The request signature we calculated does not match the signature you provided.
|
2天前
|
Java 关系型数据库 MySQL
【JAVA进阶篇教学】第八篇:Java链接MySql数据库异常
【JAVA进阶篇教学】第八篇:Java链接MySql数据库异常
|
2天前
|
Java 索引
【JAVA基础篇教学】第七篇:Java异常类型说明
【JAVA基础篇教学】第七篇:Java异常类型说明
|
2天前
|
Java Android开发
java利用xml-rpc协议操作wordpress博客
java利用xml-rpc协议操作wordpress博客
|
5天前
|
存储 Java 开发者
探索Java开发中触发空指针异常的场景
作为一名后端开发者在Java编程的世界中,想必大家对空指针并不陌生,空指针异常是一种常见而又令人头疼的问题,它可能会在我们最不经意的时候突然出现,给我们的代码带来困扰,甚至导致系统的不稳定性,而且最可怕的是有时候不能及时定位到它的具体位置。针对这个问题,我们需要深入了解触发空指针异常的代码场景,并寻找有效的方法来识别和处理这些异常情况,而且我觉得空指针异常是每个Java开发者都可能面临的挑战,但只要我们深入了解它的触发场景,并采取适当的预防和处理措施,我们就能够更好地应对这个问题。那么本文就来分享一下实际开发中一些常见的触发空指针异常的代码场景,并分享如何有效地识别和处理这些异常情况。
19 1
探索Java开发中触发空指针异常的场景
|
6天前
|
SQL 网络协议 Java
Java异常详解
Java异常详解
8 1
|
15天前
|
Java 程序员 编译器
|
17天前
Swagger2异常:java.lang.NumberFormatException: For input string: ““
Swagger2异常:java.lang.NumberFormatException: For input string: ““
20 1
|
18天前
|
存储 Java 程序员
JavaSE&Java的异常
JavaSE&Java的异常
26 0
|
25天前
|
网络协议 Java API
深度剖析:Java网络编程中的TCP/IP与HTTP协议实践
【4月更文挑战第17天】Java网络编程重在TCP/IP和HTTP协议的应用。TCP提供可靠数据传输,通过Socket和ServerSocket实现;HTTP用于Web服务,常借助HttpURLConnection或Apache HttpClient。两者结合,构成网络服务基础。Java有多种高级API和框架(如Netty、Spring Boot)简化开发,助力高效、高并发的网络通信。