跳转至

项目介绍与准备

约 4391 个字 117 行代码 6 张图片 预计阅读时间 16 分钟

何为RPC

RPC(Remote Procedure Call)远程过程调用,是一种通过网络从远程计算机上请求服务,而不需要了解底层网络通信细节。RPC可以使用多种网络协议进行通信,如HTTP、TCP、UDP等,并且在TCP/IP网络四层模型中跨越了传输层和应用层,如下图所示:

功能介绍

在本次实现的项目中,首先就是要保证RPC本身的功能:本地调用远程方法。换句话说,客户端并不需要自己去实现某一个具体方法,而是通过调用服务端的方法来得到需要的结果,这个过程中也可以涉及到参数的传递,如下图所示:

虽然上面的过程已经基本实现了RPC的功能,但是还需要考虑一些其他的问题,例如:

  1. 客户端如何确定有哪些服务器可以提供当前需要的服务
  2. 客户端已经知道有一些服务器可以提供当前需要的服务,但是这些服务器可能已经下线了,此时客户端如何确定哪些服务器已经下线,哪些服务器还可以提供当前需要的服务

针对上面的两个问题,在本次项目中会考虑下面的解决方案:

  1. 针对第一个问题:提供一个服务发现客户端和服务注册中心,当客户端连接到服务注册中心时,需要向服务注册中心发送针对指定服务的服务发现请求,服务注册中心会返回当前有哪些服务器可以提供当前客户端需要的服务
  2. 针对第二个问题:提供一个注册中心,当有一个服务器第一次上线时需要向注册中心发起注册请求,而在整个服务过程中,有可能存在某一个可以提供客户端需要的服务的服务端上线了,对于这个过程来说,只有注册功能还不足,还需要提供一个服务上线通知的功能,一旦某一个需要的服务对应的服务端上线,就可以通知发现过对应服务的发现者,同样如果是下线,也是同样的步骤

这样就实现了一个分布式架构的RPC。另外,基于这个服务器也可以实现负载均衡的功能,例如当一台服务器压力过大时可以将请求转发给其他拥有相同服务的服务器来处理,如下图所示:

在上图中可以看到,当客户端需要请求某一个服务时,首先需要先请求服务注册中心,而不是直接请求提供服务的服务,接着服务注册中心会告诉客户端有哪些服务器可以提供客户端需要的服务,客户端收到可以提供服务的服务器IP地址和端口号之后就可以请求对应的服务器来处理请求

除了上面的RPC功能外,本次项目中还考虑实现一个发布订阅的功能,简单来说就是允许客户端向服务端发送一个内容,服务端再将内容发布给其他客户端。从这个功能可以看出,客户端发送给服务端的内容需要被服务端保存,如果直接将客户端的内容存储到服务端,那么就会存在一台服务器上有各种各样的内容,查找起来也会非常麻烦,所以可以考虑设计一个主题(topic)功能,在这个功能中客户端并不被允许直接发送消息,而必须是先创建一个主题,再向该主题发送消息,而为了实现服务端将内容发布给客户端,本次还提供了主题订阅功能,只有订阅了同一个服务的客户端才能收到对应发布的消息。整体功能如下图所示:

基于上面的功能,现在将上面的功能进行合并,如下图所示:

综上所述,本次项目需要实现的功能如下:

  1. 基本RPC功能
  2. 服务的注册与发现以及服务的下线/上线通知
  3. 主题功能(包括创建、删除、订阅、取消订阅和主题消息发布)

基础模块

模块总览

  1. 网络服务模块(Network):基于Muduo库实现底层网络服务器来处理客户端的请求
  2. 应用层协议模块(Protocol):因为TCP传输是面向字节流的,所以在大量的网络通信时必然会存在粘包问题,但是TCP本身无法解决这个问题,因为尽管是粘包,它也是一个数据,在TCP看来都属于有效载荷,但是这个数据是否是否是有效数据需要应用层来进行判断,所以就需要这个协议模块来处理这个问题。这一点在序列化和反序列化与网络计算器部分也有所提及
  3. 消息分发模块(Dispatcher):这个模块是用来针对不同的请求/响应类型调用不同的模块来处理任务
  4. 日志模块(Log):用于整个项目中的日志记录

网络服务模块

基于Muduo库设计,,对于Muduo库的介绍和使用可以参考关于Muduo库,关于Muduo库具体的设计原理,可以参考仿Muduo库高并发服务器

应用层协议模块

在协议设计中,常见有三种协议设计方案:

  1. 通过特殊字符分割不同的数据:这个方案最大的缺点就在于特殊字符可能存在于消息中,这就需要对数据中的特殊字符进行转义,这个过程相对复杂,本次不考虑
  2. 通过固定长度限制每一条消息的最大大小:这个方案最大的缺点就在于消息的长度是固定的,这就导致如果消息比较短的话就会浪费空间,本次不考虑
  3. 采用Length-Value协议:通过长度字段来标记每一条消息的长度,这个方案的优点就在于可以灵活的控制消息的长度,本次采用这个方案

根据Length-Value协议的设计思想,本次设计的协议如下:

  1. 有效数据的总长度(TotalLength):大小为4字节,用于表示包括后续内容的长度,根据这个长度可以做到多个不同请求之间的拆分
  2. 请求类型(RequestType):大小为4字节,用于表示当前请求的类型,例如:RPC请求、服务注册请求、发布订阅请求
  3. 请求/响应ID长度(IDLength):大小为4字节,用于表示消息ID的长度
  4. 请求/响应ID(ReqRespID):大小为IDLength字节,用于表示消息的ID,这个ID用于标记一类请求和应答,因为客户端不会在收到上一条请求的应答之后才发送下一次请求,而是可能多次发送多个请求,而对于服务器来说,当其处理完毕返回给客户端应答时,客户端就需要知道这个应答是属于哪一个请求的。根据这个需求,这个ID必须是唯一的,所以本次采用UUID来作为消息ID,而UUID一般是一个字符串。即使是字符串,哪怕是固定的,为了保证之后的扩展性,依旧将其设计为可变长度,描述长度的字段就是IDLength
  5. 有效数据(Body):大小为总长度减去其他字段的长度,用于表示具体的请求或者响应内容

整个协议的设计如下图所示:

消息分发模块

在本次项目中,消息分发模块基本设计思路为:根据消息类型注册对应的回调函数,当接收到客户端或者服务端发送的数据时,根据消息类型调用对应的回调函数来处理请求。所以在这个模块中需要有一个哈希表来建立不同的消息类型和不同的回调函数之间的映射关系,而对应的还需要两个函数表示注册和调用回调函数,既registerServiceexecuteService

服务端模块介绍

模块总览

本次实现的项目中服务端主要分为下面几个模块:

  1. RPC功能模块(RpcRouter):这个模块用来实现RPC的基本功能
  2. 服务注册模块(RpcRegistry):这个模块用来实现服务注册与发布、上线与下线通知的功能
  3. 主题操作模块(RpcTopic):这个模块用来实现主题操作功能的,其中主题操作包括:创建主题、删除主题、取消主题、跟随主题和主题消息发布的功能
  4. 服务器模块(MainServer):这个模块用来封装上面提到的所有功能模块对应的服务器,简化上层创建服务器的过程

RPC功能模块

在RPC模块中,主要就是针对客户端发送的RPC服务请求做出RPC服务的响应,但是这个过程中涉及一些问题

首先是客户端和服务端都必须确保可以识别到彼此发送的信息,这就涉及到协议的定制,在上面的应用层协议模块中已经设计好了通用格式,但是对于RPC功能来说,还需要额外的字段来描述具体的服务,即在应用层协议的正文字段中还需要进行协议设计。设计思路如下:

  1. 客户端发送的请求:包含服务名称和服务参数
  2. 服务端返回的响应:包含服务执行结果(只在执行结果状态码为正常时存在)和执行结果的状态码

根据上面的设计,用JSON字符串表示如下:

JSON
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// RPC-request
{
    "method" : "Add",
    "parameters" : 
    { 
        "num1" : 11, 
        "num2" : 22 
    }
}

// RPC-response
{
    "rcode" : OK,
    "result": 33
}
{
    "rcode" : ERROR_INVALID_PARAMETERS
}

有了协议的设计之后,服务端就需要考虑对客户端发送的请求进行解析和判断,确保客户端发送的请求体是合法的,一旦判断客户端请求的服务是合法的,就可以调用上层设置的处理回调函数进行请求处理并根据处理结果返回给客户端响应

服务注册模块

首先需要明白,当前是服务端模块,所以当前的服务模块就是设计注册中心,根据前面对注册中心的描述,注册中心需要提供下面的功能:

  1. 服务注册:当一个服务启动时,需要向当前注册中心注册,确保该服务提供者可以被记录
  2. 服务发现:在服务注册完毕后,需要告诉客户端当前有哪些服务可以提供,即通知服务发现者
  3. 服务上线:当有一个新的服务端上线时,一旦注册到注册中心,就需要通知已经发现过当前服务器可以提供的服务对应的客户端
  4. 服务下线:当一个服务端下线时,注册中心需要通知已经发现过当前服务器可以提供的服务对应的客户端

根据上面的功能描述,下面就需要考虑如何设计客户端的请求协议格式和服务端的响应协议格式:

JSON
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 服务提供者注册请求
{
    // 服务类型
    "sevice_type" : "Service_register",
    // 服务名称
    "method" : "add",
    // 服务提供者信息
    "provider_info" : {
        // 服务器IP地址
        "ip" : "127.0.0.1",
        // 服务器端口号
        "port" : 8000,
    }
}
// 服务提供者注册响应
{
    "rcode" : "RCode_fine"
}
// 发现者客户端发起服务发现请求
{
    // 服务类型
    "sevice_type" : "Service_discover",
    // 服务名称
    "method" : "add",
}
// 注册中心返回服务发现响应
{
    // 服务类型
    "sevice_type" : "Service_discover",
    // 服务名称
    "method" : "add",
    // 服务提供者信息
    "provider_info" : [
        {
            // 服务器IP地址
            "ip" : "127.0.0.1",
            // 服务器端口号
            "port" : 8000,
        },
        {
            // 服务器IP地址
            "ip" : "127.0.0.1",
            // 服务器端口号
            "port" : 7000,
        }
    ]
}
// 服务上线请求
{
    // 服务类型
    "sevice_type" : "Service_online",
    // 服务名称
    "method" : "add",
    // 服务提供者信息
    "provider_info" : {
        // 服务器IP地址
        "ip" : "127.0.0.1",
        // 服务器端口号
        "port" : 8000,
    }
}
// 服务下线请求
{
    // 服务类型
    "sevice_type" : "Service_offline",
    // 服务名称
    "method" : "add",
    // 服务提供者信息
    "provider_info" : {
        // 服务器IP地址
        "ip" : "127.0.0.1",
        // 服务器端口号
        "port" : 8000,
    }
}

当一个服务器向注册中心注册时,此时就相当于服务提供者向注册中心发送数据,所以可以看做是一个请求,而对应的字段sevice_type就填写Service_register。除了有请求以外,还存在对应的响应,这个响应相对比较简单,只需要表示当前的操作是否成功,即:

JSON
1
2
3
{
    "rcode" : "OK"
}

而对于服务发现,客户端给注册中心发送服务发现请求,对应地注册中心返回给客户端针对指定的服务返回可以提供该服务的所有服务提供者主机信息,在该响应中,需要描述的内容就是服务和所有可以提供该服务的服务提供者信息,即:

JSON
1
2
3
4
5
6
7
8
{
    "sevice_type" : "Service_discover",
    "method": "Add",
    "providers": [
        {"ip" : "127.0.0.1","port" : 9090},
        {"ip" : "127.0.0.2","port" : 8080}
    ]
}

对于服务的上线和下线,请求和响应的设计与服务注册类似,只不过请求和响应的sevice_type字段分别为Service_onlineService_offline,而服务的上线和下线本质也是服务提供者客户端向注册中心发送数据,所以可以看做是一个请求

总结来说,服务处理模块实际上就是一个处理服务操作请求的一个模块

主题操作模块

根据前面对主题操作的介绍,一共涉及到5个操作,针对这5个操作给出相应的描述字段:

  1. 创建主题:Topic_create
  2. 删除主题:Topic_remove
  3. 取消主题:Topic_subscribe
  4. 跟随主题:Topic_cancel
  5. 主题消息发布:Topic_publish

而客户端发送的请求和服务器返回的响应都需要包含主题名称,所以可以将主题名称作为消息的一部分,这样就可以在消息的分发过程中根据消息的类型来判断当前的消息是属于哪一种类型,然后调用对应的函数来处理

对于前四种,执行逻辑是类似的,而第五个操作需要将消息分发给所有订阅了指定主题的客户端,所以还需要考虑转发的功能。在四种之中,因为都需要指定主题,所以客户端除了指定哪一种操作类型外,还需要指定操作的主题,这里就需要两个字段描述:topictopic_op,而如果topic_opTopicPublish,那么还需要指定消息的内容,所以客户端发送的请求和服务器返回的响应都需要包含这三个字段,具体的请求和响应如下:

JSON
1
2
3
4
5
6
7
8
// 客户端发送的请求
// TopicRequest
{
    "topic" : "test",
    "topic_op" : "Topic_create"/"Topic_remove"/"Topic_cancel"/"Topic_subscribe"/"Topic_publish",
    // 下面的内容只有当topic_op为TopicPublish时才需要
    "message": "hello world"
}

而主题操作部分不需要给客户端返回具体的执行结果,所以服务端只需要返回对应的执行状态即可,所以主题操作部分的响应如下:

JSON
1
2
3
4
5
// 服务器返回的响应
// TopicResponse
{
    "rcode": "OK"
}

服务器模块

根据每个功能模块,进行服务器封装

客户端模块介绍

模块总览

客户端主要就是处理服务端发送给客户端的请求,根据上面服务端可以提供的功能,客户端可以分为下面几个模块:

  1. 请求管理模块(Requestor):这个模块用来封装请求接口,并正确处理收到的响应
  2. RPC功能模块(RpcCaller):向服务端提供发起RPC请求
  3. 服务注册模块(RpcRegistry):向服务端发起服务注册和服务发现请求
  4. 主题操作模块(RpcTopic):向服务端发送主题操作请求并判断响应结果
  5. 客户端模块(MainClient):根据上面的功能模块进行客户端封装

请求管理模块

在前面应用层协议模块中提到一个字段:请求/响应ID,这个ID的作用是区分不同的请求,确保客户端可以正确地知道哪一个响应对应着哪一个请求,而为了处理这个问题,就存在Requestor模块,这个模块的作用就是封装请求发送接口,并且收到响应时判断响应ID是否匹配于某一个请求ID,如果存在说明存在对应的请求,接着调用需要的处理函数处理响应

RPC功能模块

实现发起RPC请求的接口

服务注册模块

这个模块不仅仅包含服务发现者客户端,还包含服务提供者客户端。实际上服务提供者既存在一个服务端,还存在一个客户端,这个客户端就是服务提供者客户端,该客户端会向注册中心发送服务注册请求并获取到对应的响应判断注册结果是否正常,对应地,服务发现客户端就需要发起服务发现请求,并处理服务发现响应,即保存所有可以提供需要的服务的主机信息

主题操作模块

客户端部分的主题操作模块主要的工作就是向上层提供主题操作的接口,包括:

  1. 创建主题
  2. 删除主题
  3. 取消主题
  4. 跟随主题
  5. 主题消息发布

但是,除了提供主题操作的接口以外,还需要考虑主题消息发布的另外一个功能:处理收到的消息。因为创建、删除、取消、跟随都是客户端主动向服务端发送的请求,但是主题消息发布中收到主题是被动收到请求,所以客户端需要处理服务端发送给自己的消息,这就需要客户端调用指定的回调函数来处理

客户端模块

根据上面的功能模块,进行客户端封装