深入浅出 RPC - 深入篇

《深入篇》我们主要围绕 RPC 的功能目标和实现考量去展开,一个基本的 RPC 框架应该提供什么功能,满足什么要求以及如何去实现它。

RPC 功能目标

RPC 的主要功能目标是让构建分布式计算(应用)更容易,在提供强大的远程调用能力时不损失本地调用的语义简介性。为实现该目标,RPC 框架需提供一种透明调用机制让使用者不必显式的区分本地调用和远程调用,在前文《浅出篇》中给出了一种实现结构,基于 stub 的结构来实现。下面我们将具体细化 stub 结构的实现。

RPC 调用分类

RPC 调用分一下两种:
1. 同步调用
客户端等待调用执行完成并返回结果
2. 异步调用
客户端调用后不用等待执行结果返回,但依然可以通过回调通知等方式获取返回结果。若客户方不关心调用结果返回 结果,则变成单向异步调用,单向调用并不用返回结果。

异步和同步的区别在于是否等待服务端执行完成并返回结果。

RPC 结构拆解

《浅出篇》给出了一个比较粗粒度的 RPC 实现概念结构,这里我们进一步细化它应该由哪些组建构成,如下图所示。
这里写图片描述

RPC 服务通过 RpcServer 去导出(export)远程接口方法,而客户方通过 RpcClient 去引入(import)远程接口方法。客户方像调用本地方法一样去调用远程接口方法,RPC 框架提供接口的代理实现,实际的调用将委托给代理 RpcProxy。代理封装调用信息并将调用转交给 RpcInvoker 去实际执行。在客户端的 RpcInvoker 通过连接器 RpcConnector 去维持与服务端的通道 RocChannel,并使用 RpcProtocol 执行协议编码(encode)并将编码后的请求信息通过通道发送给服务方。
RPC 服务端接收器 RpcAcceptor 接收客户端的调用请求,同样使用 RpcProtocol 执行协议代码(decode)。解码后的调用信息传递给 RpcProcessor 去控制处理调用过程,最后再委托调用给 RpcInvoker 去实际执行并返回调用结果。

RPC 组件职责

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

1. RpcServer
   负责导出(export)远程接口
2. RpcClient
   负责导入(import)远程接口的代理实现
3. RpcProxy
   远程接口的代理实现
4. PpcInvoker
   客户方实现:负责编码调用信息和发送调用请求到服务方并等待调用结果返回
   服务方实现:负责调用服务端接口的具体实现并返回调用结果
5. RpcProtocol
   负责协议编/解码
6. RpcConnector
   负责维持客户方和服务方的连接通道和发送数据到服务方
7. RpcAcceptor
   负责接收客户方请求并返回请求结果
8. RpcProcessor
   负责在服务控制调用过程,包括管理调用线程池、超时时间等
9. RpcChannel
   数据传输通道

RPC 实现分析

在进一步拆解了组件并划分了职责之后,这里以在 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 接口有两个实现,那么在导出接口时就需要特殊标记不同的实现,如:

DemoService demo = new ...;
DemoService demo2 = new ...;
RpcService 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 动态代理,另外一种是字节码生成。动态代理相比字节码生成使用起来更方便,但动态代理方式在性能上是要逊色于直接的字节码生成的,而字节码生成在代码可读性上要差很多。两者权衡起来,个人认为牺牲一些性能来获得代码可读性和可维护性显得更重要。

协议编解码

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

--调用编码--
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. 序列化和反序列化的兼容性,接口参数对象若增加了字段,是否兼容

传输服务

client stub 所做的事情仅仅是编码消息并传输给服务方,而真正调用过程发生在服务方。server stub 从前文的结构拆解中我们

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值