APP下载

深入浅出 RPC - 深入篇

消息来源:baojiabao.com 作者: 发布时间:2024-05-17

报价宝综合消息深入浅出 RPC - 深入篇

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

RPC 功能目标

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

RPC 呼叫分类

RPC 呼叫分以下两种:

1. 同步呼叫

客户方等待呼叫执行完成并返回结果。

2. 异步呼叫

客户方呼叫后不用等待执行结果返回,但依然可以通过回拨通知等方式获取返回结果。

若客户方不关心呼叫返回结果,则变成单向异步呼叫,单向呼叫不用返回结果。

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

RPC 结构拆解

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

RPC 服务方通过 RpcServer 去汇出(export)远端界面方法,而客户方通过 RpcClient 去引入(import)远端界面方法。客户方像呼叫本地方法一样去呼叫远端界面方法,RPC 框架提供界面的代理实现,实际的呼叫将委托给代理RpcProxy 。代理封装呼叫资讯并将呼叫转交给RpcInvoker 去实际执行。在客户端的RpcInvoker 通过联结器RpcConnector 去维持与服务端的通道RpcChannel,并使用RpcProtocol 执行协议编码(encode)并将编码后的请求讯息通过通道传送给服务方。

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

RPC 元件职责

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

1. RpcServer

负责汇出(export)远端界面

2. RpcClient

负责汇入(import)远端界面的代理实现

3. RpcProxy

远端界面的代理实现

4. RpcInvoker

客户方实现:负责编码呼叫资讯和传送呼叫请求到服务方并等待呼叫结果返回

服务方实现:负责呼叫服务端界面的具体实现并返回呼叫结果

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

协议编解码

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

-- 呼叫编码 --

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,因此可以更容易的复用连线。

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

连线是由 client 端发起建立并维持。如果 client 和 server 之间是直连的,那么连线一般不会中断(当然物理链路故障除外)。如果 client 和 server 连线经过一些负载中转装置,有可能连线一段时间不活跃时会被这些中间装置中断。为了保持连线有必要定时为每个连线传送心跳资料以维持连线不中断。心跳讯息是 RPC 框架库使用的内部讯息,在前文协议头结构中也有一个专门的心跳位,就是用来标记心跳讯息的,它对业务应用透明。

执行呼叫

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

java 中实现程式码的动态界面呼叫目前一般通过反射呼叫。除了原生的 jdk 自带的反射,一些第三方库也提供了效能更优的反射呼叫,因此 RpcInvoker 就是封装了反射呼叫的实现细节。

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

1. 效率提升

每个请求应该尽快被执行,因此我们不能每请求来再建立执行绪去执行,需要提供执行绪池服务。

2. 资源隔离

当我们汇出多个远端界面时,如何避免单一界面呼叫占据所有执行绪资源,而引发其他界面执行阻塞。

3. 超时控制

当某个界面执行缓慢,而 client 端已经超时放弃等待后,server 端的执行绪继续执行此时显得毫无意义。

RPC 异常处理

无论 RPC 怎样努力把远端呼叫伪装的像本地呼叫,但它们依然有很大的不同点,而且有一些异常情况是在本地呼叫时绝对不会碰到的。在说异常处理之前,我们先比较下本地呼叫和 RPC 呼叫的一些差异:

1. 本地呼叫一定会执行,而远端呼叫则不一定,呼叫讯息可能因为网络原因并未传送到服务方。

2. 本地呼叫只会丢掷界面宣告的异常,而远端呼叫还会跑出 RPC 框架执行时的其他异常。

3. 本地呼叫和远端呼叫的效能可能差距很大,这取决于 RPC 固有消耗所占的比重。

正是这些区别决定了使用 RPC 时需要更多考量。当呼叫远端界面丢掷异常时,异常可能是一个业务异常,也可能是 RPC 框架丢掷的执行时异常(如:网络中断等)。业务异常表明服务方已经执行了呼叫,可能因为某些原因导致未能正常执行,而 RPC 执行时异常则有可能服务方根本没有执行,对呼叫方而言的异常处理策略自然需要区分。

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

总结

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

原文:https://blog.csdn.net/mindfloating/article/details/39474123

2019-09-26 11:54:00

相关文章