RPC原理及实现 RPC原理及实现

转『RPC原理及实现html

1 简介

RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者没必要显式的区分本地调用和远程调用java

2 调用分类

RPC 调用分如下两种:web

  • 同步调用typescript

    客户方等待调用执行完成并返回结果。json

  • 异步调用markdown

    客户方调用后不用等待执行结果返回,但依然能够经过回调通知等方式获取返回结果。 若客户方不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。网络

异步和同步的区分在因而否等待服务端执行完成并返回结果数据结构

3 结构拆解

输入图片说明

RPC 服务方经过 RpcServer 去导出(export)远程接口方法,而客户方经过 RpcClient 去引入(import)远程接口方法。 客户方像调用本地方法同样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理 RpcProxy 。 代理封装调用信息并将调用转交给 RpcInvoker 去实际执行。 在客户端的 RpcInvoker 经过链接器 RpcConnector 去维持与服务端的通道 RpcChannel, 并使用 RpcProtocol 执行协议编码(encode)并将编码后的请求消息经过通道发送给服务方。并发

RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求,一样使用 RpcProtocol 执行协议解码(decode)。 解码后的调用信息传递给 RpcProcessor 去控制处理调用过程,最后再委托调用给 RpcInvoker 去实际执行并返回调用结果负载均衡

4 组件职责

上面咱们进一步拆解了 RPC 实现结构的各个组件组成部分,下面咱们详细说明下每一个组件的职责划分。

  1. RpcServer

    负责导出(export)远程接口

  2. RpcClient

    负责导入(import)远程接口的代理实现

  3. RpcProxy

    远程接口的代理实现

  4. RpcInvoker

    客户方实现:负责编码调用信息和发送调用请求到服务方并等待调用结果返回

    服务方实现:负责调用服务端接口的具体实现并返回调用结果

  5. RpcProtocol

    负责协议编/解码

  6. RpcConnector

    负责维持客户方和服务方的链接通道和发送数据到服务方

  7. RpcAcceptor

    负责接收客户方请求并返回请求结果

  8. RpcProcessor

    负责在服务方控制调用过程,包括管理调用线程池、超时时间等

  9. RpcChannel

    数据传输通道

5 实现分析

在进一步拆解了组件并划分了职责以后,这里以在 java 平台实现该 RPC 框架概念模型为例,详细分析下实现中须要考虑的因素。

5.1 导出远程接口

导出远程接口的意思是指只有导出的接口能够供远程调用,而未导出的接口则不能。 在 java 中导出接口的代码片断可能以下:

DemoService demo   = new ...; RpcServer server = new ...; server.export(DemoService.class, demo, options); 

咱们能够导出整个接口,也能够更细粒度一点只导出接口中的某些方法,如:

// 只导出 DemoService 中签名为 hi(String s) 的方法 server.export(DemoService.class, demo, "hi", new Class<?>[] { String.class }, options); 

java 中还有一种比较特殊的调用就是多态,也就是一个接口可能有多个实现,那么远程调用时到底调用哪一个? 这个本地调用的语义是经过 jvm 提供的引用多态性隐式实现的,那么对于 RPC 来讲跨进程的调用就无法隐式实现了。 若是前面 DemoService 接口有 2 个实现,那么在导出接口时就须要特殊标记不一样的实现,如:

DemoService demo   = new ...; DemoService demo2 = new ...; RpcServer server = new ...; server.export(DemoService.class, demo, options); server.export("demo2", DemoService.class, demo2, options); 

上面 demo2 是另外一个实现,咱们标记为 demo2 来导出, 那么远程调用时也须要传递该标记才能调用到正确的实现类,这样就解决了多态调用的语义

5.2 导入远程接口与客户端代理

导入相对于导出远程接口,客户端代码为了可以发起调用必需要得到远程接口的方法或过程定义。目前,大部分跨语言平台 RPC 框架采用根据 IDL 定义经过 code generator 去生成 stub 代码,这种方式下实际导入的过程就是经过代码生成器在编译期完成的。 我所使用过的一些跨语言平台 RPC 框架如 CORBAR、WebService、ICE、Thrift 均是此类方式。

代码生成的方式对跨语言平台 RPC 框架而言是必然的选择,而对于同一语言平台的 RPC 则能够经过共享接口定义来实现。 在 java 中导入接口的代码片断可能以下:

RpcClient client = new ...; DemoService demo = client.refer(DemoService.class); demo.hi("how are you?"); 

在 java 中 import 是关键字,因此代码片断中咱们用 refer 来表达导入接口的意思。 这里的导入方式本质也是一种代码生成技术,只不过是在运行时生成,比静态编译期的代码生成看起来更简洁些。 java 里至少提供了两种技术来提供动态代码生成,一种是 jdk 动态代理,另一种是字节码生成。 动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差不少。 二者权衡起来,我的认为牺牲一些性能来得到代码可读性和可维护性显得更重要。

5.3 协议编解码

客户端代理在发起调用前须要对调用信息进行编码,这就要考虑须要编码些什么信息并以什么格式传输到服务端才能让服务端完成调用。 出于效率考虑,编码的信息越少越好(传输数据少),编码的规则越简单越好(执行效率高)。 咱们先看下须要编码些什么信息:

  1. 调用编码

    接口方法:包括接口名、方法名

    方法参数:包括参数类型、参数值

    调用属性:包括调用属性信息,例如调用附件隐式参数、调用超时时间等

  2. 返回编码

    返回结果:接口方法中定义的返回值

    返回码:异常返回码

    返回异常信息:调用异常信息

除了以上这些必须的调用信息,咱们可能还须要一些元信息以方便程序编解码以及将来可能的扩展。 这样咱们的编码消息里面就分红了两部分,一部分是元信息、另外一部分是调用的必要信息。 若是设计一种 RPC 协议消息的话,元信息咱们把它放在协议消息头中,而必要信息放在协议消息体中。 下面给出一种概念上的 RPC 协议消息设计格式:

  1. 消息头

    输入图片说明

    magic : 协议魔数,为解码设计

    header size: 协议头长度,为扩展设计

    version : 协议版本,为兼容设计

    st : 消息体序列化类型

    hb : 心跳消息标记,为长链接传输层心跳设计

    ow : 单向消息标记,

    rp : 响应消息标记,不置位默认是请求消息

    status code: 响应消息状态码

    reserved : 为字节对齐保留

    message id : 消息 id

    body size : 消息体长度

  2. 消息体

    采用序列化编码,常见有如下格式

    xml : 如 webservie SOAP

    json : 如 JSON-RPC

    binary: 如 thrift; hession; kryo 等

    格式肯定后编解码就简单了,因为头长度必定因此咱们比较关心的就是消息体的序列化方式。 序列化咱们关心三个方面:

    序列化和反序列化的效率,越快越好。

    序列化后的字节长度,越小越好。

    序列化和反序列化的兼容性,接口参数对象若增长了字段,是否兼容。

5.4 传输服务

协议编码以后,天然就是须要将编码后的 RPC 请求消息传输到服务方,服务方执行后返回结果消息或确认消息给客户方。 RPC 的应用场景实质是一种可靠的请求应答消息流,和 HTTP 相似。 所以选择长链接方式的 TCP 协议会更高效,与 HTTP 不一样的是在协议层面咱们定义了每一个消息的惟一 id,所以能够更容易的复用链接

既然使用长链接,那么第一个问题是到底 client 和 server 之间须要多少根链接? 实际上单链接和多链接在使用上没有区别,对于数据传输量较小的应用类型,单链接基本足够。 单链接和多链接最大的区别在于,每根链接都有本身私有的发送和接收缓冲区, 所以大数据量传输时分散在不一样的链接缓冲区会获得更好的吞吐效率。 因此,若是你的数据传输量不足以让单链接的缓冲区一直处于饱和状态的话,那么使用多链接并不会产生任何明显的提高,反而会增长链接管理的开销。

链接是由 client 端发起创建并维持。 若是 client 和 server 之间是直连的,那么链接通常不会中断(固然物理链路故障除外)。 若是 client 和 server 链接通过一些负载中转设备,有可能链接一段时间不活跃时会被这些中间设备中断。 为了保持链接有必要定时为每一个链接发送心跳数据以维持链接不中断。 心跳消息是 RPC 框架库使用的内部消息,在前文协议头结构中也有一个专门的心跳位, 就是用来标记心跳消息的,它对业务应用透明。

5.5 执行调用

client stub 所作的事情仅仅是编码消息并传输给服务方,而真正调用过程发生在服务方。 server stub 从前文的结构拆解中咱们细分了 RpcProcessor 和 RpcInvoker 两个组件, 一个负责控制调用过程,一个负责真正调用。这里咱们仍是以 java 中实现这两个组件为例来分析下它们到底须要作什么?

java 中实现代码的动态接口调用目前通常经过反射调用。 除了原生的 jdk 自带的反射,一些第三方库也提供了性能更优的反射调用, 所以 RpcInvoker 就是封装了反射调用的实现细节

调用过程的控制须要考虑哪些因素,RpcProcessor 须要提供什么样地调用控制服务呢? 下面提出几点以启发思考:

  1. 效率提高

    每一个请求应该尽快被执行,所以咱们不能每请求来再建立线程去执行,须要提供线程池服务。

  2. 资源隔离

    当咱们导出多个远程接口时,如何避免单一接口调用占据全部线程资源,而引起其余接口执行阻塞。

  3. 超时控制

    当某个接口执行缓慢,而 client 端已经超时放弃等待后,server 端的线程继续执行此时显得毫无心义。

5.6 异常处理

不管 RPC 怎样努力把远程调用假装的像本地调用,但它们依然有很大的不一样点,并且有一些异常状况是在本地调用时绝对不会碰到的。在说异常处理以前,咱们先比较下本地调用和 RPC调用的一些差别:

  1. 本地调用必定会执行,而远程调用则不必定,调用消息可能由于网络缘由并未发送到服务方。

  2. 本地调用只会抛出接口声明的异常,而远程调用还会抛出 RPC 框架运行时的其余异常。

  3. 本地调用和远程调用的性能可能差距很大,这取决于 RPC 固有消耗所占的比重。

正是这些区别决定了使用 RPC 时须要更多考量。 当调用远程接口抛出异常时,异常多是一个业务异常, 也多是 RPC 框架抛出的运行时异常(如:网络中断等)。 业务异常代表服务方已经执行了调用,可能由于某些缘由致使未能正常执行, 而 RPC 运行时异常则有可能服务方根本没有执行,对调用方而言的异常处理策略天然须要区分。

因为 RPC 固有的消耗相对本地调用高出几个数量级,本地调用的固有消耗是纳秒级,而 RPC 的固有消耗是在毫秒级。 那么对于过于轻量的计算任务就并不合适导出远程接口由独立的进程提供服务, 只有花在计算任务上时间远远高于 RPC 的固有消耗才值得导出为远程接口提供服务

6 如何调用他人的远程服务

因为各服务部署在不一样机器,服务间的调用免不了网络通讯过程,服务消费方每调用一个服务都要写一坨网络通讯相关的代码,不只复杂并且极易出错。

若是有一种方式能让咱们像调用本地服务同样调用远程服务,而让调用者对网络通讯这些细节透明,那么将大大提升生产力,好比服务消费方在执行helloWorldService.sayHello(“test”)时,实质上调用的是远端的服务。这种方式其实就是RPC(Remote Procedure Call Protocol),在各大互联网公司中被普遍使用,如阿里巴巴的hsf、dubbo(开源)、Facebook的thrift(开源)、Google grpc(开源)、Twitter的finagle等。

要让网络通讯细节对使用者透明,咱们天然须要对通讯细节进行封装,咱们先看下一个RPC调用的流程:

输入图片说明

1)服务消费方(client)调用以本地调用方式调用服务;

2)client stub接收到调用后负责将方法、参数等组装成可以进行网络传输的消息体;

3)client stub找到服务地址,并将消息发送到服务端;

4)server stub收到消息后进行解码;

5)server stub根据解码结果调用本地的服务;

6)本地服务执行并将结果返回给server stub;

7)server stub将返回结果打包成消息并发送至消费方;

8)client stub接收到消息,并进行解码;

9)服务消费方获得最终结果。

RPC的目标就是要2~8这些步骤都封装起来,让用户对这些细节透明。

6.1 怎么作到透明化远程服务调用

怎么封装通讯细节才能让用户像以本地调用方式调用远程服务呢?对java来讲就是使用代理!java代理有两种方式:1) jdk 动态代理;2)字节码生成。尽管字节码生成方式实现的代理更为强大和高效,但代码不易维护,大部分公司实现RPC框架时仍是选择动态代理方式。

下面简单介绍下动态代理怎么实现咱们的需求。咱们须要实现RPCProxyClient代理类,代理类的invoke方法中封装了与远端服务通讯的细节,消费方首先从RPCProxyClient得到服务提供方的接口,当执行helloWorldService.sayHello(“test”)方法时就会调用invoke方法。

public class RPCProxyClient implements java.lang.reflect.InvocationHandler{ private Object obj; public RPCProxyClient(Object obj){ this.obj=obj; } /**  * 获得被代理对象;  */ public static Object getProxy(Object obj){ return java.lang.reflect.Proxy.newProxyInstance(obj.getClass().getClassLoader(), obj.getClass().getInterfaces(), new RPCProxyClient(obj)); } /**  * 调用此方法执行  */ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { //结果参数; Object result = new Object(); // ...执行通讯相关逻辑 // ... return result; } } public class Test { public static void main(String[] args) { HelloWorldService helloWorldService = (HelloWorldService)RPCProxyClient.getProxy(HelloWorldService.class); helloWorldService.sayHello("test"); } } 

6.2 怎么对消息进行编码和解码

  1. 肯定消息数据结构

    上节讲了invoke里须要封装通讯细节,而通讯的第一步就是要肯定客户端和服务端相互通讯的消息结构。客户端的请求消息结构通常须要包括如下内容:

    1)接口名称

    在咱们的例子里接口名是“HelloWorldService”,若是不传,服务端就不知道调用哪一个接口了;

    2)方法名

    一个接口内可能有不少方法,若是不传方法名服务端也就不知道调用哪一个方法;

    3)参数类型&参数值

    参数类型有不少,好比有bool、int、long、double、string、map、list,甚至如struct(class);以及相应的参数值;

    4)超时时间

    5)requestID

    标识惟一请求id,在下面一节会详细描述requestID的用处。

    同理,服务端返回的消息结构通常包括如下内容。

    1)返回值

    2)状态code

    3)requestID

  2. 序列化

    一旦肯定了消息的数据结构后,下一步就是要考虑序列化与反序列化了。

    什么是序列化?序列化就是将数据结构或对象转换成二进制串的过程,也就是编码的过程。

    什么是反序列化?将在序列化过程当中所生成的二进制串转换成数据结构或者对象的过程。

    为何须要序列化?转换为二进制串后才好进行网络传输嘛!为何须要反序列化?将二进制转换为对象才好进行后续处理!

    现现在序列化的方案愈来愈多,每种序列化方案都有优势和缺点,它们在设计之初有本身独特的应用场景,那到底选择哪一种呢?从RPC的角度上看,主要看三点:1)通用性,好比是否能支持Map等复杂的数据结构;2)性能,包括时间复杂度和空间复杂度,因为RPC框架将会被公司几乎全部服务使用,若是序列化上能节约一点时间,对整个公司的收益都将很是可观,同理若是序列化上能节约一点内存,网络带宽也能省下很多;3)可扩展性,对互联网公司而言,业务变化快,若是序列化协议具备良好的可扩展性,支持自动增长新的业务字段,删除老的字段,而不影响老的服务,这将大大提供系统的健壮性。

    目前国内各大互联网公司普遍使用hessian、protobuf、thrift、avro等成熟的序列化解决方案来搭建RPC框架,这些都是久经考验的解决方案。

6.3 通讯

消息数据结构被序列化为二进制串后,下一步就要进行网络通讯了。目前有两种IO通讯模型:1)BIO;2)NIO。通常RPC框架须要支持这两种IO模型。

如何实现RPC的IO通讯框架?1)使用java nio方式自研,这种方式较为复杂,并且颇有可能出现隐藏bug,见过一些互联网公司使用这种方式;2)基于mina,mina在早几年比较火热,不过这些年版本更新缓慢;3)基于netty,如今不少RPC框架都直接基于netty这一IO通讯框架,好比阿里巴巴的HSF、dubbo,Twitter的finagle等。

6.4 消息里为何要带有requestID

若是使用netty的话,通常会用channel.writeAndFlush()方法来发送消息二进制串,这个方法调用后对于整个远程调用(从发出请求到接收到结果)来讲是一个异步的,即对于当前线程来讲,将请求发送出来后,线程就能够日后执行了,至于服务端的结果,是服务端处理完成后,再以消息的形式发送给客户端的。因而这里出现如下两个问题:

1)怎么让当前线程“暂停”,等结果回来后,再向后执行?

2)若是有多个线程同时进行远程方法调用,这时创建在client server之间的socket链接上会有不少双方发送的消息传递,先后顺序也多是随机的,server处理完结果后,将结果消息发送给client,client收到不少消息,怎么知道哪一个消息结果是原先哪一个线程调用的?

以下图所示,线程A和线程B同时向client socket发送请求requestA和requestB,socket前后将requestB和requestA发送至server,而server可能将responseA先返回,尽管requestA请求到达时间更晚。咱们须要一种机制保证responseA丢给ThreadA,responseB丢给ThreadB

输入图片说明

怎么解决呢?

1)client线程每次经过socket调用一次远程接口前,生成一个惟一的ID,即requestID(requestID必需保证在一个Socket链接里面是惟一的),通常经常使用AtomicLong从0开始累计数字生成惟一ID

2)将处理结果的回调对象callback,存放到全局ConcurrentHashMap里面put(requestID, callback)

3)当线程调用channel.writeAndFlush()发送消息后,紧接着执行callback的get()方法试图获取远程返回的结果。在get()内部,则使用synchronized获取回调对象callback的锁,再先检测是否已经获取到结果,若是没有,而后调用callback的wait()方法,释放callback上的锁,让当前线程处于等待状态

4)服务端接收到请求并处理后,将response结果(此结果中包含了前面的requestID)发送给客户端,客户端socket链接上专门监听消息的线程收到消息,分析结果,取到requestID,再从前面的ConcurrentHashMap里面get(requestID),从而找到callback对象,再用synchronized获取callback上的锁,将方法调用结果设置到callback对象里,再调用callback.notifyAll()唤醒前面处于等待状态的线程

public Object get() { synchronized (this) { // 旋锁 while (!isDone) { // 是否有结果了 wait(); //没结果是释放锁,让当前线程处于等待状态 } } } private void setDone(Response res) { this.res = res; isDone = true; synchronized (this) { //获取锁,由于前面wait()已经释放了callback的锁了 notifyAll(); // 唤醒处于等待的线程 } } 

7 如何发布本身的服务

如何让别人使用咱们的服务呢?有同窗说很简单嘛,告诉使用者服务的IP以及端口就能够了啊。确实是这样,这里问题的关键在因而自动告知仍是人肉告知。

人肉告知的方式:若是你发现你的服务一台机器不够,要再添加一台,这个时候就要告诉调用者我如今有两个ip了,大家要轮询调用来实现负载均衡;调用者咬咬牙改了,结果某天一台机器挂了,调用者发现服务有一半不可用,他又只能手动修改代码来删除挂掉那台机器的ip。现实生产环境固然不会使用人肉方式。

有没有一种方法能实现自动告知,即机器的增添、剔除对调用方透明,调用者再也不须要写死服务提供方地址?固然能够,现现在zookeeper被普遍用于实现服务自动注册与发现功能!

简单来说,zookeeper能够充当一个服务注册表(Service Registry),让多个服务提供者造成一个集群,让服务消费者经过服务注册表获取具体的服务访问地址(ip+端口)去访问具体的服务提供者。以下图所示:

输入图片说明

具体来讲,zookeeper就是个分布式文件系统,每当一个服务提供者部署后都要将本身的服务注册到zookeeper的某一路径上: /{service}/{version}/{ip:port}, 好比咱们的HelloWorldService部署到两台机器,那么zookeeper上就会建立两条目录:分别为/HelloWorldService/1.0.0/100.19.20.01:16888 /HelloWorldService/1.0.0/100.19.20.02:16888。

zookeeper提供了“心跳检测”功能,它会定时向各个服务提供者发送一个请求(实际上创建的是一个 socket 长链接),若是长期没有响应,服务中心就认为该服务提供者已经“挂了”,并将其剔除,好比100.19.20.02这台机器若是宕机了,那么zookeeper上的路径就会只剩/HelloWorldService/1.0.0/100.19.20.01:16888。

服务消费者会去监听相应路径(/HelloWorldService/1.0.0),一旦路径上的数据有任务变化(增长或减小),zookeeper都会通知服务消费方服务提供者地址列表已经发生改变,从而进行更新

更为重要的是zookeeper 与生俱来的容错容灾能力(好比leader选举),能够确保服务注册表的高可用性。

相关文章
相关标签/搜索