当前位置: 首页 > 后端技术 > Java

Dubbo服务调用流程

时间:2023-04-01 20:10:16 Java

本文已同步到我的公众号Code4j,欢迎和我一起玩。一、什么是远程过程调用在描述Dubbo的服务调用流程之前,我们先来了解一下什么是远程过程调用。RemoteProcedureCall,即RemoteProduceCall,简单来说就是跨进程调用,通过网络传输,让A机器上的应用程序可以像调用本地服务一样调用B机器上的服务。举个最简单的栗子,假设现在有一个电商系统,里面有用户服务、优惠券服务、订单服务等服务模块,这些不同的服务并不运行在同一个JVM中,而是分别运行在不同的in中虚拟机。因此,当订单服务要调用优惠券服务时,不能像之前的单体应用那样直接在本地发起对相应服务的调用,而只能通过网络发起调用。那么,最简??单的远程过程调用是什么?看看下面的图片。也就是说,最简单的RPC调用无非就是调用者通过网络将调用的参数传递给服务端。服务器端收到调用请求后,根据参数完成本地调用,并将结果通过网络返回给调用方。.在这个过程中,参数封装、网络传输等细节将由RPC框架来完成。对上图进行完善,一个完整的RPC调用流程如下:客户端(Client)在本地调用RemoteService。客户端代理对象(ClientStub)将本次请求的相关信息(要调用的类名、方法名、方法参数等)封装成一个Request,并序列化,为网络通信做准备。客户端代理对象(ClientStub)找到服务器(Server)的地址,通过网络(Socket通信)向服务器发送Request。服务器代理对象(ServerStub)收到客户端(Client)的请求后,将二进制数据反序列化为Request。服务器代理对象(ServerStub)根据调用信息发起对本地方法的调用。服务器端代理对象(ServerStub)将调用结果封装成Response,序列化,通过网络发送给客户端。客户端代理对象(ClientStub)收到响应后,反序列化为Response,远程调用结束。2.Dubbo的远程调用流程本节内容基于Dubbo2.6.x版本,使用官网提供的Demo来分析同步调用。在上一节中,我们已经对服务调用的流程有了一定的了解。其实Dubbo在实现远程调用的时候,核心流程和上图是一模一样的,只是Dubbo在这个基础上增加了一些额外的流程,比如集群容错、负载均衡、过滤器链等,本文只分析核心调用流程,其他附加流程自行理解。在讲解Dubbo的调用流程之前,我们先了解一下Dubbo的一些概念。Invoker:作为Dubbo中的一个实体域,代表了要操作的对象模型。这有点像Spring中的bean,所有的操作都是围绕这个实体域进行的。表示可以对其进行调用的可执行文件。可能是本地实现,可能是远程实现,也可能是集群实现。Invocation:作为Dubbo中的一个session域,代表了每一个操作的瞬时状态,操作前创建,操作后销毁。其实就是调用信息,存储了被调用的类名、方法名、参数等信息。Protocol:作为Dubbo中的服务域,负责实体域和会话域的生命周期管理。可以理解为Spring中的BeanFactory,是产品的入口。2.1远程调用开始——动态代理了解了以上基本概念后,我们开始跟踪Dubbo的远程调用过程。在RPC框架中,如果要实现远程调用,代理对象是必不可少的,因为它可以帮助我们屏蔽很多底层细节,让我们对远程调用毫无察觉。如果你用过JDK的动态代理或者CGLIB的动态代理,你应该知道每一个代理对象都会有一个对应的处理器用于动态代理处理的增强,比如JDK使用的InvacationHandler或者CGLIB的MethodInterceptor。在Dubbo中默认使用javasisst实现动态代理,与JDK动态一样使用InvocationHandler增强代理。下面是分别使用javasisst和JDK动态代理时反编译代理类的结果。从上面可以看出,InvacationHandler要做的就是将本次调用的方法名和方法参数封装到调用信息Invacation中,然后传递给持有的Invoker对象。从这里开始,也算是真正进入了Dubbo的核心模型。2.2客户端调用链路在了解客户端调用链路之前,我们需要看一下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需要在此处调用一定的阻塞操作,阻塞用户线程,直到本次调用的结果返回。2.3远程调用的基石——网络层在上一节的DubboInvoker中,我们可以看到远程调用请求是通过一个ExchangeClient类发送的,该类在Dubbo框架层的远程通信模块中进行Exchange信息交互.从前面的架构图可以看出,远程通信模块分为三层,从上到下分别是Exchange信息交换层、Transport网络传输层和Serialize序列化层,每一层都有其特定的作用。从最底层的Serialize层开始,这一层负责序列化/反序列化,抽象出各种序列化方式,如JDK序列化、Hessian序列化、JSON序列化等,往上是Transport层。这一层负责单向消息传输,强调Message的语义,不体现交互的概念。同时这一层也抽象出各种NIO框架,如Netty、Mina等。再往上就是Exhange层,与Transport层不同。这一层负责request/response交互,强调Request和Response的语义,正是因为有request-response的存在,才有Client和Server的区分。了解了远程通信模块的层次结构之后,我们再来看看这个模块中的核心概念。Dubbo在该模块中提取了端点的概念,通过一个IP和一个端口,可以唯一确定一个端点。在这两个端点之间,我们可以建立一个TCP连接,这个连接被Dubbo抽象成一个通道Channel,通道处理器ChannelHandler负责处理通道,比如处理通道的连接建立事件和连接断开事件,以及处理读取数据、发送数据、捕获异常等。同时,为了在语义上区分端点,Dubbo将发起请求的端点抽象为client客户端,将发送响应的端点抽象为客户端作为服务器服务器。由于不同的NIO框架有不同的对外接口和使用方式,为了避免上层接口直接依赖具体的NIO库,Dubbo在Client和Server之上抽象了一个Transporter接口,用于获取Client和Server。如果需要更换使用的NIO库,只需要更换相关的实现类即可。Dubbo将负责数据编解码功能的处理器抽象成一个Codec接口。有兴趣的可以自己去了解一下。Endpoint的主要功能是发送数据,所以Dubbo为其定义了send()方法;同时让Channel继承Endpoint,使其具有在发送数据的基础上增加K/V属性的功能。对于client来说,一个Cleint只关联一个Channel,可以直接继承Channel从而也有发送数据的功能,而Server可以接受多个Cleint建立的Channel连接,所以Dubbo不允许继承频道。而是选择直接继承Endpoint,并提供getChannels()方法获取关联连接。为了体现request/response的交互方式,在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和创建的DefaultFuture进行映射保存,同时设置超时时间。保存映射的目的是因为在异步情况下,请求和响应之间不存在一一对应关系。为了让后面收到的响应能够被正确处理,Dubbo会在响应中带上对应的请求ID。收到响应后,可以根据其中的请求ID找到对应的DefaultFuture,并将响应结果设置为DefaultFuture。使得阻塞在get()操作中的用户线程能够及时返回。整个过程可以抽象为如下时序图。当ExchangeChannel调用send()时,数据会通过底层的NIO框架发送出去,但是在数据通过网络传输之前,还有最后一步需要做,就是序列化和编码。注意,在调用send()方法之前,所有的逻辑都交由用户线程处理,编码工作交由Netty的I/O线程处理。有兴趣的可以去了解一下Netty的线程模型。2.4协议和编码上面已经多次提到了协议(Protocol)和编码,那么什么是协议,什么是编码呢?实际上,一般来说,协议就是一套约定好的通信规则。比如张三和李四要交流,就需要先约定好交流方式,然后再交流。例如,双方约定,当听到“HelloWorld”时,就意味着对方要开始说话了。此时,张三和李四之间的这份约定,就是他们的沟通约定。至于编码,其实就是把数据按照约定的协议组装成协议规定的格式。当张三要对李四说“早上好”时,那么张三只需要在“早上好”之前加上约定好的“HelloWorld”即可,即最后的消息是“HelloWorld,早上好”。李斯一听“世界你好”就知道接下来的内容就是张三要说的。通过这个表格,张三和李四之间就可以完成正常的交流了。具体到实际的RPC通信,所谓的Dubbo协议、RMI协议、HTTP协议等,它们只是对应的通信规则不同而已,但最终的作用是一样的,都是提供一套通信数据组装规则,就这样。这里借用官网的一张图来展示默认的Dubbo协议包格式。Dubbo数据包分为消息头和消息体。消息头是定长格式,一共16个字节,用来存放一些元信息,比如消息初始标识的MagicNumber,数据包的类型,序列化的ID使用的方法,以及消息正文的长度。消息体采用变长格式,具体长度存储在消息头中。这部分用于存放具体的调用信息或调用结果,即Invocation序列化后的字节序列或远程调用返回的对象的字节数。Sequence,消息体这部分数据经过序列化/反序列化处理。前面提到过,Dubbo将用于数据编解码的通道处理器抽象到Codec接口中,所以在消息发出前,Dubbo会调用该接口的encode()方法进行编码。其中消息体,即本次调用的调用信息Invacation,会通过Serialization接口进行序列化。Dubbo在启动client和server的时候,会通过adapter的方式将Codec相关的codec适配到Netty,并添加到Nettypipeline中,参见NettyCodecAdapter、NettyClient和NettyServer。下面是相关的编码逻辑,最好和上图对比一下。编码完成后,数据由NIO框架发送,通过网络到达服务器。2.5服务端的调用环节当服务端接收到数据时,因为接收到的都是字节序列,所以第一步应该是对其进行解码,而这一步最终会交给Codec接口的decode方法进行处理。解码时会先解析消息头,然后根据消息头中的元信息,如消息头长度、消息类型等,将消息体反序列化为一个DecodeableRpcInvocation对象(即调用信息).此时的线程是Netty的I/O线程,在当前线程中可能还没有解码,所以有可能得到一个部分解码的Request对象。具体分析见下文。值得注意的是,在2.6.x版本中,请求的解码默认会在I/O线程中执行,而2.7.x之后的版本则交给业务线程执行。这里的I/O线程是指底层通信框架中接收请求的线程(其实就是Netty中的Worker线程),业务线程是线程池中用来处理里面请求/响应的线程多宝。如果某个事件可能比较耗时,无法在I/O线程上执行,那么线程调度器就需要将线程调度到线程池中执行。再借用官网的一张图,当服务端收到请求后,会根据不同的线程调度策略将请求调度到线程池中执行。线程调度器Dispatcher本身不具备调度线程的能力,它只是用来创建一个具有线程调度能力的ChannelHandler。Dubbo有5种线程调度策略,默认策略是all。具体策略差异如下表所示。策略目的所有的消息都派发到线程池,包括请求、响应、连接事件、断开事件等。direct所有的消息都不派发到线程池,所有的消息直接在IO线程上执行。只有request和response消息被派发到线程池,其他消息都在IO线程上执行,只有request消息被派发到线程池,response不包括在内。其他消息在IO线程上执行。IO线程上的Connection将连接断开事件放入队列中,有序的一个一个执行。其他消息派发到线程池,DubboCodec解码器处理后的数据,再由Netty传给下一个输入。StationProcessor,根据配置的线程调度策略最终到达对应的ChannelHandler,比如默认的AllChannelHandler。可以看出,对于每一个事件,AllChannelHandler只是创建一个ChannelEventRunnable对象,提交给业务线程池执行。这个Runnable对象实际上只是一个中转站,用来避免I/O线程中的特定操作。最终将真正的操作委托给持有的ChannelHandler进行处理。服务端派发请求的流程如下图所示。上面说了解码操作也可以在业务线程中进行,因为ChannelEventRunnable中直接持有的ChannelHandler就是一个解码的DecodeHandler。如果需要解码,通道处理器会调用在I/O线程中创建的DecodeableRpcInvocation对象的decode方法,从字节序列中反序列化得到本次调用的类名、方法名、参数信息等。解码完成后,DecodeHandler会将完全解码后的Request对象传递给下一个通道处理程序HeaderExchangeHandler。至此,其实可以体会到Dubbo抽取ChannelHandler的好处,可以避免与具体的NIO库耦合,使用装饰器模式逐层处理请求。最后,只有特定的Handler暴露给NIO库,更加灵活。这里附上服务端ChannelHandler的结构图。HeaderExchangeHandler会根据这个请求的类型来决定如何处理。如果是单向调用,那么只需要向后调用,不需要返回响应。如果是双向调用,那么在得到具体的调用结果后,需要封装成一个Response对象,通过持有的Channel对象将本次调用的response返回给客户端。HeaderExchangeHandler将调用委托给持有的ExchangeHandler处理器,这与服务暴露时使用的协议有关,一般是某个协议的内部类。由于默认使用的是Dubbo协议,接下来分析Dubbo协议中的处理器。Dubbo协议内部的ExchangeHandler会从暴露的服务列表中找到本次调用的Invoker,并向其发起本地调用。不过需要注意的是,这里的Invoker是一个动态生成的AbstractProxyInvoker类型的代理对象,它持有的是处理业务的真实对象。当发起invoke调用时,会通过持有的真实对象完成调用,封装成RpcResult对象返回给下层。对RpcResult感兴趣的可以了解一下2.7.x异步改造后的变化。简单的说,RpcResult被AppResonse代替,用来保存调用结果或者调用异常,引入一个新的中间状态类AsyncRpcResult来表示未完成的RPC调用。这个代理对象是在服务端暴露服务时产生的。javassist会动态生成一个Wrapper类,并创建一个匿名内部对象,将调用操作委托给Wrapper。下面是反编译后的Wrapper类。可以看到具体的处理逻辑和客户端的InvocationHandler类似,根据本次调用的方法名对真实对象进行调用。.png)至此,服务端完成调用过程。下层的ChannelHandler收到调用结果后,会将response通过Channel返回给客户端,期间会进行编码和序列化操作。由于与请求的编码和序列化过程类似,这里不再赘述。有兴趣的可以自己查看ExchangeCodec#encodeResponse()和DubboCodec#encodeResponseData()。这是服务器处理请求的顺序图。2.6客户端处理响应当客户端收到调用的响应时,毫无疑问,它仍然需要对接收到的字节序列进行解码和反序列化。这类似于在服务器端解码请求的过程。参见ExchangeCodec#decode()和DubboCodec#decodeBody()自行理解,或者参考上面服务端解码请求的时序图,这里只是客户端处理(部分)解码响应的时序图。这里主要说一下客户端对解码后的Response对象的处理逻辑。客户端的ChannelHandler结构和上面服务端的ChnnelHandler结构图没有太大区别,解码后的响应最终会传递给HeaderExchangeHandler处理器进行处理。我们在客户端发起请求的时候提到过,每一个构造的请求都有一个ID,当返回相应的响应时,会携带这个ID。当Dubbo收到响应后,会根据返回的请求ID,从请求的Future映射集中找到对应的DefaultFuture,并将结果设置到DefaultFuture,同时唤醒阻塞的用户线程,从而完成Dubbo的业务线程Transition到用户线程。有兴趣的可以详细了解DefauFuture的超时处理以及Dubbo2.7异步改造后线程模型的变化。最后附上一张官网图片。至此,一个完整的RPC调用就结束了。由于本人水平有限,有些细节可能没有解释清楚。有什么问题欢迎指出,一起交流学习。3.参考链接Dubbo官网-服务调用流程《深入理解 Apache Dubbo 与实战》