一、JSON-RPC 简介
RPC(Remote Procedure Call)远程过程调用,是一种通过网络从远程计算机上请求服务,而不需要了解底层网络通信细节。RPC 可以使用多种网络协议进行通信,如 HTTP、TCP、UDP 等,并且在 TCP/IP 网络四层模型中跨越了传输层和应用层。简言之 RPC 就是像调用本地方法一样调用远程方法。
过程可以理解为业务处理、计算任务,更直白地说,就是程序/方法/函数等,就是像调用本地方法一样调用远程方法。
举个形象的例子:谈恋爱例子
- 本地过程调用:恋爱对象在你的身边,可以随时约对象吃饭、看电影、约会等等
- 远端过程调用:好像异地恋一样,隔着千山万水,如果想约会,需要先和对象进行约定,在坐火车/飞机赶到约定的地点
一个完整 RPC 通信框架,大概包含以下内容:
- 序列化协议
- 通信协议
- 连接复用
- 服务注册
- 服务发现
- 服务订阅和通知
- 负载均衡
- 服务监控
- 同步调用
- 异步调用
该项目是基于 C++、JsonCpp、muduo 网络库实现一个简单、易用的 RPC 通信框架,即使是不懂网络的开发者也可以很快速的上手,它实现了同步调用、异步 callback 调用、异步 future 调用、服务注册/发现,服务上线/下线以及发布订阅等功能设计。
二、技术选型
2-1 目前 RPC 的实现方案有两种
-
client 和 server 继承公共接口
- 根据 IDL(接口描述语言)定义公共接口
- 编写代码生成器根据 IDL 语言生成相关的 C++、Java 代码
- 然后我们的客户端和服务器程序共同向上继承公共接口即可
- 比如我们常用的 Protobuf、json 可以定义 IDL 接口,并生成 RPC 相关的代码
- 缺点:使用 pb 因为生成一部分代码,所以对理解不够友好;如果是 json 定义 IDL 语言需要自己编写代码生成器难度较大一点,暂不考虑这种方案
-
实现一个远程调用接口 call,然后通过传入函数名参数来调用 RPC 接口,我们采用这种实现方案
2-2 网络传输的参数和返回值怎么映射到对应的 RPC 接口上?
- 使用 protobuf 的反射机制
- 使用 C++ 模板、类型萃取、函数萃取等机制
- 使用更通用的类型,比如 JSON 类型,设计好参数和返回值协议即可
- 前两种技术难度和学习成本较高,我们使用第三种方式
2-3 网络传输怎么做?
- 原生 socket - 实现难度较大,暂不考虑
- Boost asio 库的异步通信 - 需要扩展 boost 库
- muduo 库,学习开发成本较低
2-4 序列化和反序列化
- Protobuf: 可选
- JSON:因为项目需要使用 JSON 来定义函数参数和返回值,所以我们项目中直接采用 JSON 进行序列化和反序列化
三、开发环境
- Linux(Ubuntu-22.04)
- VSCode/Vim
- g++/gdb
- Makefile
四、环境搭建
- 安装 wget(一般情况下默认会自带)
- 更换国内软件源
- 先备份原来的/etc/apt/source.list 文件
- 安装 lrzsz 传输工具
- 安装编译器 gcc/g++
- 安装项目构建工具 make
- 安装调试器 gdb
- 安装 git
- 安装 cmake
- 安装 jsoncpp
- 安装 Muduo
五、第三方库使用介绍
5-1 JsonCpp 库
5-1-1 Json 数据格式
Json 是一种数据交换格式,它采用完全独立于编程语言的文本格式来存储和表示数据。 例如:我们想表示一个同学的学生信息
Json 的数据类型包括对象,数组,字符串,数字等。
- 对象:使用花括号 {} 括起来的表示一个对象
- 数组:使用中括号 [] 括起来的表示一个数组
- 字符串:使用常规双引号 "" 括起来的表示一个字符串
- 数字:包括整形和浮点型,直接使用
5-1-2 JsonCpp 介绍
Jsoncpp 库主要是用于实现 Json 格式数据的序列化和反序列化,它实现了将多个数据对象组织成为 json 格式字符串,以及将 Json 格式字符串解析得到多个数据对象的功能。
Json 数据对象类的表示
- Json::Value 类:中间数据存储类
- 如果要将数据对象进行序列化,就需要先存储到 Json::Value 对象中;
- 如果要将数据进行反序列化,就是解析后,将数据对象放入到 Json::Value 对象中。
- Json::StreamWrite 类:用于进行数据序列化
- Json::CharReader 类:反序列化类
// jsoncpp.cpp
// 此处省略具体实现代码
六、Muduo 库
Muduo 由陈硕大佬开发,是一个基于非阻塞 IO和事件驱动的 C++ 高并发TCP 网络编程库。它是一个基于主从 Reactor 模型的网络库,其使用的线程模型是one loop per thread, 所谓 one loop per thread 指的是:
- 一个线程只能有一个事件循环(EventLoop),用于响应计时器和 IO 事件
- 一个文件描述符只能由一个线程进行读写,换句话说就是一个 TCP 连接必须归属于某个 EventLoop 管理
6-1 Muduo 库常见接口介绍
6-1-1 TcpServer 类介绍
// server.cpp
// 此处省略具体实现代码
6-1-2 EventLoop 类介绍
// _baseloop 的作用
// 在 muduo 网络库中,muduo::net::EventLoop _baseloop; 声明了一个 EventLoop 类型的对象 _baseloop,它是整个事件驱动模型的核心组件之一,主要作用如下:
// 此处省略具体实现代码
6-1-3 TcpConnection 类介绍
// client.cpp
// 此处省略具体实现代码
6-1-4 TcpClient 类介绍
// server.cpp p22 中 bind 的作用
// 此处省略具体实现代码
6-1-5 Buffer 类介绍
# makefile
# 此处省略具体实现代码
七、C++11 异步操作
7-1 std::future
std::future 是 C++11 标准库中的一个模板类,它表示一个异步操作的结果。当我们在多线程编程中使用异步任务时,std::future 可以帮助我们在需要的时候获取任务的执行结果。std::future 的一个重要特性是能够阻塞当前线程,直到异步操作完成,从而确保我们在获取结果时不会遇到未完成的操作。
std::future 本质上不是一个异步任务,而是一个辅助我们获取异步结果的东西
应用场景
- 异步任务:当我们需要在后台执行一些耗时操作时,如网络请求或计算密集型任务等,std::future 可以用来表示这些异步任务的结果。通过将任务与主线程分离,我们可以实现任务的并行处理,从而提高程序的执行效率
- 并发控制:在多线程编程中,我们可能需要等待某些任务完成后才能继续执行其他操作。通过使用 std::future,我们可以实现线程之间的同步,确保任务完成后再获取结果并继续执行后续操作
- 结果获取:std::future 提供了一种安全的方式来获取异步任务的结果。我们可以使用 std::future::get() 函数来获取任务的结果,此函数会阻塞当前线程,直到异步操作完成。这样,在调用 get() 函数时,我们可以确保已经获取到了所需的结果
std::future 并不能单独使用,需要搭配一些能够执行异步任务的模板类或函数一起使用
7-2 使用 std::async 关联异步任务
异步执行一个函数,内部会创建线程执行异步任务,返回一个 future 对象用于获取函数结果
std::async 是一种将任务与 std::future 关联的简单方法。它创建并运行一个异步任务,并返回一个与该任务结果关联的 std::future 对象。默认情况下,std::async 是否启动一个新线程,或者在等待 future 时,任务是否同步运行都取决于你给的 参数。这个参数为 std::launch 类型:
// async.cpp
// 此处省略具体实现代码
7-3 使用 std::packaged_task 和 std::future 配合
std::packaged task 类模板:是一个任务包,是对一个函数进行二次封装,封装成为一个可调用对象作为任务放到其他线程执行的。为一个函数生成一个异步任务对象 (可调用对象),用于在其他线程中执行
std::packaged_task 就是将任务和 std::feature 绑定在一起的模板,是一种对任务的封装。我们可以通过 std::packaged_task 对象获取任务相关联的 std::feature 对象,通过调用 get_future() 方法获得。std::packaged_task 的模板参数是函数签名。
可以把 std::future 和 std::async 看成是分开的,而 std::packaged_task 则是一个整体。
// packaged_task.cpp
// 此处省略具体实现代码
7-4 使用 std::promise 和 std::future 配合
std::promise 类模板:实例化的对象可以返回一个 future,在其他线程中向 promise 对象设置数据,其他线程的关联 future 就可以获取数据
std::promise 提供了一种设置值的方式,它可以在设置之后通过相关联的 std::future 对象进行读取。换种说法就是之前说过 std::future 可以读取一个异步函数的返回值了,但是要等待就绪,而 std::promise 就提供一种 方式手动让 std::future 就绪
// promise.cpp
// 此处省略具体实现代码
八、项目设计
8-1 理解项目功能
实现 rpc(远端调用) 思想上并不复杂,甚至可以说是简单,其实就是客户端想要完成某个任务的处理,但是这个处理的过程并不自己来完成,而是,将请求发送到服务器上,让服务器来帮其完成处理过程,并返回结果,客户端拿到结果后返回。
上图的模型中,是一种多对一或一对一的关系,一旦服务端掉线,则客户端无法进行远端调用,且其服务端的负载也会较高,因此在 rpc 实现中,我们不仅要实现其基本功能,还要再进一步,实现分布式架构的 rpc。
分布式架构:简单理解就是由多个节点组成的一个系统,这些节点通常指的是服务器,将不同的业务或者同一个业务拆分分布在不同的节点上,通过协同工作解决高并发的问题,提高系统扩展性和可用性。
其实现思想也并不复杂,也就是在原来的模型基础上,增加一个注册中心,基于注册中心不同的服务提供服务器向注册中心进行服务注册,相当于告诉注册中心自己能够提供什么服务,而客户端在进行远端调用前,先通过注册中心进行服务发现,找到能够提供服务的服务器,然后发起调用。
而其次的发布订阅功能,则是依托于多个客户端围绕服务端进行消息的转发。
不过单纯的消息转发功能,并不能满足于大部分场景的需要,因此会在其基础上实现基于主题订阅的转发。
基于以上功能的合并,我们可以得到一个实现所有功能的结构图。
在上图的结构中,我们甚至可以让每一个 Server 作为备用注册中心形成分布式架构,一旦一个注册中心下线,可以向备用中心进行注册以及请求,且在此基础上客户端在请求 Rpc 服务的时候,因为可以有多个 rpc-provider 可选,因此可以实现简单的负载均衡策略,且基于注册中心可以更简便实现发布订阅的功能。项目的三个主要功能:
- rpc 调用
- 服务的注册与发现以及服务的下线/上线通知
- 消息的发布订阅
8-2 框架设计
8-2-1 服务端模块划分
服务端的功能请求:
- 基于网络通信接收客户端的请求,提供 rpc 服务
- 基于网络通信接收客户端的请求,提供服务注册与发现,上线&下线通知
- 基于网络通信接收客户端的请求,提供主题操作(创建/删除/订阅/取消),消息发布
在服务端的模块划分中,基于以上理解的功能,可以划分出这么几个模块:
- Network:网络通信模块
- Protocol:应用层通信协议模块
- Dispatcher:消息分发处理模块
- RpcRouter:远端调用路由功能模块
- Publish-Subscovery:发布订阅功能模块
- Registry-Discovery:服务注册/发现/上线/下线功能模块
- Server:基于以上模块整合而出的服务端模块
1. Network
该模块为网络通信模块,实现底层的网络通信功能,这个模块本质上也是一个比较复杂庞大的模块,该模块我们将使用陈硕大佬的 Muduo 库来进行搭建。
2. Protocol
应用层通信协议模块的存在意义:解析数据,解决通信中有可能存在的粘包问题,能够获取到一条完整的消息。
在前边的 muduo 库基本使用中,我们能够知道想要让一个服务端/客户端对消息处理,就要设置一个 onMessage 的回调函数,在这个函数中对收到的数据进行应用层协议处理。
而 Protocol 模块就是是网络通信协议模块的设计,也就是在网络通信中,我们必须设计一个应用层的网络通信协议出来,以解决网络通信中可能存在的粘包问题,而解决粘包有三种方式:特殊字符间隔,定长,LV 格式。
而项目中我们将使用 LV 格式来定义应用层的通信协议格式
- Length:该字段固定 4 字节长度,用于表示后续的本条消息数据长度。
- MType:该字段为 Value 中的固定字段,固定 4 字节长度,用于表示该条消息的类型。
- Rpc 调用请求/响应类型消息
- 发布/订阅/取消订阅/消息推送类型消息
- 主题创建/删除类型消息
- 服务注册/发现/上线/下线类型消息
- IDLength:为消息中的固定字段,该字段固定 4 字节长度,用于描述后续 ID 字段的实际长度。
- MID:在每条消息中都会有一个固定字段为 ID 字段,用于唯一标识消息,ID 字段长度不固定。
- Body:消息主题正文数据字段,为请求或响应的实际内容字段。
3. Dispatcher
模块存在的意义:区分消息类型,根据不同的类型,调用不同的业务处理函数进行消息处理。
当 muduo 库底层通信收到数据后,在 onMessage 回调函数中对数据进行应用层协议解析,得到一条实际消息载荷后,我们就该决定这条消息代表这客户端的什么请求,以及应该如何处理。
因此,我们设计出了 Dispatcher 模块,作为一个分发模块,这个模块内部会保存有一个 hash_map<消息类型,回调函数>,以此由使用者来决定哪条消息用哪个业务函数进行处理,当收到消息后,在该模块找到其对应的处理回调函数进行调用即可。
消息类型:
- rpc 请求&响应
- 服务注册/发现/上线/下线请求&响应
- 主题创建/删除/订阅/取消订阅请求&响应,消息发布的请求&响应
4. RpcRouter
RpcRouter 模块存在的意义:提供 rpc 请求的处理回调函数,内部所要实现的功能,分辨出客户端请求的服务进行处理得到结果进行响应。 rpc 请求中,最关键的两个点:
- 请求方法名称
- 请求对应要处理的参数信息
在 Rpc 远端调用中,首先将客户端到服务端的通信链路打通,然后将自己所需要调用的服务名称,以及参数信息传递给服务端,由服务端进行接收处理,并返回结果。
而,不管是客户端要传递给服务端的服务名称以及参数信息,或者服务端返回的结果,都是在上边 Protocol 中定义的 Body 字段中,因此 Body 字段中就存在了另一层的正文序列化/反序列化过程。
序列化方式有很多种,鉴于当前我们是 json-rpc,因此这个序列化过程我们就初步使用 json 序列化来进行,所定义格式如下:
需要注意的是,在服务端,当接收到这么一条消息后,Dispatcher 模块会找到该 Rpc 请求类型的回调处理函数进行业务处理,但是在进行业务处理的时候,也是只会将 parameters 参数字段传入回调函数中进行处理。
然而,对服务端来说,应该从传入的 Json::Value 对象中,有什么样的参数,以及参数信息是否符合自己所提供的服务的要求,都应该有一个检测,符合要求了再取出指定字段的数据进行处理。
因此,对服务端来说,在进行服务注册的时候,必须有一个服务描述,以代码段中的 Add 请求为例,该服务描述中就应该描述:
- 服务名称:Add,
- 参数名称:num1,是一个整形
- 参数名称:num2,是一个整形,
- 返回值类型:整形
有了这个描述,在回调函数中就可以先对传入的参数进行校验,没问题了则取出指定字段数据进行处理并返回结果
基于以上理解,在实现该模块时,该有以下设计:
- 该模块必须具备一个 Rpc 路由管理,其中包含对于每个服务的参数校验功能
- 该模块必须具备一个方法名称和方法业务回调的映射
- 该模块必须向外提供 Rpc 请求的业务处理函数。
5. Publish-Subscovery
Publish-Subscribe 模块存在的意义:针对发布订阅请求进行处理,提供一个回调函数设置给 Dispatcher 模块。
发布订阅所包含的请求操作:
- 主题的创建
- 主题的删除
- 主题的订阅
- 主题的取消订阅
- 主题消息的发布
在当前的项目中,我们也实现一个简单的发布订阅功能,该功能是围绕多个客户端与一个服务端来展开的。
即,任意一个客户端在发布或订阅之前先创建一个主题,比如在新闻发布中我们创建一个音乐新闻主题,哪些客户端希望能够收到音乐新闻相关的消息,则就订阅这个主题,服务端会建立起该主题与客户端之间的联系。
当某个客户端向服务端发布消息,且发布消息的目标主题是音乐新闻主题,则服务端会找出订阅了该主题的客户端,将消息推送给这些客户端。
既然涉及到网络通信,那就先将通信消息的正文格式定义出来:
设计实现:
- 该模块必须具备一个主题管理,且主题中需要保存订阅了该主题的客户端连接 a. 主题收到一条消息,需要将这条消息推送给订阅了该主题的所有客户端
- 该模块必须具备一个订阅者管理,且每个订阅者描述中都必须保存自己所订阅的主题名称 a. 目的是为了当一个订阅客户端断开连接时,能够找到订阅信息的关联关系,进行删除
- 该模块必须向外提供 主题创建/销毁,主题订阅/取消订阅,消息发布处理的业务处理函数
6. Registry-Discovery
Registry-Discovery 模块存在的意义:就是针对服务注册与发现请求的处理。
- 服务注册/发现类型请求中的详细划分
- 服务注册:服务 provider 告诉中转中心,自己能提供哪些服务
- 服务发现:服务 caller 询问中转中心,谁能提供指定服务
- 服务上线:在一个 provider 上线了指定服务后,通知发现过该服务的客户端有个 provider 可以提供该服务
- 服务下线:在一个 provider 断开连接,通知发现过该服务的 caller,谁下线了哪个服务
服务注册模块,该模块主要是为了实现分布式架构而存在,让每一个 rpc 客户端能够从不同的节点主机上获取自己所需的服务,让业务更具扩展性,系统更具健壮性。
而为了让 rpc-caller 知道有哪些 rpc-provider 能提供自己所需服务,那么就需要有一个注册中心让这些 rpc-provider 去注册登记自己的服务,让 rpc-caller 来发现这些服务。
因此,在我们的服务端功能中,还需实现服务的注册/发现,以及服务的上线/下线功能。
该模块的设计如下: 1️⃣ 必须具备一个服务发现者的管理: a. 方法与发现者:当一个客户端进行服务发现的时候,进行记录谁发现过该服务,当有一个新的提供者上线的时候,可以通知该发现者 b. 连接与发现者:当一个发现者断开连接了,删除关联关系,往后就不需要通知了 2️⃣ 必须具备一个服务提供者的管理: a. 连接与提供者:当一个提供者断开连接的时候,能够通知该提供者提供的服务对应的发现者,该主机的该服务下线了 b. 方法与提供者:能够知道谁的哪些方法下线了,然后通知发现过该方法的客户端 3️⃣ 必须向 Dispatcher 模块提供一个服务注册/发现的业务处理回调函数
这样,当一个 rpc-provider 登记了服务,则将其管理起来,当 rpc-caller 进行服务发现时,则将保存的对应服务所对应的主机信息,响应给 rpc-caller。
而,当中途一个 rpc-provider 上线登记服务时,则可以给进行了对应服务发现的 rpc-caller 进行服务上线通知,通知 rpc-caller 当前多了一个对应服务的 rpc-provider。
同时,当一个 rpc-provider 下线时,则可以找到了进行了该服务发现的 rpc-caller 进行服务的下线通知。
7. Server
当以上的所有功能模块都完成后,我们就可以将所有功能整合起来实现服务端程序了
- RpcServer:rpc 功能模块与网络通信部分结合。
- RegistryServer:服务发现注册功能模块与网络通信部分结合
- TopicServer:发布订阅功能模块与网络通信部分结合。
8-2-2 客户端模块划分
在客户端的模块划分中,基于以上理解的功能,可以划分出这么几个模块
- Protocol:应用层通信协议模块
- Network:网络通信模块
- Dispatcher:消息分发处理模块
- Requestor:请求管理模块
- RpcCaller:远端调用功能模块
- Publish-Subscribe:发布订阅功能模块
- Registry-Discovery:服务注册/发现/上线/下线功能模块
- Client:基于以上模块整合而出的客户端模块
4. Requestor
Requestor 模块存在的意义:针对客户端的每一条请求进行管理,以便于对请求对应的响应做出合适的操作。
首先,对于客户端来说,更多的地方在于,更多时候客户端是请求方,是主动发起请求服务的一方,而在多线程的网络通信中,多线程下,针对多个请求进行响应可能会存在时序的问题,这种情况下,则我们无法保证一个线程发送一个请求后,接下来接收到的响应就是针对自己这条请求的响应,这种情况是非常危险的一种情况。
其次,类似于 Muduo 库这种异步 IO 网络通信库,通常 IO 操作都是异步操作,即发送数据就是把数据放入发送缓冲区,但是什么时候会发送由底层的网络库来进行协调,并且也并不会提供 recv 接口,而是在连接触发可读事件后,IO 读取数据完成后调用处理回调进行数据处理,因此也无法直接在发送请求后去等待该条请求的响应。
针对以上问题,我们则创建出当前的请求管理模块来解决,它的思想也非常简单,就是给每一个请求都设定一个请求 ID,服务端进行响应的时候标识响应针对的是哪个请求(也就是响应信息中会包含请求 ID),因此客户端这边我们不管收到哪条请求的响应,将数据存储入一则 hash_map 中,以请求 ID 作为映射,并向外提供获取指定请求 ID 响应的阻塞接口,这样只要在发送请求的时候知道自己的请求 ID,那么就能获取到自己想要的响应,而不会出现异常。
针对这个思想,我们再进一步,可以将每个请求进一步封装描述,加入异步的 future 控制,或者设置回调函数的方式,在不仅可以阻塞获取响应,也可以实现异步获取响应以及回调处理响应。
5. RpcCaller
RpcCaller 模块存在的意义:向用户提供进行 rpc 调用的模块。
Rpc 服务调用模块,这个模块相对简单,只需要向外提供几个 rpc 调用的接口,内部实现向服务端发送请求,等待获取结果即可,稍微麻烦一些的是 Rpc 调用我们需要提供多种不同方式的调用:
- 同步调用:发起调用后,等收到响应结果后返回
- 异步调用:发起调用后立即返回,在想获取结果的时候进行获取
- 回调调用:发起调用的同时设置结果的处理回调,收到响应后自动对结果进行回调处理
6. Publish-Subscribe
Publish-Subscribe 模块存在意义:向用户提供发布订阅所需的接口,针对推送过来的消息进行处理。发布订阅稍微能复杂一丢丢,因为在发布订阅中有两种角色,一个客户端可能是消息的发布者,也可能是消息的订阅者。而且不管是哪个角色都是对主题进行操作,因此其中也包含了主题的相关操作,比如,要发布一条消息需要先创建主题。且一个订阅者可能会订阅多个主题,每个主题的消息可能都会有不同的处理方式,因此需要有订阅者主题回调的管理。
7. Registry-Discovery
服务注册和发现模块需要实现的功能会稍微复杂一些,因为分为两个角色来完成其功能 1️⃣ 注册者:作为 Rpc 服务的提供者,需要向注册中心注册服务,因此需要实现向服务器注册服务的功能 2️⃣ 发现者:作为 Rpc 服务的调用者,需要先进行服务发现,也就是向服务器发送请求获取能够提供指定服务的主机地址,获取地址后需要管理起来留用,且作为发现者,需要已关注注册中心发送过来的服务上线/下线消息,以及时对已经下线的服务和主机进行管理。
8. Client
将以上模块进行整合就可以实现各个功能的客户端了。
- RegistryClient:服务注册功能模块与网络通信客户端结合
- DiscoveryClient:服务发现功能模块与网络通信客户端结合
- RpcClient:DiscoveryClient & RPC 功能模块与网络通信客户端结合
- TopicClient:发布订阅功能模块与网络通信客户端结合
框架设计
在当前项目的实现中,我们将整个项目的实现划分为三层来进行实现 1️⃣ 抽象层:将底层的网络通信以及应用层通信协议以及请求响应进行抽象,使项目更具扩展性和灵活性。 2️⃣ 具象层:针对抽象的功能进行具体的实现。 3️⃣ 业务层:基于抽象的框架在上层实现项目所需功能。
8-2-3 抽象层
在咱们的项目实现中,网络通信部分采用了第三方库 Muduo 库,以及通信协议使用了 LV 格式的通信协议解决粘包问题,数据正文中采用了 Json 格式进行序列化和反序列化,而这几方面我们都可能会存在继续优化的可能,甚至在序列化方面不一定要采用 Json,因此在设计项目框架的时候,我们对于底层通信部分相关功能先进行抽象,形成一层抽象层,而上层业务部分根据抽象层来完成功能,这样的好处是在具体的底层功能实现部分,我们可以实现插拔式的模块化替换,以此来提高项目的灵活性和扩展性。
8-2-4 具象层
具象层就是针对抽象的具体实现。
而具体的实现也比较简单,从抽象类派生出具体功能的派生类,然后在内部实现各个接口功能即可。
- 基于 Muduo 库实现网络通信部分抽象
- 基于 LV 通信协议实现 Protocol 部分抽象
不过这一层中比较特殊的是,我们需要针对不同的请求,从 BaseMessage 中派生出不同的请求和响应类型,以便于在针对指定消息处理时,能够更加轻松的获取或设置请求及响应中的各项数据元素。
8-2-5 业务层
业务层就是基于底层的通信框架,针对项目中具体的业务功能的实现了,比如 Rpc 请求的处理,发布订阅请求的处理以及服务注册与发现的处理等等。
🌟整体框架设计🌟
九、项目实现
9-1 常用的零碎功能实现
9-1-1 简单日志宏实现
意义:快速定位程序运行逻辑出错的位置。
项目在运行中可能会出现各种问题,出问题不可怕,关键的是要能找到问题,并解决问题。
解决问题的方式:
- gdb 调试:逐步调试过于繁琐,缓慢。主要用于程序崩溃后的定位。
- 系统运行日志分析:在任何程序运行有可能逻辑错误的位置进行输出提示,快速定位逻辑问题的位置。
9-2 Json 序列化/反序列化
9-3 UUID 生成
UUID(Universally Unique Identifier), 也叫通用唯一识别码,通常由 32 位 16 进制数字字符组成。UUID 的标准型式包含 32 个 16 进制数字字符,以连字号分为五段,形式为 8-4-4-4-12 的 32 个字符,如:550e8400-e29b-41d4-a716-446655440000。在这里,uuid 生成,我们采用生成 8 个随机数字,加上 8 字节序号,共 16 字节数组生成 32 位 16 进制字符的组合形式来确保全局唯一的同时能够根据序号来分辨数据。
// detail.hpp
// 核心设计思路:此处省略具体实现代码
9-2 项目消息类型字段信息定义
9-2-1 请求字段宏定义
- 消息 ID
- 消息类型
- 消息正文
- Rpc 请求:方法名称,方法参数
- 发布订阅相关请求:主题名称,操作类型,主题消息
- 服务操作相关请求:方法名称,操作类型,主机信息 (IP 地址,PORT 端口)
- 响应码
- Rpc 响应:调用结果
9-2-2 消息类型定义
- Rpc 请求 & 响应
- 主题操作请求 & 响应
- 消息发布请求 & 响应
- 服务操作请求 & 响应
9-2-3 响应码类型定义
- 成功处理
- 解析失败
- 消息中字段缺失或错误导致无效消息
- 连接断开
- 无效的 Rpc 调用参数
- Rpc 服务不存在
- 无效的 Topic 操作类型
- 主题不存在
- 无效的服务操作类型
9-2-4 RPC 请求类型定义
- 同步请求:等待收到响应后返回
- 异步请求:返回异步对象,在需要的时候通过异步对象获取响应结果(还未收到结果会阻塞)
- 回调请求:设置回调函数,通过回调函数对响应进行处理
9-2-5 主题操作类型定义
- 主题创建
- 主题删除
- 主题订阅
- 主题取消订阅
- 主题消息发布
9-2-6 服务操作类型定义
- 服务注册
- 服务发现
- 服务上线
- 服务下线
// fields.hpp
// 此处省略具体实现代码
9-3 通信抽象实现
BaseMessage, BaseBuffer, BaseProtocol, BaseConnection, BaseServer, BaseClient
逐个核心抽象类介绍
// abstract.hpp
// 此处省略具体实现代码
9-4 消息具体实现
JsonMessage, JsonRequest & JsonResponse, RpcRequest & RpcResponse, TopicRequest & TopicResponse, ServiceRequest & ServiceResponse
// message.hpp
// 此处省略具体实现代码
9-5 通信-Muduo 封装实现
防备一种情况:缓冲区中数据有很多很多。但是因为数据错误,导致数据又不足一条完整消息,也就是一条消息过大,针对这种情况,直接关闭连接
MuduoBuffer, MuduoProtocol, MuduoConnection, MuduoServer, MuduoClient
// net.hpp
// 整体调用流程:此处省略具体实现代码
9-6 消息-不同消息封装实现
JsonRequest, RpcRequest, TopicRequest, ServiceRequest JsonResponse, RpcResponse, TopicResponse, ServiceResponse
9-7 服务端-Dispatcher 实现
服务端会收到不同类型的请求,客户端会收到不同类型的响应(因为请求和响应都具有多样性因此在回调函数中,就需要判断消息类型,根据不同类型的消息做出不同的处理;如果单纯使用 if 语句做分支处理,是一件非常不好的事
程序设计中需要遵守一个原则:开闭原则 --- 对修改关闭,对扩展开放
当后期维护代码或新增功能时:不去修改以前的代码,而是新增当前需要的代码
Dispatcher 模块就是基于开闭原则设计的目的就是建立消息类型与业务回调函数的映射关系;如果后期新增功能,不需要修改以前的代码,只需要增加一个映射关系即可
// Dispatcher.hpp
// 注册消息类型 - 回调函数映射关系
// 提供消息处理接口
// 此处省略具体实现代码
9-8 服务端-RpcRouter 实现
提供 Rpc 请求处理回调函数,内部的服务管理(方法名称,参数信息,对外提供参数校验接口)
在 rpc 请求中,可能会有大量不同的 rpc 请求:比如加法,翻译... 作为服务端,首先要对自己所能提供的服务进行管理,以便于收到请求后,能够明确判断自身能否提供客户端所请求的服务。
能提供服务,则调用接口进行处理,返回结果;不能提供服务,则响应客户端请求的服务不存在。
RpcRouter 模块:一共枚举四个类
- 枚举类:枚举出 rpc 请求参数的类型(布尔,整形,浮点型,浮点数,字符串,数组,对象)
- 服务描述类:
- 业务回调函数 --- Add 处理回调函数
- 参数信息描述 --- pair<参数字段名称,参数字段类型> {<'num1',int>,<'num2',int>}
- 返回值类型描述 --- int
- 提供参数校验接口 --- 针对请求中的参数,判断是否包含有 num1 字段,其类型是否是整形;处理逻辑:收到一个 rpc 请求后,取出方法名称,参数信息;通过方法名称 Add,找到 Add 服务的描述对象,先进行参数校验,校验参数中是否有 num1 字段,且类型是整形... 判断都没问题则调用回调函数进行处理。
- 服务管理类:服务端会提供很多方法服务,需要进行良好的管理
- std::hash_map<方法名称,服务描述> 通过这个 hash_map 就可以很容易判断能否提供服务
- 对外 RpcRouter 类:服务注册接口;提供给 dispatcher 模块的 rpc 请求处理回调函数
// rpc_router.hpp
// 步骤拆解与价值
// 核心流程拆解(onRpcRequest 方法):此处省略具体实现代码
// 补充:整体架构中的定位:此处省略具体实现代码
9-9 服务端-Publish&Subscribe 实现
对外提供主题操作处理回调函数,对外提供消息发布处理回调函数,内部进行主题及订阅者的管理
// rpc_topic.hpp
// 核心设计亮点:此处省略具体实现代码
9-10 服务端-Registry&Discovery 实现
- 为什么要注册服务,服务注册是要做什么❓ 服务注册是要实现分布式系统,让系统更加强壮;一个节点主机,将自己所能提供的服务,在注册中心进行登记。
- 为什么要服务发现,服务发现要做什么❓ rpc 调用者需要知道哪个节点主机能够为自己提供指定服务;服务发现其实就是询问注册中心,谁能为自己提供指定的服务,将节点信息给保存起来以待后用。
- 服务下线 当前使用长连接进行服务主机是否在线的判断,一旦服务提供方断开连接,查询这个主机提供了哪些服务,分析哪些调用者进行过这些服务发现,则进行服务下线通知。
- 服务上线 因为服务发现是一锤子买卖(调用方不会进行二次服务发现),因此一旦中途有新主机可以提供指定服务,调用方是不知道的;因此,一旦某个服务上线了,则对发现过这个服务的主机进行一次服务上线通知。
服务端要能够提供服务注册,发现的请求业务处理需要将 哪个服务 能够由 哪个主机提供 管理起来 hash<method, vector> 实现当由 caller 进行服务发现的时候,告诉 caller 谁能提供指定的服务需要将 哪个主机 发现过 哪个服务 管理起来 当进行服务通知的时候,都是根据谁发现过这个服务,才会给谁通知 <method,vector>需要将 哪个连接 对应哪个 服务提供者 管理起来 hash<conn, provider> 当一个连接断开的时候,能够知道哪个主机的哪些服务下线了,然后才能给发现者通知 xxx 的 xxx 服务下线了需要将 哪个连接 对应哪个 服务发现者 管理起来 hash<conn, discoverer> 当一个连接断开的时候,如果有服务上线下线,就不需要给他进行通知了
// rpc_registry.hpp
// 数据结构设计:适配核心场景的高效映射
// 与 ProviderManager 的协同关系
// 两者配合实现了「服务注册 - 发现 - 动态感知」的完整闭环:提供者注册 / 下线 → ProviderManager 处理数据 → 触发 DiscovererManager 推送通知 → 客户端感知服务变化。
// 此处省略具体实现代码
9-11 服务端-整合封装 Server
三大核心组件的协同关系
// rpc_server.hpp
// 核心工作流程:此处省略具体实现代码
9-12 客户端-Requestor 实现
提供发送请求的接口,内部进行请求&响应的管理
Requestor 是 RPC 客户端的核心请求管理器,核心作用是统一封装 RPC 请求的发送逻辑,解决'请求 - 响应精准关联'问题,并支持同步、异步(带回调/不带回调)三种调用方式,是客户端实现灵活 RPC 调用的核心组件。
简单来说,它的核心使命是:让客户端可以用「同步阻塞、异步 Future、异步回调」任意一种方式发送 RPC 请求,并在服务端响应返回时,自动把响应分发到对应的请求处理逻辑中(比如唤醒阻塞的同步调用、执行回调函数、给 Future 赋值)。
// requestor.hpp
// 此处省略具体实现代码
9-13 客户端-RpcCaller 实现
提供 Rpc 请求接口
RpcCaller 是 RPC 客户端的业务层适配封装类,核心作用是基于底层的 Requestor 封装更易用、更贴合业务的 JSON 级 RPC 调用接口,屏蔽框架层 BaseMessage/RpcRequest/RpcResponse 等细节,让业务代码无需关注 RPC 通信的底层实现,只需聚焦'方法名、JSON 参数、JSON 结果'的业务逻辑。
简单来说,它是客户端业务代码与底层 RPC 通信框架之间的'适配层'——把框架层的'消息级调用'转换为业务层的'JSON 级调用',大幅降低业务开发的成本。
核心作用拆解
- 屏蔽底层通信细节,简化业务调用
Requestor 处理的是通用的 BaseMessage 类型(框架层消息),而业务层只关心'调用哪个方法、传什么 JSON 参数、拿什么 JSON 结果'。RpcCaller 做了以下封装:
- 请求自动封装:业务层传入 method(方法名)和 params(JSON 参数),RpcCaller 自动构建 RpcRequest 消息(设置唯一 ID、消息类型、方法名、参数),无需业务代码手动处理消息构造;
- 响应自动解析:把 Requestor 返回的 BaseMessage 响应,自动转换为 RpcResponse,提取其中的 JSON 结果(result),并处理类型转换、状态码校验等框架级逻辑;
- 异常统一处理:集中处理'消息类型转换失败、响应状态码错误'等框架级异常,输出标准化日志,业务层只需判断调用是否成功,无需处理底层异常。
- 统一封装三种 RPC 调用方式(适配业务场景) 基于 Requestor 的同步/异步能力,封装出业务层更易理解的三种调用方式,且所有接口都以 JSON 为数据载体:
- 适配层核心:完成'框架层↔业务层'的转换
RpcCaller 的核心逻辑是'类型转换 + 回调适配',解决框架层与业务层的数据类型不匹配问题:
- 请求侧:把业务层的'方法名+JSON 参数'→框架层的 RpcRequest 消息;
- 响应侧:把框架层的 BaseMessage 响应→业务层的 JSON 结果;
- 回调侧:把业务层的'JSON 回调'→框架层的 BaseMessage 回调(通过 Callback1),把业务层的'JSON Future'→框架层的 BaseMessage 回调(通过 Callback)。
- 依赖注入设计,保证灵活性 构造函数接收 Requestor::ptr 作为参数(依赖注入),而非内部创建:解耦 RpcCaller 与 Requestor 的实例化逻辑,便于测试(如注入 Mock 版 Requestor);复用 Requestor 的核心能力(请求 - 响应关联、多调用方式支持),无需重复开发。
// rpc_caller.hpp
// 此处省略具体实现代码
9-14 客户端-Publish&Subscribe 实现
// rpc_topic.hpp
// 此处省略具体实现代码
9-15 客户端-Registry&Discovery 实现
客户端的功能比较分离,注册端跟发现端根本就不在同一个主机上。因此客户端的注册与发现功能是完全分离的
作为服务提供者 --- 需要一个能够进行服务注册的接口,连接注册中心,进行服务注册, 作为服务发现者 --- 需要一个能够进行服务发现的接口,需要将获取到的能够提供指定服务的主机信息管理起来 hash<method, vector> 一次发现,多次使用,没有的话再次进行发现。需要进行服务上线/下线通知请求的处理(需要向 dispatcher 提供一个请求处理的回调函数)
因为客户端在一次服务发现中,会一次获取多个能够提供服务的 主机地址信息,到底请求谁合理?负载均衡的思想:RR 轮转(一个一个请求,雨露均沾)
// rpc_registry.hpp
// 业务场景中的定位
// 在整个服务治理体系中,Provider 类的角色是「客户端侧的注册执行者」:
// 服务提供者业务代码 → 调用 Provider::registryMethod → 发送注册请求 → 服务治理节点(PDManager)→ 返回响应 → Provider 校验响应 → 告知业务代码注册结果
// 此处省略具体实现代码
9-16 客户端-整合封装 Client
// rpc_client.hpp
// 此处省略具体实现代码
9-17 整合封装 RpcServer & RpcClient
十、测试
test_1
test_client.cpp
test_server.cpp
- 完整运行时序(按时间线)
test_2
rpc_client.cpp
rpc_server.cpp
registry_server.cpp
一、先明确整体架构与核心角色
这三份代码构成了「微服务 RPC 调用的最小闭环」,核心角色分工如下:
四、完整运行时序(按时间线)
test_3
publish_client.cpp
subscribe_client.cpp
server.cpp
一、先明确整体架构与核心角色
这三份代码构成了 bitrpc 框架 Pub/Sub 模式的最小闭环,核心角色分工如下:
四、完整运行时序(按时间线)
🌟发布订阅🌟
🌟服务注册与发现🌟
🌟总结🌟
🌟扩展🌟
附录
1.工厂模式
工厂模式是创建型设计模式的核心之一,其核心思想是'封装对象的创建过程',通过一个统一的'工厂'类或方法来生成目标对象,而非让客户端直接使用 new 关键字创建。这样可以降低客户端与具体产品类的耦合度,提高代码的可扩展性和可维护性。
-
三种核心形式(对比梳理,重点记适用场景)
-
两种枚举类型(对比梳理,重点记推荐用法)
2.完美转发回顾
'完美转发(Perfect Forwarding)'是 C++11 及后续版本中,借助右值引用和模板参数推导,实现的一种能让函数模板'精准传递'参数值类别(左值/右值)的技术。它的核心目的是:让模板函数接收到的参数,能以和原始调用时完全一致的'值类别',传递给内部调用的其他函数,避免不必要的拷贝或移动,同时保留参数的'左值/右值'属性。
3.建造者模式
二、建造者模式的核心角色(用「定制电脑」举例,秒懂)
用'定制电脑'这个典型场景对应每个角色,你能直观理解各角色的作用:
四、建造者模式 vs 工厂模式(核心差异对比,重点记)
这是最容易混淆的点,用表格清晰区分核心维度:
4.异步响应与事件驱动
一、核心概念:一句话讲透
5.dynamic_pointer_cast
五、dynamic_pointer_cast vs 其他指针转换(对比表)
6.服务端中 rpc_topic.hpp 中为什么既要用结构体锁,又要用全局锁?
四、两者的职责边界(清晰对比表)
7.使用 lambda 解决 bind 参数不匹配问题/bind 的理解
四、Lambda vs std::bind(核心优势对比)


