Dubbo服务调用过程

本文已同步至我的公众号 Code4j,欢迎各位看官老爷来玩。

1. 什么是远程过程调用

在讲述 Dubbo 的服务调用过程之前,让我们先来了解一下什么是远程过程调用。

远程过程调用即 Remote Producedure Call,简单来说就是跨进程调用,通过网络传输,使得 A 机器上的应用可以像调用本地的服务一样去调用 B 机器上的服务。

举个最简单的栗子,假设现在有一个电商系统,其中有着用户服务,优惠券服务,订单服务等服务模块,这些不同的服务并不是运行在同一个 JVM 中,而是分开运行在不同的 JVM 中。因此,当订单服务想要调用优惠券服务时,就不能像以前的单体应用一样,直接向对应服务发起本地调用,只能够通过网络来发起调用。

那么,一个最简单的远程过程调用是怎么样的呢?来看下面这张图。

Dubbo服务调用过程_第1张图片

也就是说,一次最简单的 RPC 调用,无非就是调用方通过网络,将调用的参数传送到服务方,服务方收到调用请求后,根据参数完成本地调用,并且将结果通过网络传送回调用方。

在这个过程中,像参数的封装,网络传输等细节会由 RPC 框架来完成,把上面的图片完善一下,一个完整的 RPC 调用的流程是这样的:

  • 客户端(Client)以本地调用的方式调用远程服务。
  • 客户端代理对象(Client Stub)将本次请求的相关信息(要调用的类名、方法名、方法参数等)封装成 Request,并且对其序列化,为网络通信做准备。
  • 客户端代理对象(Client Stub)找到服务端(Server)的地址,通过网络(Socket 通信)将 Request 发送到服务端。
  • 服务端代理对象(Server Stub)接收到客户端(Client)的请求后,将二进制数据反序列化为 Request
  • 服务端代理对象(Server Stub)根据调用信息向本地的方法发起调用。
  • 服务端代理对象(Server Stub)将调用后的结果封装到 Response 中,并且对其序列化,通过网络发送给客户端。
  • 客户端代理对象(Client Stub)收到响应后,将其反序列化为 Response,远程调用结束。

Dubbo服务调用过程_第2张图片

2. Dubbo 的远程调用过程

本节内容基于 Dubbo 2.6.x 版本,并且使用官网提供的 Demo 对同步调用进行分析。

在上一节内容中,我们已经对服务调用的过程有了一定的了解。实际上,Dubbo 在实现远程调用的时候,核心流程和上面的图片是完全一样的,只不过 Dubbo 在此基础上增加了一些额外的流程,例如集群容错、负载均衡、过滤器链等。

本篇文章只分析核心的调用流程,其它的额外流程可以自行了解。

在讲解 Dubbo 的调用过程之前,我们先来了解一下 Dubbo 的一些概念。

  • Invoker:在 Dubbo 中作为实体域,也就是代表了要操作的对象模型,这有点像 Spring 中的 Bean,所有的操作都是围绕着这个实体域来进行。

    • 代表了一个可执行体,可以向它发起 invoke 调用。它有可能是一个本地实现,也有可能是一个远程实现,也有可能是一个集群实现。
  • Invocation:在 Dubbo 中作为会话域,表示每次操作的瞬时状态,操作前创建,操作后销毁。

    • 其实就是调用信息,存放了调用的类名、方法名、参数等信息。
  • Protocol:在 Dubbo 作为服务域,负责实体域和会话域的生命周期管理。

    • 可以理解为 Spring 中的 BeanFactory,是产品的入口。

2.1 远程调用的开端 —— 动态代理

在了解以上基本概念后,我们开始来跟踪 Dubbo 的远程调用流程。在 RPC 框架中,想要实现远程调用,代理对象是不可或缺的,因为它可以帮我们屏蔽很多底层细节,使得我们对远程调用无感知。

如果用过 JDK 的动态代理或者是 CGLIB 的动态代理,那么应该都知道每个代理对象都会有对应的一个处理器,用于处理动态代理时的增强,例如 JDK 使用的 InvacationHandler 或者 CGLIB 的 MethodInterceptor。在 Dubbo 中,默认是使用 javasisst 来实现动态代理的,它与 JDK 动态一样使用 InvocationHandler 来进行代理增强。

Dubbo服务调用过程_第3张图片

下面分别是使用 javasisst 和使用 JDK 动态代理时对代理类进行反编译后的结果。

Dubbo服务调用过程_第4张图片

从上面可以看出,InvacationHandler 要做的事无非就是根据本次调用的方法名和方法参数,将其封装成调用信息 Invacation,然后将其传递给持有的 Invoker 对象。从这里开始,才算是真正进入到了 Dubbo 的核心模型中。

2.2 客户端的调用链路

在了解客户端的调用链路之前,我们需要先看一下 Dubbo 的整体设计,下图是来自于 Dubbo 官网的一张框架设计图,很好地展示了整个框架的结构。

Dubbo服务调用过程_第5张图片

为了容易理解,我把上图中的 Proxy 代理层、Cluster 集群层以及 Protocol 协议层进行了一个抽象。

如下图所示, Dubbo 的 Proxy 代理层先与下层的 Cluster 集群层进行交互。Cluster 这一层的作用就是将多个 Invoker 伪装成一个 ClusterInvoker 后暴露给上层使用,由该 ClusterInvoker 来负责容错的相关逻辑,例如快速失败,失败重试等等。对于上层的 Proxy 来说,这一层的容错逻辑是透明的。

Dubbo服务调用过程_第6张图片

因此,当 Proxy 层的 InvocationHandler 将调用请求委托给持有的 Invoker 时,其实就是向下传递给对应的 ClusterInvoker,并且经过获取可用 Invoker,根据路由规则过滤 Invoker,以及负载均衡选中要调用的 Invoker 等一系列操作后,就会得到一个具体协议的 Invoker

这个具体的 Invoker 可能是一个远程实现,例如默认的 Dubbo 协议对应的 DubboInvoker,也有可能是一个本地实现,例如 Injvm 协议对应的 InjvmInvoker 等。

关于集群相关的 Invoker,如果有兴趣的话可以看一下用于服务降级的 MockClusterInvoker,集群策略抽象父类 AbstractClusterInvoker 以及默认的也是最常用的失败重试集群策略 FailoverClusterInvoker,实际上默认情况下的集群调用链路就是逐个经过这三个类的。

顺带提一句,在获取到具体的协议 Invoker 之前会经过一个过滤器链,对于每一个过滤器对于本次请求都会做一些处理,比如用于统计的 MonitorFilter,用于处理当前上下文信息的 ConsumerContextFilter 等等。过滤器这一部分给用户提供了很大的扩展空间,有兴趣的话可以自行了解。

拿到具体的 Invoker 之后,此时所处的位置为上图中的 Protocol 层,这时候就可以通过下层的网络层来完成远程过程调用了,先来看一下 DubboInvoker 的源码。

Dubbo服务调用过程_第7张图片

可以看到,Dubbo 对于调用方式做了一些区分,分别为同步调用,异步调用以及单次调用。

首先有一点要明确的是,同步调用也好,异步调用也好,这都是站在用户的角度来看的,但是在网络这一层面的话,所有的交互都是异步的,网络框架只负责将数据发送出去,或者将收到的数据向上传递,网络框架并不知道本次发送出去的二进制数据和收到的二进制的数据是否是一一对应的。

因此,当用户选择同步调用的时候,为了将底层的异步通信转化为同步操作,这里 Dubbo 需要调用某个阻塞操作,使用户线程阻塞在这里,直到本次调用的结果返回。

2.3 远程调用的基石 —— 网络层

在上一小节的 DubboInvoker 当中,我们可以看到远程调用的请求是通过一个 ExchangeClient 的类发送出去的,这个 ExchangeClient 类处于 Dubbo 框架的远程通信模块中的 Exchange 信息交换层。

从前面出现过的架构图中可以看到,远程通信模块共分为三层,从上到下分别是 Exchange 信息交换层,Transport 网络传输层以及 Serialize 序列化层,每一层都有其特定的作用。

从最底层的 Serialize 层说起,这一层的作用就是负责序列化/反序列化,它对多种序列化方式进行了抽象,如 JDK 序列化,Hessian 序列化,JSON 序列化等。

往上则是 Transport 层,这一层负责的单向的消息传输,强调的是一种 Message 的语义,不体现交互的概念。同时这一层也对各种 NIO 框架进行了抽象,例如 Netty,Mina 等等。

再往上就是 Exhange 层,和 Transport 层不同,这一层负责的是请求/响应的交互,强调的一种 RequestReponse 的语义,也正是由于请求响应的存在,才会有 ClientServer 的区分。

Dubbo服务调用过程_第8张图片

了解完远程通信模块的分层结构后,我们再来看一下该模块中的核心概念。

Dubbo 在这个模块中抽取出了一个端点 Endpoint 的概念,通过一个 IP 和 一个 Port,就可以唯一确定一个端点。在这两个端点之间,我们可以建立 TCP 连接,而这个连接被 Dubbo 抽象成了通道 Channel,通道处理器 ChannelHandler 则负责对通道进行处理,例如处理通道的连接建立事件、连接断开事件,处理读取到的数据、发送的数据以及捕获到的异常等。

同时,为了在语义上对端点进行区分,Dubbo 将发起请求的端点抽象为客户端 Client,而发送响应的端点则抽象成服务端 Server。由于不同的 NIO 框架对外接口和使用方式不一样,所以为了避免上层接口直接依赖具体的 NIO 库,Dubbo 在 ClientServer 之上又抽象出了一个 Transporter 接口,该接口用于获取 ClientServer,后续如果需要更换使用的 NIO 库,那么只需要替换相关实现类即可。

Dubbo 将负责数据编解码功能的处理器抽象成了 Codec 接口,有兴趣的话可以自行了解。

Dubbo服务调用过程_第9张图片

Endpoint 主要的作用就是发送数据,因此 Dubbo 为其定义了 send() 方法;同时,让 Channel 继承 Endpoint,使其在发送数据的基础上拥有添加 K/V 属性的功能。

对于客户端来说,一个 Cleint 只会关联着一个 Channel,因此直接继承 Channel 使其也具备发送数据的功能即可,而 Server 可以接受多个 Cleint 建立的 Channel 连接,所以 Dubbo 没有让其继承 Channel,而是选择让其直接继承 Endpoint,并且提供了 getChannels() 方法用于获取关联的连接。

为了体现了请求/响应的交互模式,在 ChannelServer 以及 Client 的基础上进一步抽象出 ExchangeChannelExchangeServer 以及 ExchangeClient 接口,并为 ExchangeChannel 接口添加 request() 方法,具体类图如下。

Dubbo服务调用过程_第10张图片

了解完网络层的相关概念后,让我们看回 DubboInvoker,当同步调用时,DubboInvoker 会通过持有的 ExchangeClient 来发起请求。实际上,这个调用最后会被 HeaderExchangeChannel 类所接收,这是一个实现了 ExchangeChannel 的类,因此也具备请求的功能。

Dubbo服务调用过程_第11张图片

可以看到,其实 request() 方法只不过是将数据封装成 Request 对象,构造一个请求的语义,最终还是通过 send() 方法将数据单向发送出去。下面是一张关于客户端发送请求的调用链路图。

Dubbo服务调用过程_第12张图片

这里值得注意的是 DefaultFuture 对象的创建。DefaultFuture 类是 Dubbo 参照 Java 中的 Future 类所设计的,这意味着它可以用于异步操作。每个 Request 对象都有一个 ID,当创建 DefaultFuture 时,会将请求 ID 和创建的 DefaultFutrue 映射给保存起来,同时设置超时时间。

保存映射的目的是因为在异步情况下,请求和响应并不是一一对应的。为了使得后面接收到的响应可以正确被处理,Dubbo 会在响应中带上对应的请求 ID,当接收到响应后,根据其中的请求 ID 就可以找到对应的 DefaultFuture,并将响应结果设置到 DefaultFuture,使得阻塞在 get() 操作的用户线程可以及时返回。

Dubbo服务调用过程_第13张图片

整个过程可以抽象为下面的时序图。

Dubbo服务调用过程_第14张图片

ExchangeChannel 调用 send() 后,数据就会通过底层的 NIO 框架发送出去,不过在将数据通过网络传输之前,还有最后一步需要做的,那就是序列化和编码。

注意,在调用 send() 方法之前,所有的逻辑都是用户线程在处理的,而编码工作则是由 Netty 的 I/O 线程处理,有兴趣的话可以了解一下 Netty 的线程模型。

2.4 协议和编码

上文提到过很多次协议(Protocol)和编码,那么到底什么是协议,什么又是编码呢?

其实,通俗一点讲,协议就是一套约定好的通信规则。打个比方,张三和李四要进行交流,那么他们之间在交流之前就需要先约定好如何交流,比如双方约定,当听到“Hello World”的时候,就代表对方要开始讲话了。此时,张三和李四之间的这种约定就是他们的通信协议。

而对于编码的话,其实就是根据约定好的协议,将数据组装成协议规定的格式。当张三想和李四说“早上好”的时候,那么张三只需要在“早上好”之前加上约定好的“Hello World”,也就是最终的消息为“Hello World 早上好”。李四一听到“Hello World”,就知道随后的内容是张三想说的,通过这种形式,张三和李四之间就可以完成正常的交流了。

具体到实际的 RPC 通信中,所谓的 Dubbo 协议,RMI 协议,HTTP 协议等等,它们只不过是对应的通信规则不一样,但最终的作用都是一样的,就是提供给组装通信数据的一套规则,仅此而已。

这里借用一张官网的图,展示了默认的 Dubbo 协议数据包格式。

Dubbo 数据包分为消息头和消息体。消息头为定长格式,共 16 字节,用于存储一些元信息,例如消息的起始标识 Magic Number,数据包的类型,使用的序列化方式 ID,消息体长度等。消息体则为变长格式,具体长度存储在消息头中,这部分是用于存储了具体的调用信息或调用结果,也就是 Invocation 序列化后的字节序列或远程调用返回的对象的字节序列,消息体这部分的数据是由序列化/反序列化来处理的。

Dubbo服务调用过程_第15张图片

之前提到过,Dubbo 将用于编解码数据的通道处理器抽象为了 Codec 接口,所以在消息发送出去之前,Dubbo 会调用该接口的 encode() 方法进行编码。其中,对于消息体,也就是本次调用的调用信息 Invacation,会通过 Serialization 接口来进行序列化。

Dubbo 在启动客户端和服务端的时候,会通过适配器模式,将 Codec 相关的编解码器与 Netty 进行适配,将其添加到 Netty 的 pipeline 中,参见 NettyCodecAdapterNettyClientNettyServer

Dubbo服务调用过程_第16张图片

下面是相关的编码逻辑,对照上图食用更佳。

Dubbo服务调用过程_第17张图片

编码完成之后,数据就会被 NIO 框架所发出,通过网络到达服务端。

2.5 服务端的调用链路

当服务端接收到数据的时候,因为接收到的都是字节序列,所以第一步应该是对其解码,这一步最终会交给 Codec 接口的 decode 方法处理。

解码的时候会先解析得到消息头,然后再根据消息头中的元信息,例如消息头长度,消息类型,将消息体反序列化为 DecodeableRpcInvocation 对象(也就是调用信息)。

Dubbo服务调用过程_第18张图片

此时的线程为 Netty 的 I/O 线程,不一定会在当前线程解码,所以有可能会得到部分解码的 Request 对象,具体分析见下文。

Dubbo服务调用过程_第19张图片

值得注意的是,在 2.6.x 版本中,默认情况下对于请求的解码会在 I/O 线程中执行,而 2.7.x 之后的版本则是交给业务线程执行。

这里的 I/O 线程指的是底层通信框架中接收请求的线程(其实就是 Netty 中的 Worker 线程),业务线程则是 Dubbo 内部用于处理请求/响应的线程池中的线程。如果某个事件可能比较耗时,不能在 I/O 线程上执行,那么就需要通过线程派发器将线程派发到线程池中去执行。

再次借用官网的一张图,当服务端接收到请求时,会根据不同的线程派发策略,将请求派发到线程池中执行。线程派发器 Dispatcher 本身并不具备线程派发的能力,它只是用于创建具有线程派发能力的 ChannelHandler

Dubbo服务调用过程_第20张图片

Dubbo 拥有 5 种线程派发策略,默认使用的策略为 all,具体策略差别见下表。

策略 用途
all 所有消息都派发到线程池,包括请求,响应,连接事件,断开事件等
direct 所有消息都不派发到线程池,全部在 IO 线程上直接执行
message 只有请求和响应消息派发到线程池,其它消息均在 IO 线程上执行
execution 只有请求消息派发到线程池,不含响应。其它消息均在 IO 线程上执行
Connection 在 IO 线程上,将连接断开事件放入队列,有序逐个执行,其它消息派发到线程池

经过 DubboCodec 解码器处理过的数据会被 Netty 传递给下一个入站处理器,最终根据配置的线程派发策略来到对应的 ChannelHandler,例如默认的 AllChannelHandler

Dubbo服务调用过程_第21张图片

可以看到,对于每种事件,AllChannelHandler 只是创建了一个 ChannelEventRunnable 对象并提交到业务线程池中去执行,这个 Runnable 对象其实只是一个中转站,它是为了避免在 I/O 线程中执行具体的操作,最终真正的操作它会委托给持有的 ChannelHandler 去处理。

Dubbo服务调用过程_第22张图片

服务端对请求进行派发的过程如下图所示。

Dubbo服务调用过程_第23张图片

上面说过,解码操作也有可能在业务线程中执行,因为 ChannelEventRunnable 中直接持有的 ChannelHandler 就是一个用于解码的 DecodeHandler

如果需要解码,那么这个通道处理器会调用在 I/O 线程中创建的 DecodeableRpcInvocation 对象的 decode 方法,从字节序列中反序列化得到本次调用的类名,方法名,参数信息等。

解码完成后,DecodeHandler 会将完全解码的 Request 对象继续传递到下一个通道处理器即 HeaderExchangeHandler

Dubbo服务调用过程_第24张图片

到这里其实已经可以体会到 Dubbo 抽取出 ChannelHandler 的好处了,可以避免和特定 NIO 库耦合,同时使用装饰者模式一层层地处理请求,最终对 NIO 库只暴露出一个特定的 Handler,更加灵活。

这里附上一张服务端 ChannelHandler 的结构图。

Dubbo服务调用过程_第25张图片

HeaderExchangeHandler 会根据本次请求的类型决定如何处理。如果是单向调用,那么只需向后调用即可,不需要返回响应。如果是双向调用,那么就需要在得到具体的调用结果后,封装成 Response 对象,并通过持有的 Channel 对象将本次调用的响应发送回客户端。

Dubbo服务调用过程_第26张图片

HeaderExchangeHandler 将调用委托给持有的 ExchangeHandler 处理器,这个处理器是和服务暴露时使用的协议有关的,一般来说都是某个协议的内部类。

由于默认情况下都是使用的 Dubbo 协议,所以接下来对 Dubbo 协议中的处理器进行分析。

Dubbo服务调用过程_第27张图片

Dubbo 协议内部的 ExchangeHandler 会从已经暴露的服务列表中找到本次调用的 Invoker,并且向其发起本地调用。不过要注意的是,这里的 Invoker 是一个动态生成的代理对象,类型为 AbstractProxyInvoker,它持有了处理业务的真实对象。

当发起 invoke 调用时,它会通过持有的真实对象完成调用,并将其封装到 RpcResult 对象中并且返回给下层。

Dubbo服务调用过程_第28张图片

关于 RpcResult 有兴趣的话可以了解一下 2.7.x 异步化改造后的变化。简单来说就是 RpcResultAppResonse 所替代,用来保存调用结果或调用异常,同时引入了一个新的中间状态类 AsyncRpcResult 用于代表未完成的 RPC 调用。

这个代理对象是在服务端进行服务暴露的时候生成的,javassist 会动态生成一个 Wrapper 类,并且创建一个匿名内部对象,将调用操作委托给 Wrapper

Dubbo服务调用过程_第29张图片

下面是反编译得到的 Wrapper 类,可以看到具体的处理逻辑和客户端的 InvocationHandler 类似,都是根据本次调用的方法名来向真实对象发起调用。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uEHGfojb-1638329374398)(https://typora-pics-1255993109.cos.ap-guangzhou.myqcloud.com/Wrapper%20(1)].png)

至此,服务端已完成了调用过程。下层 ChannelHandler 收到调用结果后,就会通过 Channel 将响应发送回客户端,期间又会经过编码序列化等操作,由于和请求的编码序列化过程类似,这里不再赘述,感兴趣的话可以自行查看 ExchangeCodec#encodeResponse() 以及 DubboCodec#encodeResponseData()

这里再附上一张服务端处理请求的时序图。

Dubbo服务调用过程_第30张图片

2.6 客户端处理响应

当客户端收到调用的响应后,毫无疑问依旧需要对收到的字节序列进行解码及反序列化,这里和服务端解码请求的过程是类似的,查看 ExchangeCodec#decode() 以及 DubboCodec#decodeBody() 自行了解,也可参考上面的服务端解码请求的时序图,这里只附上一张客户端处理已(部分)解码的响应的时序图。

Dubbo服务调用过程_第31张图片

这里主要讲的是客户端对解码后的 Reponse 对象的处理逻辑。客户端的 ChannelHandler 结构和上面的服务端 ChnnelHandler 结构图没有太大区别,经过解码后的响应最终也会传递到 HeaderExchangeHandler 处理器中进行处理。

Dubbo服务调用过程_第32张图片

在客户端发起请求时我们提到过,每个构造的请求都有一个 ID 标识,当对应的响应返回时,就会把这个 ID 带上。当接收到响应时, Dubbo 会从请求的 Future 映射集合中,根据返回的请求 ID,找到对应的 DefaultFuture,并将结果设置到 DefaultFuture 中,同时唤醒阻塞的用户线程,这样就完成了 Dubbo 的业务线程到用户线程的转换。

Dubbo服务调用过程_第33张图片

有兴趣的话可以再了解一下 DefauFuture 的超时处理 以及 Dubbo 2.7 异步化改造后的线程模型变化。

最后附上一张来源官网的图。

Dubbo服务调用过程_第34张图片

至此,一个完整的 RPC 调用就结束了。

由于本人水平有限,可能部分细节并没有讲清楚 ,如果有疑问的话欢迎大家指出,一起交流学习。

3. 参考链接

  • Dubbo 官网 - 服务调用过程

  • 《深入理解 Apache Dubbo 与实战》

你可能感兴趣的:(Dubbo,java,dubbo)