RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简洁性。 为实现该目标,RPC 框架需提供一种透明调用机制让使用者没必要显式的区分本地调用和远程调用。 下面咱们将具体细化 stub 结构的实现。java
RPC 调用分如下两种:web
同步调用 客户方等待调用执行完成并返回结果。json
异步调用 客户方调用后不用等待执行结果返回,但依然能够经过回调通知等方式获取返回结果。 若客户方不关心调用返回结果,则变成单向异步调用,单向调用不用返回结果。bash
异步和同步的区分在因而否等待服务端执行完成并返回结果。网络
以下图所示。框架
RPC 服务方经过 RpcServer
去导出(export)远程接口方法,而客户方经过 RpcClient
去引入(import)远程接口方法。 客户方像调用本地方法同样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理 RpcProxy
。 代理封装调用信息并将调用转交给 RpcInvoker
去实际执行。 在客户端的 RpcInvoker
经过链接器 RpcConnector
去维持与服务端的通道 RpcChannel
, 并使用 RpcProtocol
执行协议编码(encode)并将编码后的请求消息经过通道发送给服务方。异步
RPC 服务端接收器 RpcAcceptor
接收客户端的调用请求,一样使用 RpcProtocol
执行协议解码(decode)。 解码后的调用信息传递给 RpcProcessor
去控制处理调用过程,最后再委托调用给 RpcInvoker
去实际执行并返回调用结果。jvm
上面咱们进一步拆解了 RPC 实现结构的各个组件组成部分,下面咱们详细说明下每一个组件的职责划分。分布式
RpcServer 负责导出(export)远程接口性能
RpcClient 负责导入(import)远程接口的代理实现
RpcProxy 远程接口的代理实现
RpcInvoker 客户方实现:负责编码调用信息和发送调用请求到服务方并等待调用结果返回 服务方实现:负责调用服务端接口的具体实现并返回调用结果
RpcProtocol 负责协议编/解码
RpcConnector 负责维持客户方和服务方的链接通道和发送数据到服务方
RpcAcceptor 负责接收客户方请求并返回请求结果
RpcProcessor 负责在服务方控制调用过程,包括管理调用线程池、超时时间等
RpcChannel 数据传输通道
在进一步拆解了组件并划分了职责以后,这里以在 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 去生成 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 协议消息的话,元信息咱们把它放在协议消息头中,而必要信息放在协议消息体中。 下面给出一种概念上的 RPC 协议消息设计格式:
消息头
采用序列化编码,常见有如下格式
格式肯定后编解码就简单了,因为头长度必定因此咱们比较关心的就是消息体的序列化方式。 序列化咱们关心三个方面:
序列化和反序列化的效率,越快越好。
序列化后的字节长度,越小越好。
序列化和反序列化的兼容性,接口参数对象若增长了字段,是否兼容。
上面这三点有时是鱼与熊掌不可兼得,这里面涉及到具体的序列化库实现细节,就不在本文进一步展开分析了。
协议编码以后,天然就是须要将编码后的 RPC 请求消息传输到服务方,服务方执行后返回结果消息或确认消息给客户方。 RPC 的应用场景实质是一种可靠的请求应答消息流,和 HTTP 相似。 所以选择长链接方式的 TCP 协议会更高效,与 HTTP 不一样的是在协议层面咱们定义了每一个消息的惟一 id,所以能够更容易的复用链接。
既然使用长链接,那么第一个问题是到底 client 和 server 之间须要多少根链接? 实际上单链接和多链接在使用上没有区别,对于数据传输量较小的应用类型,单链接基本足够。 单链接和多链接最大的区别在于,每根链接都有本身私有的发送和接收缓冲区, 所以大数据量传输时分散在不一样的链接缓冲区会获得更好的吞吐效率。 因此,若是你的数据传输量不足以让单链接的缓冲区一直处于饱和状态的话,那么使用多链接并不会产生任何明显的提高, 反而会增长链接管理的开销。
链接是由 client 端发起创建并维持。 若是 client 和 server 之间是直连的,那么链接通常不会中断(固然物理链路故障除外)。 若是 client 和 server 链接通过一些负载中转设备,有可能链接一段时间不活跃时会被这些中间设备中断。 为了保持链接有必要定时为每一个链接发送心跳数据以维持链接不中断。 心跳消息是 RPC 框架库使用的内部消息,在前文协议头结构中也有一个专门的心跳位, 就是用来标记心跳消息的,它对业务应用透明。
client stub 所作的事情仅仅是编码消息并传输给服务方,而真正调用过程发生在服务方。 server stub 从前文的结构拆解中咱们细分了 RpcProcessor
和 RpcInvoker
两个组件, 一个负责控制调用过程,一个负责真正调用。 这里咱们仍是以 java 中实现这两个组件为例来分析下它们到底须要作什么?
java 中实现代码的动态接口调用目前通常经过反射调用。 除了原生的 jdk 自带的反射,一些第三方库也提供了性能更优的反射调用, 所以 RpcInvoker 就是封装了反射调用的实现细节。
调用过程的控制须要考虑哪些因素,RpcProcessor 须要提供什么样地调用控制服务呢? 下面提出几点以启发思考:
效率提高 每一个请求应该尽快被执行,所以咱们不能每请求来再建立线程去执行,须要提供线程池服务。
资源隔离 当咱们导出多个远程接口时,如何避免单一接口调用占据全部线程资源,而引起其余接口执行阻塞。
超时控制 当某个接口执行缓慢,而 client 端已经超时放弃等待后,server 端的线程继续执行此时显得毫无心义。
不管 RPC 怎样努力把远程调用假装的像本地调用,但它们依然有很大的不一样点,并且有一些异常状况是在本地调用时绝对不会碰到的。 在说异常处理以前,咱们先比较下本地调用和 RPC 调用的一些差别:
本地调用必定会执行,而远程调用则不必定,调用消息可能由于网络缘由并未发送到服务方。
本地调用只会抛出接口声明的异常,而远程调用还会跑出 RPC 框架运行时的其余异常。
本地调用和远程调用的性能可能差距很大,这取决于 RPC 固有消耗所占的比重。
正是这些区别决定了使用 RPC 时须要更多考量。 当调用远程接口抛出异常时,异常多是一个业务异常, 也多是 RPC 框架抛出的运行时异常(如:网络中断等)。 业务异常代表服务方已经执行了调用,可能由于某些缘由致使未能正常执行, 而 RPC 运行时异常则有可能服务方根本没有执行,对调用方而言的异常处理策略天然须要区分。
因为 RPC 固有的消耗相对本地调用高出几个数量级,本地调用的固有消耗是纳秒级,而 RPC 的固有消耗是在毫秒级。 那么对于过于轻量的计算任务就并不合适导出远程接口由独立的进程提供服务, 只有花在计算任务上时间远远高于 RPC 的固有消耗才值得导出为远程接口提供服务。
至此咱们提出了一个 RPC 实现的概念框架,并详细分析了须要考虑的一些实现细节。 不管 RPC 的概念是如何优雅,可是“草丛中依然有几条蛇隐藏着”,只有深入理解了 RPC 的本质,才能更好地应用。
欢迎你们关注个人公众号【风平浪静如码】,海量Java相关文章,学习资料都会在里面更新,整理的资料也会放在里面。
以为写的还不错的就点个赞,加个关注呗!点关注,不迷路,持续更新!!!