能看懂的RPC框架讲解
实践记录 · 2023/8/22 · 玉米哥
正在努力成长为一名合格的软件开发工程师(Software Development Engineer),记录自己学习的点点滴滴,如果喜欢我的文章,请留下您的小心心。感谢!
目录
RPC框架的基本概念
RPC框架的分层设计
如何评价一个RPC框架
RPC框架在实践中的使用
前言
在上一篇文章小白也能看懂的socks5代理服务器原理及实现中,我总结了socks5代理服务器的基本原理和代码实现,全文尽量使用了通俗易懂的语言和方式来讲解,如果小伙伴感兴趣,可以点击链接阅读全文哦。
本篇文章会带来RPC框架的相关知识,我会以一边学习、一边总结的方式来阐述相关的原理,遇到疑惑,也会在文中及时提出,让我们一起思考、学习、进步。
RPC框架的基本概念
我们熟悉的函数调用
如果你学过C++、C语言或者Java,那么一定不会对main
函数感到陌生。我们的程序从main
函数开始执行,直到main
函数结束。
倘若main
函数中有大量重复的代码块,我们可以将其包裹到一个自定义函数中,然后在main
函数中调用自定义函数。
func main() {
var a = 3
var b = 5
result := calculate(a, b)
fmt.PrintLn(c)
}
func calculate(x, y int) {
z := x*y
return z
}
main
函数调用calculate
函数时,发生了一系列的事情。
- 将
a
b
的值压入函数调用栈中。 - 通过函数指针找到
calculate
函数,进入函数,取出栈中的值,并赋予x
y
。 - 计算
x*y
,将结果赋予z
。 - 将
z
的值压入函数调用栈,然后从calculate
返回。 - 从函数调用栈中取出
z
的值,并赋值给result
。
上面这个例子中,main
函数和calculate
函数位于同一个源文件中,被编译成一个可执行程序,运行该程序时,就产生了一个进程,在该进程的地址空间中,main
函数可以找到calculate
函数的地址,调用该函数,并将数据传送给它。
我们不熟悉的远程函数调用
还是上面的示例,当你看到下面这一行函数调用时,是不是以为calculate
函数和main
函数同一个程序中,实际上不是。
result := calculate(a, b)
有所不同的是,calculate
函数是另一台电脑中的程序的函数。
func calculate(x, y int) {
z := x*y
return z
}
那就纳闷了,看起来就像调用本地函数那样,实际上调用的却是远程函数。在这个过程中,我难道不需要关心main
函数如何调用另一台电脑的calculate
函数吗?不需要关心如何通过网络发送函数所需的参数和计算结果吗?
你的猜测对了一半,不同机器上的程序,各自所占用的内存空间不同,如果你不关心上面的细节,是没办法正常调用calculate
函数的。
然而事实上,作为调用方,你可以不需要关心这些细节。而这背后的功劳,就是RPC框架。简单来说,RPC框架的目标或者作用之一,就是让调用方像调用本地函数那样调用远程函数,而无需处理繁杂的细节。
当然,如果你需要开发一个RPC框架,你是需要关心这些细节问题的。
示例中的简单程序不大可能部署在不同的机器上,然而现实中的应用程序有着多功能、多模块。每个功能可能运行在不同的电脑上。那么这些位于不同电脑的功能,该如何相互调用呢?
很容易想到,不同电脑中的功能,属于各自电脑里的不同进程。两台电脑之间不会凭空共享彼此产生的数据。因此,就诞生了远程函数调用(Remote Procedure Call,RPC),即RPC调用。
让我们用学武老师的复杂例子说明RPC调用。
网上商城 ↔ 支付中心
当我们网购时,首先会浏览各种不同的商品,查看商品的详细信息、评价等,如果喜欢该商品,就会加入购物车,或者直接购买。
由于购物软件的复杂度,商品展示的功能和支付功能往往部署在不同的机器上。
当我们点击支付按钮时,会发生下列的事情。
- 商城程序将商品价格、账户信息等数据传递给支付中心。
- 支付中心对相应账户进行扣款。
- 支付中心向商城程序返回扣款成功的数据。
如果你是开发商城的程序员,你只需要简单地像本地调用一样调用支付函数。但如果你是RPC框架的开发者,那就需要考虑下面几个问题。
- 不同的功能模块有多个函数。商城程序如何确保调用支付中心的扣款函数而不是退款函数。
- 商城模块如何将商品价格等数据发送到支付模块所在的机器。
- 如何确保传输过程中网络的安全和稳定。
内存中的数据往往需要序列化为字节流之后再通过网络传输。如果你不知道什么是序列化和反序列化,请阅读我的文章Go语言入门指南(下)。
RPC概念模型
从0设计一个RPC框架是很难的,但是我们可以借鉴前人的智慧。早在1984年,Nelson发表了论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server。
模型间的关系如下表。
User | User-Stub | RPCRuntime | Network | RPCRuntime | Server-Stub | Server | ||||
---|---|---|---|---|---|---|---|---|---|---|
local call | ➡ | pack argument | ➡ | transmit | ➡ | receive | ➡ | unpack argument | ➡ | call |
local return | ⬅ | unpack result | ⬅ | receive | ⬅ | transmit | ⬅ | pack result | ⬅ | return |
- 左侧的
User
User-Stub
RPCRuntime
属于调用端,通过网络与右侧的被调用端的Server
Server-Stub
RPCRuntime
通信。
通信过程如下。
User
发起本地调用,将参数传递给User-Stub
。User-Stub
将参数打包,交给调用端的RPCRuntime
。- 调用端的
RPCRuntime
将包通过网络通信,发送给被调用端的RPCRuntime
。 - 被调用端的
RPCRuntime
将包交给Server-Stub
。 Server-Stub
解压参数,交给Server
进行处理。Server
将处理好的结果交给Server-Stub
。Server-Stub
将结果打包,交给RPCRuntime
。RPCRuntime
将包通过网络返回给调用端的RPCRuntime
,并交给User-Stub
。User-Stub
对结果解压,将结果返回给User
。
现在你对PRC调用过程有了一个大致的印象,通过这个经典的RPC理论模型,你就可以开发一个RPC框架了。
但还有一个问题。调用者如何知道被调用端有哪些函数,这些函数有什么参数呢?
这就需要我们事先定义好这些函数的接口,类似于C++中的函数声明。当我们看到calculate
函数声明时,就知道该函数需要接收2个整型参数,并且没有返回值了。
void calculate(int x, int y);
IDL文件
然而,RPC调用涉及的调用端和被调用端可能会使用不同的语言。比如,商城模块使用C++开发,支付模块使用Go开发。因此,我们需要先准备好一份接口描述语言文件(Interface Description Language,IDL),通过一种类似伪代码的方式来描述接口。这样不同平台上运行的不同语言的程序也可以相互调用了。
生成代码
调用端和被调用端往往使用特定的程序进行开发,我们可以通过IDE工具将IDL文件转换成该特定语言的静态库。通过调用这些静态库中的函数,我们的程序就可以像被调用端发起调用了。
编解码
我们将所需的参数和相关信息编码为字节流,然后就可以通过网络传输了。编解码也叫序列化或者反序列化。
通信协议
调用端和被调用端可以约定使用的通信协议,规范在网络中传输的内容以及格式。也就是说,除了程序所需的参数外,还需要将其他的信息与参数一起打包,总之是为了数据的高效传输,你可以将其类别为快递包裹内的物品(程序所需参数)和快递包裹面单(其他必要信息)。
网络传输
所有的数据都打包好后,可以通过网络协议传输,如TCP、UDP协议。
如果你对通信协议不太熟悉,可以阅读我的另一篇文章万字长文 | Go语言实战案例(中):在线词典,在这篇文章中,我基于简易词典,详细讲解了HTTP协议的相关内容,通俗易懂,非常适合我这样的小白加深对通信协议的感性认识。
RPC的好处是什么
讲解完RPC的基本概念后,问题又来了,这么复杂的模型,我们为什么要用呢,为什么不将所有的服务都放在一台机器中呢,这样就彻底消除了远程调用的可能性。
有利于分工协作和运维开发
在大公司中,软件往往由多个团队同时开发,采用RPC调用的方式,只需要事先约定好团队之间的调用接口,然后就可以专注于团队内的功能开发了。且不同的团队可以使用不同的语言,在不同的机器上部署。若程序出现了故障,可以直接对该程序进行运维等,不需要干涉其他程序的运行。
扩展性强,资源使用率更优
以抖音为例,抖音中包括账户信息、地理位置、视频流、广告、商城、直播等模块。如果购物节来临,可根据需要给商城等模块添加更多的机器,减少负荷。此外,基本的地理位置、账户信息等模块,可以同时被其他模块复用。
故障隔离
当某个模块出现故障,无法提供服务时,不会干扰到其他模块的运行。
RPC可能带来哪些问题
RPC虽然有上述好处,但是由于服务调用方和被调用方位于不同的机器,需要通过网络通信,因此,也会遇到一些网络相关的问题。
- 服务宕机,对方该如何处理?
- 调用过程中发生网络异常,如何保证消息的可达性?
- 某个服务的请求量突增,服务无法及时处理,该怎么办?
遇到这些问题时,我们就需要在RPC框架中添加额外的特性来逐一攻破。
小结
通过上述内容,我相信小伙伴对下面的知识点应该有了初步的印象吧。
本地函数调用和RPC调用的区别:函数映射、数据转换为字节流、网络传输。
RPC的概念模型:User、User-Stub、RPCRuntime、Server-Stub、Server。
了解一次RPC调用的完整过程。
了解RPC的好处和缺点,将由RPC框架解决。
RPC框架的分层设计
现在我们应该对RPC框架有了一个感性的认识,如果我们要开发一个RPC框架,那么必须要理解框架实现的原理,让我们进入RPC框架内部一探究竟。
Apache Thrift的分层设计
Apache Thrift是一个RPC框架,该框架分为以下几层。
Client | Server |
---|---|
Code | Code |
Service Client | Service Processor |
read/write | read/write |
TProtocal | TProtocal |
TTransport | TTransport |
Network IO | Network IO |
- Code层是用户自己编写的业务逻辑代码,不属于框架的一部分。
- Service层和read/write层都属于生成代码层(Generated Code),由代码生成工具根据IDL文件生成,其中封装了编解码的逻辑。
- TProtocol是编解码层。
- TTransport是协议层。
- Network IO是网络通信层。
下面我们详细探究每一层。
生成代码层和编解码层
我们将生成代码层和编解码层放在一起讨论,是因为生成代码层封装了编解码的逻辑,也可以认为是编解码层的一部分。
生成代码的过程是什么样的呢?
调用端和被调用端依赖同一份IDL文件,利用代码生成工具,可以生成不同语言编写的库代码,这样调用端和被调用端的业务就可以使用不同的语言来开发。
那么IDL文件包含了什么呢?IDL文件中包含了远程过程调用中需要的参数和函数等,在网络中传输这些参数并指定需要调用的函数时,就需要编码和解码。
在这里,我们插入一个小话题,介绍一下常用的数据格式。
-
文本格式
我们可以将程序运行时内存中的对象表示为常见的文本格式,比如CSV、XML、JSON等,这些都是可以通过记事本打开的文本文件,具有人类可读性。
-
语言特定的格式
我们还可以将内存中的对象表示为字节序列,但是这种字节序列只能由同种语言识别。比如Go语言中的
JSON.Marshall
函数,可以将struct
对象中包含的数据序列化为字节流。从而通过网络传输。但是,接收方接收到了这些字节流,必须要使用Go语言的
JSON.Unmarshall
函数进行反序列化,才能将数据正确恢复为struct
对象。如果使用其他编程语言的反序列化函数,那么得到的大概率是一团乱麻。 -
二进制编码
那么有没有这样一种语言无关的编解码算法呢,答案是有的,可以遵循某一种特定的算法,将内存中的对象编码为二进制序列。这样就可以通过网络传输。接收方收到该序列号,遵循相同的算法进行解码,就能得到原来的对象了。唯一不同的是,这种方式不限定双方使用的编程语言,只要双方使用相同的编解码算法即可。如BinaryProtocol、Protobuf等。
我们详细介绍一下Thrift的BinaryProtocol编码的实现方式。BinaryProtocol使用TLV编码实现。
什么是TLV呢?TLV其实是三个单词的缩写。
- Tag:标签,即类型
- Length:长度
- Value:值
假设我们在IDL文件中描述了下面的结构体。
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}
根据这个结构体,我们创建了一个person对象。
{userName: "Martin", favoriteNumber: 1337, interests: ["daydreaming", "hacking"]}
现在的任务就是将这个对象的数据编码。如果你学过ASCII码和数字的二进制表示,那么很容易的可以将上面的内容编码为下列的十六进制。
Martin | 4d 61 72 74 69 6e |
---|
1337 | 05 39 |
---|
daydreaming | 64 61 79 64 72 65 61 6d 69 6e 67 |
---|
hacking | 68 61 63 6b 69 6e 67 |
---|
编码完毕!现在我们可以将这串字节流通过网络发送给对方了。等等,好像有问题,接收端收到一长串字节流后,怎么分割呢?怎么知道第一个字符串表示userName
呢?并且还需要用6字节表示呢?怎么知道05 39
表示的是数字而非字符呢?
看起来我们还需要将对象内每个数据的类型,长度等信息也编码到字节流中。而TLV就是一种编码方式。
以Martin
字符串为例。
Martin | 0b 00 01 00 00 00 06 4d 61 72 74 69 6e |
---|---|
0b | 表示该字段的值是字符串类型 |
00 01 | 表示该字段是对象中的第一个字段,即userName |
00 00 00 06 | 表示字符串的长度为6个字节 |
4d 61 72 74 69 6e | 表示字符串本身 |
请你仔细观察上面的表格,我们采用TLV编码后的字节流,不仅包含了字符串本身,还包含了解码该字符串所需的信息,这样接收端收到这一长串后,就不会两眼发黑了。
再以数字1337
为例。
1337 | 0a 00 02 00 00 00 00 00 00 05 39 |
---|---|
0a | 表示该字段的类型是64位整型 |
00 02 | 表示该字段是对象中的第二个字段,即favoriteNumber |
00 00 00 00 00 00 05 39 | 表示数值本身 |
类似的,当接收方收到字节流后,就能正确解码该字段。有朋友可能疑惑,为什么对数字编码不需要明确长度信息呢?仔细看,我们已经明确了数值类型是64位整型,因此数值的长度已经确定了。而如果是字符串,则长度没办法提前预知,必须编码进去。
下一步
在这篇文章中,我主要讲解了RPC框架的基本概念和分层设计,希望你能有所收获。
码字不易,如果您看到了这里,听我说谢谢你😀
如果您觉得本文还不错,请留下小小的赞😀
如果您有感而发,请留下宝贵的评论😀