RPC 的概念模型与实现解析

今天分布式应用、云计算、微服务大行其道,做为其技术基石之一的 RPC 你了解多少?一篇 RPC 的技术总结文章,数了下 5k+ 字,略长,可能也不适合休闲的碎片化时间阅读,能够先收藏抽空再细读:)git

全文目录以下:程序员

  • 定义github

  • 起源web

  • 目标算法

  • 分类json

  • 结构微信

    • 模型网络

    • 拆解架构

    • 组件框架

  • 实现

    • 导出

    • 导入

    • 协议

      • 编解码

      • 消息头

      • 消息体

    • 传输

    • 执行

    • 异常

  • 总结

  • 参考


两年前写过两篇关于 RPC 的文章,现在回顾发现结构和逻辑略显凌乱,特做整理从新整合成一篇,想了解 RPC 原理的同窗能够看看。

近几年的项目中,服务化和微服务化渐渐成为中大型分布式系统架构的主流方式,而 RPC 在其中扮演着关键的做用。 在平时的平常开发中咱们都在隐式或显式的使用 RPC,一些刚入行的程序员会感受 RPC 比较神秘,而一些有多年使用 RPC 经验的程序员虽然使用经验丰富,但有些对其原理也不甚了了。 缺少对原理层面的理解,每每也会形成开发中的一些误用。

定义

RPC 的全称是 Remote Procedure Call 是一种进程间通讯方式。 它容许程序调用另外一个地址空间(一般是共享网络的另外一台机器上)的过程或函数,而不用程序员显式编码这个远程调用的细节。即程序员不管是调用本地的仍是远程的函数,本质上编写的调用代码基本相同。

起源

RPC 这个概念术语在上世纪 80 年代由 Bruce Jay Nelson(参考[1])提出。 这里咱们追溯下当初开发 RPC 的原动机是什么?在 Nelson 的论文 _Implementing Remote Procedure Calls_(参考[2]) 中他提到了几点:

简单:RPC 概念的语义十分清晰和简单,这样创建分布式计算就更容易。
高效:过程调用看起来十分简单并且高效。
通用:在单机计算中「过程」每每是不一样算法部分间最重要的通讯机制。

通俗一点说,就是通常程序员对于本地的过程调用很熟悉,那么咱们把 RPC 作成和本地调用彻底相似,那么就更容易被接受,使用起来毫无障碍。 Nelson 的论文发表于 30 年前,其观点今天看来确实高瞻远瞩,今天咱们使用的 RPC 框架基本就是按这个目标来实现的。

目标

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

分类

RPC 调用分如下两种:

  1. __同步调用__:客户端等待调用执行完成并获取到执行结果。

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

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

结构

下面咱们对 RPC 的结构从理论模型到真实组件一步步抽丝剥茧。

模型

最先在 Nelson 的论文中指出实现 RPC 的程序包括 5 个理论模型部分:

User
User-stub
RPCRuntime
Server-stub
Server

这 5 个部分的关系以下图所示:

这里 User 就是 Client 端。当 User 想发起一个远程调用时,它实际是经过本地调用 User-stub。 User-stub 负责将调用的接口、方法和参数经过约定的协议规范进行编码并经过本地的 RPCRuntime 实例传输到远端的实例。 远端 RPCRuntime 实例收到请求后交给 Server-stub 进行解码后发起向本地端 Server 的调用,调用结果再返回给 User 端。

拆解

上面给出了一个比较粗粒度的 RPC 实现理论模型概念结构,这里咱们进一步细化它应该由哪些组件构成,以下图所示。

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

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

组件

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

  1. RpcServer
    负责导出(export)远程接口

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

  3. RpcProxy
    远程接口的代理实现

  4. RpcInvoker
    客户端:负责编码调用信息和发送调用请求到服务端并等待调用结果返回
    服务端:负责调用服务端接口的具体实现并返回调用结果

  5. RpcProtocol
    负责协议编/解码

  6. RpcConnector
    负责维持客户端和服务端的链接通道和发送数据到服务端

  7. RpcAcceptor
    负责接收客户端请求并返回请求结果

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

  9. RpcChannel
    数据传输通道

实现

Nelson 论文中给出的这个概念模型也成为后来你们参考的标准范本。十多年前,我最先接触分布式计算时使用的 CORBAR(参考[3])实现结构基本与此基本相似。CORBAR 为了解决异构平台的 RPC,使用了 IDL(Interface Definition Language)来定义远程接口,并将其映射到特定的平台语言中。

后来大部分的跨语言平台 RPC 基本都采用了此类方式,好比咱们熟悉的 Web Service(SOAP),近年开源的 Thrift 等。 他们大部分都经过 IDL 定义,并提供工具来映射生成不一样语言平台的 User-stub 和 Server-stub,并经过框架库来提供 RPCRuntime 的支持。 不过貌似每一个不一样的 RPC 框架都定义了各自不一样的 IDL 格式,致使程序员的学习成本进一步上升。而 Web Service 尝试创建业界标准,无赖标准规范复杂而效率偏低,不然 Thrift 等更高效的 RPC 框架就不必出现了。

IDL 是为了跨平台语言实现 RPC 不得已的选择,要解决更普遍的问题天然致使了更复杂的方案。 而对于同一平台内的 RPC 而言显然不必搞个中间语言出来,例如 Java 原生的 RMI,这样对于 Java 程序员而言显得更直接简单,下降使用的学习成本。

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

导出

导出是指暴露远程接口的意思,只有导出的接口能够供远程调用,而未导出的接口则不能。 在 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 来导出,
那么远程调用时也须要传递该标记才能调用到正确的实现类,这样就解决了多态调用的语义。

导入

导入相对于导出而言,客户端代码为了可以发起调用必需要得到远程接口的方法或过程定义。目前,大部分跨语言平台 RPC 框架采用根据 IDL 定义经过 code generator 去生成 User-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 动态代理,另一种是字节码生成。 动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差不少。二者权衡起来,做为一种底层通用框架,我的更倾向于选择性能优先。

协议

协议指 RPC 调用在网络传输中约定的数据封装方式,包括三个部分:编解码消息头消息体

编解码

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

咱们先看下须要编码些什么信息:

调用编码

  1. 接口方法
    包括接口名、方法名

  2. 方法参数
    包括参数类型、参数值

  3. 调用属性
    包括调用属性信息,例如调用附加的隐式参数、调用超时时间等

返回编码

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

  2. 返回码
    异常返回码

  3. 返回异常信息
    调用异常信息

消息头

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

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

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

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

  • st
    消息体序列化类型

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

  • ow
    单向消息标记,

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

  • status code
    响应消息状态码

  • reserved
    为字节对齐保留

  • message id
    消息 id

  • body size
    消息体长度

消息体

消息体常采用序列化编码,常见有如下序列化方式:

  • xml
    如 webservie SOAP

  • json
    如 JSON-RPC

  • binary
    如 thrift; hession; kryo 等

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

  1. __效率__:序列化和反序列化的效率,越快越好。

  2. __长度__:序列化后的字节长度,越小越好。

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

上面这三点有时是鱼与熊掌不可兼得,这里面涉及到具体的序列化库实现细节,就不在本文进一步展开分析了。

传输

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

既然使用长链接,那么第一个问题是到底客户端和服务端之间须要多少根链接?实际上单链接和多链接在使用上没有区别,对于数据传输量较小的应用类型,单链接基本足够。单链接和多链接最大的区别在于,每根链接都有本身私有的发送和接收缓冲区,所以大数据量传输时分散在不一样的链接缓冲区会获得更好的吞吐效率。

因此,若是你的数据传输量不足以让单链接的缓冲区一直处于饱和状态的话,那么使用多链接并不会产生任何明显的提高,反而会增长链接管理的开销。

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

执行

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

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

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

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

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

  3. 超时控制
    当某个接口执行缓慢,而客户端已经超时放弃等待后,服务端的线程继续执行此时显得毫无心义。

异常

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

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

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

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

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

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

总结

至此咱们提出了一个 RPC 实现的概念框架,并详细分析了须要考虑的一些实现细节。不管 RPC 的概念是如何优雅,可是“草丛中依然有几条蛇隐藏着”,只有深入理解了 RPC 的本质,才能更好地应用。

看到这里的同窗也许会想按这个概念模型和实现解析真得能开发实现一个 RPC 框架库么?这个问题我能确定的回答,真得能够。由于我就按这个模型开发实现了一个最小化的 RPC 框架库来学习验证,相关的代码放在 Github 上,感兴趣的同窗能够本身去阅读。这是我本身的一个实验性质的学习验证用开源项目,地址是 https://github.com/mindwind/craft-atom,其中的 craft-atom-rpc 便是按这个模型实现的微型 RPC 框架库,代码量相对工业级使用的 RPC 框架库少的多,方便阅读学习。

最后,读到这里的确定都是好学不倦的同窗,谢谢你们的时间,让我写做的意义更多了一点:)。

参考

1] Bruce Jay Nelson. [Bruce Jay Nelson
2] BIRRELL, NELSON. [Implementing Remote Procedure Calls. 1983
3] CORBAR. [CORBAR
4] DUBBO. [DUBBO


写点程序世间的文字,画点生活瞬间的画儿,微信公众号「瞬息之间」,碰见了不妨就关注看看。
图片描述

相关文章
相关标签/搜索