项目介绍与准备¶
约 5964 个字 54 行代码 14 张图片 预计阅读时间 21 分钟
Warning
本文档的内容后期可能会进行大修改,当前暂时停止更新RPC框架所有文档
何为RPC¶
RPC(Remote Procedure Call)远程过程调用,是一种通过网络从远程计算机上请求服务,而不需要了解底层网络通信细节。RPC可以使用多种网络协议进行通信,如HTTP、TCP、UDP等,并且在TCP/IP网络四层模型中跨越了传输层和应用层,如下图所示:
功能介绍¶
在本次实现的项目中,首先就是要保证RPC本身的功能:本地调用远程方法。换句话说,客户端并不需要自己去实现某一个具体方法,而是通过调用服务端的方法来得到需要的结果,这个过程中也可以涉及到参数的传递,如下图所示:
虽然上面的过程已经基本实现了RPC的功能,但是还需要考虑一些其他的问题,例如当前服务器只有一台,如果客户端请求的服务器离线了但是客户端并不知道这一点,这就导致客户端想请求服务就会失败,所以在本次项目中会考虑提供一个服务注册的功能,这个服务注册本质就是了解当前有哪些服务器可以提供哪些服务,而用于服务注册的服务器一旦知道了具体可以提供的服务就可以告知客户端,并且自己也可以将客户端的请求转发给具体的服务器来处理,这样就实现了一个分布式架构的RPC。另外,基于这个服务器也可以实现负载均衡的功能,例如当一台服务器压力过大时可以将请求转发给其他拥有相同服务的服务器来处理,如下图所示:
在上图中可以看到,客户端并不是直接请求到具体的服务器,而是请求到了服务注册中心,服务注册中心会根据客户端的请求来决定具体的服务器
但是,用于服务注册的服务器本身也是一台服务器,也可能存在崩溃的问题,所以在本次项目中除了单独提供一台服务器用于服务注册以外,还考虑将提供服务的每一台服务器设计为备用注册中心
另外,本次还考虑实现一个发布订阅的功能,简单来说就是允许客户端向服务端发送一个内容,从这个功能可以看出,客户端发送给服务端的内容需要被服务端保存,如果直接将客户端的内容存储到服务端,那么就会存在一台服务器上有各种各样的内容,查找起来也会非常麻烦,所以可以考虑设计一个主题(topic)功能,在这个功能中,客户端并不直接将内容发送给存储内容的服务器,而是先被主题服务器归类到某一个主题,再将该内容发送给对应主题的服务器,同时客户端本身也可以发布主题。最后,如果某一个客户端发布了某个主题的消息,那么其他订阅了同一个主题的客户端也需要收到这个消息,所以在本项目中除了对主题的操作以外,还需要有对消息的发布功能,整体功能如下图所示:
基于上面的功能,现在将上面的功能进行合并,如下图所示:
综上所述,本次项目需要实现的功能如下:
- 基本RPC功能
- 服务的注册与发现以及服务的下线/上线通知
- 主题操作和消息发布功能
服务端模块介绍¶
模块总览¶
本次实现的项目中服务端主要分为下面几个模块:
- 网络服务模块(Network):基于Muduo库实现底层网络服务器来处理客户端的请求
- 应用层协议模块(Protocol):因为TCP传输是面向字节流的,所以在大量的网络通信时必然会存在粘包问题,但是TCP本身无法解决这个问题,因为尽管是粘包,它也是一个数据,在TCP看来都属于有效载荷,但是这个数据是否是否是有效数据需要应用层来进行判断,所以就需要这个协议模块来处理这个问题。这一点在序列化和反序列化与网络计算器部分也有所提及
- 请求分发模块(Dispatcher):在上面的功能介绍中提到本次实现的RPC框架除了RPC本身的功能外,还存在着另外两种功能,所以需要在处理客户端请求时判断客户端当前需要的功能,根据功能的类型决定调用哪一台服务器来处理,这个模块就是用来实现这个功能的
- RPC功能模块(RpcServer):这个模块就是用来实现RPC的基本功能
- 服务处理模块(ServiceHandleServer):这个模块就是用来实现服务注册与发布、上线与下线通知的功能
- 主题操作模块(TopicOpServer):这个模块就是用来实现主题操作功能的,其中主题操作包括:创建主题、删除主题、取消主题、跟随主题和主题消息发布的功能
- 服务器模块(MainServer):整合上面的所有模块,实现整个服务器
网络服务模块¶
基于Muduo库设计,,对于Muduo库的介绍和使用可以参考关于Muduo库,关于Muduo库具体的设计原理,可以参考仿Muduo库高并发服务器
应用层协议模块¶
在协议设计中,常见有三种协议设计方案:
- 通过特殊字符分割不同的数据:这个方案最大的缺点就在于特殊字符可能存在于消息中,这就需要对数据中的特殊字符进行转义,这个过程相对复杂,本次不考虑
- 通过固定长度限制每一条消息的最大大小:这个方案最大的缺点就在于消息的长度是固定的,这就导致如果消息比较短的话就会浪费空间,本次不考虑
- 采用
Length-Value
协议:通过长度字段来标记每一条消息的长度,这个方案的优点就在于可以灵活的控制消息的长度,本次采用这个方案
根据Length-Value
协议的设计思想,本次设计的协议如下:
- 有效数据的总长度(TotalLength):大小为4字节,用于表示包括后续内容的长度,根据这个长度可以做到多个不同请求之间的拆分
- 请求类型(RequestType):大小为4字节,用于表示当前请求的类型,例如:RPC请求、服务注册请求、发布订阅请求
- 请求/响应ID长度(IDLength):大小为4字节,用于表示消息ID的长度
- 请求/响应ID(ReqRespID):大小为IDLength字节,用于表示消息的ID,这个ID用于标记一类请求和应答,因为客户端不会在收到上一条请求的应答之后才发送下一次请求,而是可能多次发送多个请求,而对于服务器来说,当其处理完毕返回给客户端应答时,客户端就需要知道这个应答是属于哪一个请求的。根据这个需求,这个ID必须是唯一的,所以本次采用
UUID
来作为消息ID,而UUID
一般是一个字符串。即使是字符串,哪怕是固定的,为了保证之后的扩展性,依旧将其设计为可变长度,描述长度的字段就是IDLength - 有效数据(Body):大小为总长度减去其他字段的长度,用于表示具体的请求或者响应内容
整个协议的设计如下图所示:
请求分发模块¶
在本次项目中,处理请求并分发请求的逻辑如下:
- 网络服务器模块接收到客户端发送的请求数据,此时收到的是原始数据
- 应用层协议模块解析请求数据,此时收到的是解析后的请求数据
- 请求分发模块根据请求数据的类型来判断当前的请求是属于哪一种类型,然后调用对应的模块来处理请求
整个过程如下图所示:
RPC功能模块¶
在RPC模块中,主要就是针对客户端发送的RPC服务请求做出RPC服务的响应,但是这个过程中涉及一些问题
首先就是服务器可以提供的服务的描述,这其中涉及到服务名称和服务参数,而服务器端对客户端的描述可能包含具体的结果值和返回值描述,这个描述在网络传输过程中也需要进行序列化和反序列化,而本次考虑的序列化和反序列化方案就是利用JSON格式的字符串,例如下面的例子:
JSON | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
另外,因为函数的实现和函数的调用是分离的,那么在服务端一旦要执行客户端指定的服务就必须确保该服务是有效的,再判断客户端传递的参数是否合法,而客户端如何知道服务端提供的服务具体是如何描述的呢?这就需要服务端提供对服务的描述。那么一旦客户端知道了描述并且传入正确的参数以及服务端检测参数合法,服务端就可以调用对应的方法来执行服务得到结果返回给客户端。根据这个思路,在RPC功能模块中就需要有一张哈希表来表示指定服务和服务具体信息的映射关系,而这个服务具体信息是一个结构体,包括下面的字段:
- 服务名称
- 服务参数类型
- 服务校验函数对象
- 服务执行函数对象
但是,现在还有一个问题,前面提到的内容都是建立在客户端的请求已经传递给了对应的服务执行函数上,那么如何做到这一点呢?在前面服务分发模块提到客户端的请求最后会根据服务类型递交给对应的模块,那么假设已经确定请求类型为RPC功能请求,那么接下来需要做的任务就是将请求递交给RPC功能模块,所以在服务分发模块中key
为RPC功能模块,value
就应该是一个函数对象用来处理RPC功能请求并返回结果
服务处理模块¶
服务处理模块本质是包含了下面的功能:
- 服务注册:当一个服务启动时,需要在注册中心进行注册,确保该服务提供者可以被记录
- 服务发现:在服务注册完毕后,需要告诉客户端当前有哪些服务可以提供
- 服务上线:当一个服务器可以提供某一个新的服务时,还需要再次通知注册中心,表示自己有了新的服务
- 服务下线:当一个服务器下线时,还需要再次通知注册中心,表示自己不再提供服务
基于上面的功能,考虑设计下面的描述字段:
JSON | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
当一个服务器向注册中心注册时,此时就相当于服务提供者向注册中心发送数据,所以可以看做是一个请求,而对应的字段sevice_type
就填写RegisterService
。除了有请求以外,还存在对应的响应,这个响应相对比较简单,只需要表示当前的操作是否成功,例如:
JSON | |
---|---|
1 2 3 |
|
而对于服务发现,本质就是注册中心向客户端发送数据,所以应该看作是一个响应,在该响应中,需要描述的内容就是服务和所有可以提供该服务的服务提供者信息,例如:
JSON | |
---|---|
1 2 3 4 5 6 7 |
|
对于服务的上线和下线,请求和响应的设计与服务注册类似,只不过请求和响应的sevice_type
字段分别为OnlineService
和OfflineService
,而服务的上线和下线本质也是服务提供者向注册中心发送数据,所以可以看做是一个请求
总结来说,服务处理模块实际上就是一个处理服务操作请求的一个模块
有了请求和响应的设计,接下来看当前模块具体的实现思路
针对服务提供者,服务处理模块主要关注下面的内容:
- 方法和提供者:指的是具体方法有多少个提供者,这里需要使用一个哈希表来表示,即
unordered_map<Method, vector<Provider>>
。当服务提供者注册后,需要将该提供者插入到对应方法的vector
中并通知客户端 - 连接和提供者:指的是当前连接的服务器有多少个提供者,这里需要使用一个哈希表来表示,即
unordered_map<Connection, vector<Provider>>
。当服务提供者断开后,需要将该提供者从对应的vector
中删除并通知客户端
针对服务发现者,服务处理模块主要关注下面的内容:
- 方法和发现者:指的是具体方法有多少个发现者,这里需要使用一个哈希表来表示,即
unordered_map<Method, vector<Discoverer>>
。当服务提供者上线时,会通知所有的发现者 - 连接和发现者:指的是当前连接的服务器有多少个发现者,这里需要使用一个哈希表来表示,即
unordered_map<Connection, vector<Provider>>
。当服务发现者断开后,需要将该发现者从对应的vector
中删除
同样,为了保证服务分发模块可以执行服务处理模块的功能,需要在服务分发模块中添加一个key
为ServiceHandleServer
,value
为一个函数对象用来处理服务处理模块的请求
整个服务处理模块的设计如下图所示:
主题操作模块¶
根据前面对主题操作的介绍,下面需要针对提到的功能进行方案设计
因为创建主题、删除主题、取消主题、跟随主题和主题消息发布的功能都比较类似,所以可以考虑使用标识表示具体的操作类型,例如:
- 创建主题:
TopicCreate
- 删除主题:
TopicDelete
- 取消主题:
TopicCancel
- 跟随主题:
TopicFollow
- 主题消息发布:
TopicPublish
而客户端发送的请求和服务器返回的响应都需要包含主题名称,所以可以将主题名称作为消息的一部分,这样就可以在消息的分发过程中根据消息的类型来判断当前的消息是属于哪一种类型,然后调用对应的函数来处理
对于前四种,执行逻辑是类似的,而第五个因为发布消息还需要转发给其他服务器,所以除了保存消息外,还需要考虑转发的功能。在四种之中,因为都需要指定主题,所以客户端除了指定哪一种操作类型外,还需要指定操作的主题,这里就需要两个字段描述:topic
和topic_op
,而如果topic_op
为TopicPublish
,那么还需要指定消息的内容,所以客户端发送的请求和服务器返回的响应都需要包含这三个字段,具体的请求和响应如下:
JSON | |
---|---|
1 2 3 4 5 6 7 8 |
|
而主题操作部分不需要给客户端返回具体的执行结果,所以服务端只需要返回对应的执行状态即可,所以主题操作部分的响应如下:
JSON | |
---|---|
1 2 3 4 5 |
|
在设计完客户端和服务端之间的通信规则之后,接下来考虑客户端和服务端如何看待这些主题以及一些功能细节
对于客户端来说,客户端和主题的关系是一对多的关系,也就是说一个客户端可以同时关注多个主题,而一个主题可以被多个客户端同时关注,所以这里考虑创建两个哈希表,第一张哈希表表示客户端连接和多个主题的映射关系,即unordered_map<Connection, vector<Topic>>
,其中Topic
是一个结构体,第二张哈希表表示主题名称和多个客户端的映射关系,即unordered_map<TopicName, vector<Connection>>
。当一个客户端跟随某一个主题,就需要将该客户端连接插入到vector<Connection>
中,而一旦对应的主题有新消息发布,就需要将消息转发给所有订阅该主题的客户端。当一个客户端断开连接时,本次考虑就是类似于注销账户,所以这里就涉及到通过当前客户端跟随的主题名称查找到对应的vector<Connection>
,然后再将该客户端连接从vector<Connection>
中移除
最后,因为主题操作模块也是需要根据消息的类型来判断当前的消息是属于哪一种类型,然后调用对应的函数来处理,所以在服务分发模块中key
为主题操作模块,value
就应该是一个函数对象用来处理主题操作请求并返回结果
整个模块的设计如下图所示:
服务器模块¶
整合上面所有的模块,实现整个服务器
客户端模块介绍¶
模块总览¶
客户端主要就是处理服务端发送给客户端的请求,根据上面服务端可以提供的功能,客户端可以分为下面几个模块:
- 网络服务模块(Network):基于Muduo库实现底层网络客户端来处理服务端的响应
- 应用层协议模块(Protocol):类似服务端,此处不再具体介绍
- 请求分发模块(Dispatcher):类似服务端,只是客户端部分处理的是对不同的响应进行分发,此处不再具体介绍
- 请求管理模块(ResponseManager):对客户端的不同请求进行操作和管理
- RPC功能模块(RpcService):向上层提供RPC服务功能
- 服务处理模块(ServiceHandleClient):对服务端的服务注册、服务发现、服务上线和服务下线的响应结果进行处理
- 主题操作模块(TopicOpClient):向服务端发送主题操作请求并判断响应结果
- 客户端模块(MainClient):整合上面的所有模块,实现整个客户端
网络服务模块¶
类似服务端,此处不再具体介绍
应用层协议模块¶
类似服务端,此处不再具体介绍
请求分发模块¶
类似服务端,只是客户端部分处理的是对不同的响应进行分发,此处不再具体介绍
请求管理模块¶
在本次项目中,客户端的发送并不是阻塞式的,这就导致客户端可能多次发送多个请求,而服务端需要接收并处理这些请求给客户端返回响应,但是客户端并不知道响应对应的请求,这就需要对响应进行类型判断
另外,类似于Muduo库这种异步IO网络通信库,通常IO操作都是异步操作,即发送数据就是把数据放入发送缓冲区,但是什么时候会发送由底层的网络库来进行协调,并且也并不会提供recv
接⼝,⽽是在连接触发可读事件后,IO读取数据完成后调用处理回调进行数据处理,因此也无法直接在发送请求后去等待该条请求的响应
针对上面的两个问题需要一次解决,对于第一个问题,就是判断协议中的ReqRespID
,根据这个ID可以找到对应的请求,而可能存在多个响应但是客户端没有来得及处理,所以还需要对这个ID进行管理,这里考虑建立ID和对应响应之间的映射关系,所以需要用到一张哈希表来表示,这样上层只需要查找这张哈希表就可以判断是否有指定请求对应的响应,如果有就取出响应并进行处理
另外,在本次项目中考虑为用户提供三种发送接口:
- 阻塞式发送
- 异步式发送
- 回调式发送
所以,整个模块的设计如下图所示:
RPC功能模块¶
在RPC功能模块中,主要是实现上面在请求管理模块中提到的三种发送接口,如下图所示:
服务处理模块¶
首先,在服务端的服务处理模块提到存在服务发现的功能,而服务发现本质就是客户端在注册中心中查找当前已经提供的服务,所以在客户端部分,这个服务处理模块就是用于进行服务发现。而在前面提到,服务端部分上线时需要向注册中心发送信息表示自己已经上线并且可以提供的服务,但是服务端和服务端之间是无法建立连接的,也就是说,在该服务端中还需要提供一个客户端用于表示注册服务的处理。所以实际上客户端不仅仅是请求注册中心获取服务的,还有向服务注册中心注册服务
除了服务注册和服务发现,本次项目中还提供了服务上线和下线通知的功能,这是因为服务注册和服务发现本质只是在刚启动时查找已经准备好的服务,但是可能在运行过程中某一个服务提供者下线了某一个服务或者上线了某一个服务,这就需要在运行过程中动态处理通知需要使用服务的客户端
所以,整个过程的设计图如下:
主题操作模块¶
客户端部分的主题操作模块主要的工作就是向上层提供主题操作的接口,包括:
- 创建主题
- 删除主题
- 取消主题
- 跟随主题
- 主题消息发布
但是,除了提供主题操作的接口以外,还需要考虑主题消息发布的另外一个功能:处理收到的消息。因为创建、删除、取消、跟随都是客户端主动向服务端发送的请求,但是主题消息发布中收到主题是被动收到请求,所以客户端需要处理服务端发送给自己的消息,这就需要客户端调用指定的回调函数来处理
所以,整个模块的设计如下图所示:
客户端模块¶
整合上面所有的模块