本文已同步至我的公众号 Code4j,欢迎各位看官老爷来玩。
在讲述 Dubbo 的服务调用过程之前,让我们先来了解一下什么是远程过程调用。
远程过程调用即 Remote Producedure Call
,简单来说就是跨进程调用,通过网络传输,使得 A 机器上的应用可以像调用本地的服务一样去调用 B 机器上的服务。
举个最简单的栗子,假设现在有一个电商系统,其中有着用户服务,优惠券服务,订单服务等服务模块,这些不同的服务并不是运行在同一个 JVM 中,而是分开运行在不同的 JVM 中。因此,当订单服务想要调用优惠券服务时,就不能像以前的单体应用一样,直接向对应服务发起本地调用,只能够通过网络来发起调用。
那么,一个最简单的远程过程调用是怎么样的呢?来看下面这张图。
也就是说,一次最简单的 RPC 调用,无非就是调用方通过网络,将调用的参数传送到服务方,服务方收到调用请求后,根据参数完成本地调用,并且将结果通过网络传送回调用方。
在这个过程中,像参数的封装,网络传输等细节会由 RPC 框架来完成,把上面的图片完善一下,一个完整的 RPC 调用的流程是这样的:
Request
,并且对其序列化,为网络通信做准备。Request
发送到服务端。Request
。Response
中,并且对其序列化,通过网络发送给客户端。Response
,远程调用结束。本节内容基于 Dubbo 2.6.x 版本,并且使用官网提供的 Demo 对同步调用进行分析。
在上一节内容中,我们已经对服务调用的过程有了一定的了解。实际上,Dubbo 在实现远程调用的时候,核心流程和上面的图片是完全一样的,只不过 Dubbo 在此基础上增加了一些额外的流程,例如集群容错、负载均衡、过滤器链等。
本篇文章只分析核心的调用流程,其它的额外流程可以自行了解。
在讲解 Dubbo 的调用过程之前,我们先来了解一下 Dubbo 的一些概念。
Invoker
:在 Dubbo 中作为实体域,也就是代表了要操作的对象模型,这有点像 Spring 中的 Bean,所有的操作都是围绕着这个实体域来进行。
invoke
调用。它有可能是一个本地实现,也有可能是一个远程实现,也有可能是一个集群实现。Invocation
:在 Dubbo 中作为会话域,表示每次操作的瞬时状态,操作前创建,操作后销毁。
Protocol
:在 Dubbo 作为服务域,负责实体域和会话域的生命周期管理。
在了解以上基本概念后,我们开始来跟踪 Dubbo 的远程调用流程。在 RPC 框架中,想要实现远程调用,代理对象是不可或缺的,因为它可以帮我们屏蔽很多底层细节,使得我们对远程调用无感知。
如果用过 JDK 的动态代理或者是 CGLIB 的动态代理,那么应该都知道每个代理对象都会有对应的一个处理器,用于处理动态代理时的增强,例如 JDK 使用的 InvacationHandler
或者 CGLIB 的 MethodInterceptor
。在 Dubbo 中,默认是使用 javasisst 来实现动态代理的,它与 JDK 动态一样使用 InvocationHandler
来进行代理增强。
下面分别是使用 javasisst 和使用 JDK 动态代理时对代理类进行反编译后的结果。
从上面可以看出,InvacationHandler
要做的事无非就是根据本次调用的方法名和方法参数,将其封装成调用信息 Invacation
,然后将其传递给持有的 Invoker
对象。从这里开始,才算是真正进入到了 Dubbo 的核心模型中。
在了解客户端的调用链路之前,我们需要先看一下 Dubbo 的整体设计,下图是来自于 Dubbo 官网的一张框架设计图,很好地展示了整个框架的结构。
为了容易理解,我把上图中的 Proxy
代理层、Cluster
集群层以及 Protocol
协议层进行了一个抽象。
如下图所示, Dubbo 的 Proxy
代理层先与下层的 Cluster
集群层进行交互。Cluster
这一层的作用就是将多个 Invoker
伪装成一个 ClusterInvoker
后暴露给上层使用,由该 ClusterInvoker
来负责容错的相关逻辑,例如快速失败,失败重试等等。对于上层的 Proxy
来说,这一层的容错逻辑是透明的。
因此,当 Proxy
层的 InvocationHandler
将调用请求委托给持有的 Invoker
时,其实就是向下传递给对应的 ClusterInvoker
,并且经过获取可用 Invoker
,根据路由规则过滤 Invoker
,以及负载均衡选中要调用的 Invoker
等一系列操作后,就会得到一个具体协议的 Invoker
。
这个具体的 Invoker
可能是一个远程实现,例如默认的 Dubbo 协议对应的 DubboInvoker
,也有可能是一个本地实现,例如 Injvm 协议对应的 InjvmInvoker
等。
关于集群相关的
Invoker
,如果有兴趣的话可以看一下用于服务降级的MockClusterInvoker
,集群策略抽象父类AbstractClusterInvoker
以及默认的也是最常用的失败重试集群策略FailoverClusterInvoker
,实际上默认情况下的集群调用链路就是逐个经过这三个类的。顺带提一句,在获取到具体的协议
Invoker
之前会经过一个过滤器链,对于每一个过滤器对于本次请求都会做一些处理,比如用于统计的MonitorFilter
,用于处理当前上下文信息的ConsumerContextFilter
等等。过滤器这一部分给用户提供了很大的扩展空间,有兴趣的话可以自行了解。
拿到具体的 Invoker
之后,此时所处的位置为上图中的 Protocol
层,这时候就可以通过下层的网络层来完成远程过程调用了,先来看一下 DubboInvoker
的源码。
可以看到,Dubbo 对于调用方式做了一些区分,分别为同步调用,异步调用以及单次调用。
首先有一点要明确的是,同步调用也好,异步调用也好,这都是站在用户的角度来看的,但是在网络这一层面的话,所有的交互都是异步的,网络框架只负责将数据发送出去,或者将收到的数据向上传递,网络框架并不知道本次发送出去的二进制数据和收到的二进制的数据是否是一一对应的。
因此,当用户选择同步调用的时候,为了将底层的异步通信转化为同步操作,这里 Dubbo 需要调用某个阻塞操作,使用户线程阻塞在这里,直到本次调用的结果返回。
在上一小节的 DubboInvoker
当中,我们可以看到远程调用的请求是通过一个 ExchangeClient
的类发送出去的,这个 ExchangeClient
类处于 Dubbo 框架的远程通信模块中的 Exchange
信息交换层。
从前面出现过的架构图中可以看到,远程通信模块共分为三层,从上到下分别是 Exchange
信息交换层,Transport
网络传输层以及 Serialize
序列化层,每一层都有其特定的作用。
从最底层的 Serialize
层说起,这一层的作用就是负责序列化/反序列化,它对多种序列化方式进行了抽象,如 JDK 序列化,Hessian 序列化,JSON 序列化等。
往上则是 Transport
层,这一层负责的单向的消息传输,强调的是一种 Message
的语义,不体现交互的概念。同时这一层也对各种 NIO 框架进行了抽象,例如 Netty,Mina 等等。
再往上就是 Exhange
层,和 Transport
层不同,这一层负责的是请求/响应的交互,强调的一种 Request
和 Reponse
的语义,也正是由于请求响应的存在,才会有 Client
和 Server
的区分。
了解完远程通信模块的分层结构后,我们再来看一下该模块中的核心概念。
Dubbo 在这个模块中抽取出了一个端点 Endpoint
的概念,通过一个 IP 和 一个 Port,就可以唯一确定一个端点。在这两个端点之间,我们可以建立 TCP 连接,而这个连接被 Dubbo 抽象成了通道 Channel
,通道处理器 ChannelHandler
则负责对通道进行处理,例如处理通道的连接建立事件、连接断开事件,处理读取到的数据、发送的数据以及捕获到的异常等。
同时,为了在语义上对端点进行区分,Dubbo 将发起请求的端点抽象为客户端 Client
,而发送响应的端点则抽象成服务端 Server
。由于不同的 NIO 框架对外接口和使用方式不一样,所以为了避免上层接口直接依赖具体的 NIO 库,Dubbo 在 Client
和 Server
之上又抽象出了一个 Transporter
接口,该接口用于获取 Client
和 Server
,后续如果需要更换使用的 NIO 库,那么只需要替换相关实现类即可。
Dubbo 将负责数据编解码功能的处理器抽象成了
Codec
接口,有兴趣的话可以自行了解。
Endpoint
主要的作用就是发送数据,因此 Dubbo 为其定义了 send()
方法;同时,让 Channel
继承 Endpoint
,使其在发送数据的基础上拥有添加 K/V
属性的功能。
对于客户端来说,一个 Cleint
只会关联着一个 Channel
,因此直接继承 Channel
使其也具备发送数据的功能即可,而 Server
可以接受多个 Cleint
建立的 Channel
连接,所以 Dubbo 没有让其继承 Channel
,而是选择让其直接继承 Endpoint
,并且提供了 getChannels()
方法用于获取关联的连接。
为了体现了请求/响应的交互模式,在 Channel
、Server
以及 Client
的基础上进一步抽象出 ExchangeChannel
、ExchangeServer
以及 ExchangeClient
接口,并为 ExchangeChannel
接口添加 request()
方法,具体类图如下。
了解完网络层的相关概念后,让我们看回 DubboInvoker
,当同步调用时,DubboInvoker
会通过持有的 ExchangeClient
来发起请求。实际上,这个调用最后会被 HeaderExchangeChannel
类所接收,这是一个实现了 ExchangeChannel
的类,因此也具备请求的功能。
可以看到,其实 request()
方法只不过是将数据封装成 Request
对象,构造一个请求的语义,最终还是通过 send()
方法将数据单向发送出去。下面是一张关于客户端发送请求的调用链路图。
这里值得注意的是 DefaultFuture
对象的创建。DefaultFuture
类是 Dubbo 参照 Java 中的 Future
类所设计的,这意味着它可以用于异步操作。每个 Request
对象都有一个 ID,当创建 DefaultFuture
时,会将请求 ID 和创建的 DefaultFutrue
映射给保存起来,同时设置超时时间。
保存映射的目的是因为在异步情况下,请求和响应并不是一一对应的。为了使得后面接收到的响应可以正确被处理,Dubbo 会在响应中带上对应的请求 ID,当接收到响应后,根据其中的请求 ID 就可以找到对应的 DefaultFuture
,并将响应结果设置到 DefaultFuture
,使得阻塞在 get()
操作的用户线程可以及时返回。
整个过程可以抽象为下面的时序图。
当 ExchangeChannel
调用 send()
后,数据就会通过底层的 NIO 框架发送出去,不过在将数据通过网络传输之前,还有最后一步需要做的,那就是序列化和编码。
注意,在调用 send() 方法之前,所有的逻辑都是用户线程在处理的,而编码工作则是由 Netty 的 I/O 线程处理,有兴趣的话可以了解一下 Netty 的线程模型。
上文提到过很多次协议(Protocol)和编码,那么到底什么是协议,什么又是编码呢?
其实,通俗一点讲,协议就是一套约定好的通信规则。打个比方,张三和李四要进行交流,那么他们之间在交流之前就需要先约定好如何交流,比如双方约定,当听到“Hello World”的时候,就代表对方要开始讲话了。此时,张三和李四之间的这种约定就是他们的通信协议。
而对于编码的话,其实就是根据约定好的协议,将数据组装成协议规定的格式。当张三想和李四说“早上好”的时候,那么张三只需要在“早上好”之前加上约定好的“Hello World”,也就是最终的消息为“Hello World 早上好”。李四一听到“Hello World”,就知道随后的内容是张三想说的,通过这种形式,张三和李四之间就可以完成正常的交流了。
具体到实际的 RPC 通信中,所谓的 Dubbo 协议,RMI 协议,HTTP 协议等等,它们只不过是对应的通信规则不一样,但最终的作用都是一样的,就是提供给组装通信数据的一套规则,仅此而已。
这里借用一张官网的图,展示了默认的 Dubbo 协议数据包格式。
Dubbo 数据包分为消息头和消息体。消息头为定长格式,共 16 字节,用于存储一些元信息,例如消息的起始标识 Magic Number
,数据包的类型,使用的序列化方式 ID,消息体长度等。消息体则为变长格式,具体长度存储在消息头中,这部分是用于存储了具体的调用信息或调用结果,也就是 Invocation
序列化后的字节序列或远程调用返回的对象的字节序列,消息体这部分的数据是由序列化/反序列化来处理的。
之前提到过,Dubbo 将用于编解码数据的通道处理器抽象为了 Codec
接口,所以在消息发送出去之前,Dubbo 会调用该接口的 encode()
方法进行编码。其中,对于消息体,也就是本次调用的调用信息 Invacation
,会通过 Serialization
接口来进行序列化。
Dubbo 在启动客户端和服务端的时候,会通过适配器模式,将
Codec
相关的编解码器与 Netty 进行适配,将其添加到 Netty 的 pipeline 中,参见NettyCodecAdapter
、NettyClient
和NettyServer
。
下面是相关的编码逻辑,对照上图食用更佳。
编码完成之后,数据就会被 NIO 框架所发出,通过网络到达服务端。
当服务端接收到数据的时候,因为接收到的都是字节序列,所以第一步应该是对其解码,这一步最终会交给 Codec
接口的 decode
方法处理。
解码的时候会先解析得到消息头,然后再根据消息头中的元信息,例如消息头长度,消息类型,将消息体反序列化为 DecodeableRpcInvocation
对象(也就是调用信息)。
此时的线程为 Netty 的 I/O 线程,不一定会在当前线程解码,所以有可能会得到部分解码的 Request 对象,具体分析见下文。
值得注意的是,在 2.6.x 版本中,默认情况下对于请求的解码会在 I/O 线程中执行,而 2.7.x 之后的版本则是交给业务线程执行。
这里的 I/O 线程指的是底层通信框架中接收请求的线程(其实就是 Netty 中的 Worker 线程),业务线程则是 Dubbo 内部用于处理请求/响应的线程池中的线程。如果某个事件可能比较耗时,不能在 I/O 线程上执行,那么就需要通过线程派发器将线程派发到线程池中去执行。
再次借用官网的一张图,当服务端接收到请求时,会根据不同的线程派发策略,将请求派发到线程池中执行。线程派发器 Dispatcher
本身并不具备线程派发的能力,它只是用于创建具有线程派发能力的 ChannelHandler
。
Dubbo 拥有 5 种线程派发策略,默认使用的策略为 all
,具体策略差别见下表。
策略 | 用途 |
---|---|
all | 所有消息都派发到线程池,包括请求,响应,连接事件,断开事件等 |
direct | 所有消息都不派发到线程池,全部在 IO 线程上直接执行 |
message | 只有请求和响应消息派发到线程池,其它消息均在 IO 线程上执行 |
execution | 只有请求消息派发到线程池,不含响应。其它消息均在 IO 线程上执行 |
Connection | 在 IO 线程上,将连接断开事件放入队列,有序逐个执行,其它消息派发到线程池 |
经过 DubboCodec
解码器处理过的数据会被 Netty 传递给下一个入站处理器,最终根据配置的线程派发策略来到对应的 ChannelHandler
,例如默认的 AllChannelHandler
。
可以看到,对于每种事件,AllChannelHandler
只是创建了一个 ChannelEventRunnable
对象并提交到业务线程池中去执行,这个 Runnable
对象其实只是一个中转站,它是为了避免在 I/O 线程中执行具体的操作,最终真正的操作它会委托给持有的 ChannelHandler
去处理。
服务端对请求进行派发的过程如下图所示。
上面说过,解码操作也有可能在业务线程中执行,因为 ChannelEventRunnable
中直接持有的 ChannelHandler
就是一个用于解码的 DecodeHandler
。
如果需要解码,那么这个通道处理器会调用在 I/O 线程中创建的 DecodeableRpcInvocation
对象的 decode
方法,从字节序列中反序列化得到本次调用的类名,方法名,参数信息等。
解码完成后,DecodeHandler
会将完全解码的 Request
对象继续传递到下一个通道处理器即 HeaderExchangeHandler
。
到这里其实已经可以体会到 Dubbo 抽取出 ChannelHandler
的好处了,可以避免和特定 NIO 库耦合,同时使用装饰者模式一层层地处理请求,最终对 NIO 库只暴露出一个特定的 Handler,更加灵活。
这里附上一张服务端 ChannelHandler
的结构图。
HeaderExchangeHandler
会根据本次请求的类型决定如何处理。如果是单向调用,那么只需向后调用即可,不需要返回响应。如果是双向调用,那么就需要在得到具体的调用结果后,封装成 Response
对象,并通过持有的 Channel
对象将本次调用的响应发送回客户端。
HeaderExchangeHandler
将调用委托给持有的 ExchangeHandler
处理器,这个处理器是和服务暴露时使用的协议有关的,一般来说都是某个协议的内部类。
由于默认情况下都是使用的 Dubbo 协议,所以接下来对 Dubbo 协议中的处理器进行分析。
Dubbo 协议内部的 ExchangeHandler
会从已经暴露的服务列表中找到本次调用的 Invoker
,并且向其发起本地调用。不过要注意的是,这里的 Invoker
是一个动态生成的代理对象,类型为 AbstractProxyInvoker
,它持有了处理业务的真实对象。
当发起 invoke
调用时,它会通过持有的真实对象完成调用,并将其封装到 RpcResult
对象中并且返回给下层。
关于
RpcResult
有兴趣的话可以了解一下 2.7.x 异步化改造后的变化。简单来说就是RpcResult
被AppResonse
所替代,用来保存调用结果或调用异常,同时引入了一个新的中间状态类AsyncRpcResult
用于代表未完成的 RPC 调用。
这个代理对象是在服务端进行服务暴露的时候生成的,javassist 会动态生成一个 Wrapper
类,并且创建一个匿名内部对象,将调用操作委托给 Wrapper
。
下面是反编译得到的 Wrapper
类,可以看到具体的处理逻辑和客户端的 InvocationHandler
类似,都是根据本次调用的方法名来向真实对象发起调用。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uEHGfojb-1638329374398)(https://typora-pics-1255993109.cos.ap-guangzhou.myqcloud.com/Wrapper%20(1)].png)
至此,服务端已完成了调用过程。下层 ChannelHandler
收到调用结果后,就会通过 Channel
将响应发送回客户端,期间又会经过编码序列化等操作,由于和请求的编码序列化过程类似,这里不再赘述,感兴趣的话可以自行查看 ExchangeCodec#encodeResponse()
以及 DubboCodec#encodeResponseData()
。
这里再附上一张服务端处理请求的时序图。
当客户端收到调用的响应后,毫无疑问依旧需要对收到的字节序列进行解码及反序列化,这里和服务端解码请求的过程是类似的,查看 ExchangeCodec#decode()
以及 DubboCodec#decodeBody()
自行了解,也可参考上面的服务端解码请求的时序图,这里只附上一张客户端处理已(部分)解码的响应的时序图。
这里主要讲的是客户端对解码后的 Reponse
对象的处理逻辑。客户端的 ChannelHandler
结构和上面的服务端 ChnnelHandler
结构图没有太大区别,经过解码后的响应最终也会传递到 HeaderExchangeHandler
处理器中进行处理。
在客户端发起请求时我们提到过,每个构造的请求都有一个 ID 标识,当对应的响应返回时,就会把这个 ID 带上。当接收到响应时, Dubbo 会从请求的 Future 映射集合中,根据返回的请求 ID,找到对应的 DefaultFuture
,并将结果设置到 DefaultFuture
中,同时唤醒阻塞的用户线程,这样就完成了 Dubbo 的业务线程到用户线程的转换。
有兴趣的话可以再了解一下 DefauFuture 的超时处理 以及 Dubbo 2.7 异步化改造后的线程模型变化。
最后附上一张来源官网的图。
至此,一个完整的 RPC 调用就结束了。
由于本人水平有限,可能部分细节并没有讲清楚 ,如果有疑问的话欢迎大家指出,一起交流学习。
Dubbo 官网 - 服务调用过程
《深入理解 Apache Dubbo 与实战》