能看懂的RPC框架讲解 | 青训营

46 阅读15分钟

能看懂的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函数时,发生了一系列的事情。

  1. a b的值压入函数调用栈中。
  2. 通过函数指针找到calculate函数,进入函数,取出栈中的值,并赋予x y
  3. 计算x*y,将结果赋予z
  4. z的值压入函数调用栈,然后从calculate返回。
  5. 从函数调用栈中取出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调用。

网上商城 ↔ 支付中心

当我们网购时,首先会浏览各种不同的商品,查看商品的详细信息、评价等,如果喜欢该商品,就会加入购物车,或者直接购买。

由于购物软件的复杂度,商品展示的功能和支付功能往往部署在不同的机器上。

当我们点击支付按钮时,会发生下列的事情。

  1. 商城程序将商品价格、账户信息等数据传递给支付中心。
  2. 支付中心对相应账户进行扣款。
  3. 支付中心向商城程序返回扣款成功的数据。

如果你是开发商城的程序员,你只需要简单地像本地调用一样调用支付函数。但如果你是RPC框架的开发者,那就需要考虑下面几个问题。

  1. 不同的功能模块有多个函数。商城程序如何确保调用支付中心的扣款函数而不是退款函数。
  2. 商城模块如何将商品价格等数据发送到支付模块所在的机器。
  3. 如何确保传输过程中网络的安全和稳定。

内存中的数据往往需要序列化为字节流之后再通过网络传输。如果你不知道什么是序列化和反序列化,请阅读我的文章Go语言入门指南(下)

RPC概念模型

从0设计一个RPC框架是很难的,但是我们可以借鉴前人的智慧。早在1984年,Nelson发表了论文《Implementing Remote Procedure Calls》,其中提出了RPC的过程由5个模型组成:User、User-Stub、RPC-Runtime、Server-Stub、Server。

模型间的关系如下表。

UserUser-StubRPCRuntimeNetworkRPCRuntimeServer-StubServer
local callpack argumenttransmitreceiveunpack argumentcall
local returnunpack resultreceivetransmitpack resultreturn
  • 左侧的User User-Stub RPCRuntime属于调用端,通过网络与右侧的被调用端的Server Server-Stub RPCRuntime通信。

通信过程如下。

  1. User发起本地调用,将参数传递给User-Stub
  2. User-Stub将参数打包,交给调用端的RPCRuntime
  3. 调用端的RPCRuntime将包通过网络通信,发送给被调用端的RPCRuntime
  4. 被调用端的RPCRuntime将包交给Server-Stub
  5. Server-Stub解压参数,交给Server进行处理。
  6. Server将处理好的结果交给Server-Stub
  7. Server-Stub将结果打包,交给RPCRuntime
  8. RPCRuntime将包通过网络返回给调用端的RPCRuntime,并交给User-Stub
  9. 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框架,该框架分为以下几层。

ClientServer
CodeCode
Service ClientService Processor
read/writeread/write
TProtocalTProtocal
TTransportTTransport
Network IONetwork 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码和数字的二进制表示,那么很容易的可以将上面的内容编码为下列的十六进制。

Martin4d 61 72 74 69 6e
133705 39
daydreaming64 61 79 64 72 65 61 6d 69 6e 67
hacking68 61 63 6b 69 6e 67

编码完毕!现在我们可以将这串字节流通过网络发送给对方了。等等,好像有问题,接收端收到一长串字节流后,怎么分割呢?怎么知道第一个字符串表示userName呢?并且还需要用6字节表示呢?怎么知道05 39表示的是数字而非字符呢?

看起来我们还需要将对象内每个数据的类型,长度等信息也编码到字节流中。而TLV就是一种编码方式。

Martin字符串为例。

Martin0b 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为例。

13370a 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框架的基本概念和分层设计,希望你能有所收获。

码字不易,如果您看到了这里,听我说谢谢你😀

如果您觉得本文还不错,请留下小小的赞😀

如果您有感而发,请留下宝贵的评论😀