目录
一、前言
在谈分布式网络通信框架之前,我们要先从单机服务器开始聊起,单机服务器就是 一个独立运行的应用程序或服务部署在一台物理或者虚拟机上。我们之前练习的网络部分的小项目都是属于单机服务器,它因为只需要维护一个服务器的原因,所以有着部署简单、成本低、调试方便等原因。但是同时也存在着诸多缺点:
- 扩展性差,无法横向扩展处理更多的请求
- 如果出现单点故障的话,服务器会整个宕机导致不可用
- 由于单个服务器的原因,服务器会存在着性能瓶颈(CPU、内存资源)
- 系统中有些模块是属于CPU密集型的,有些是属于I\O密集型的,造成各个模块对于硬件资源的需求是不一样的。
于是针对单机服务器的缺点,我们再考虑集群服务器,即多个服务器组成一组,共同提供相同的服务,通常是通过负载均衡器统一对外提供访问入口。这样存在多服务器多节点的情况下可以避免单点故障所带来的影响,且横向扩展性强,多个请求节点的存在还能提升性能,如下图
虽然这种方式解决了一些问题,但是其本身又引出了其他的问题;
- 架构复杂,需要负载均衡器
- 成本上升,需要多态服务器
- 且如果有一个服务器出问题,虽然其他的服务器也能照常运行,但是出现问题的服务器同样也会完全宕机,对于大型的项目来说,它的重启会花费很多时间,成本增加,需要编译,可能还需要多次编译。
- 模块冗余,对于上图来说,多个服务器其实只需要一个后台管理模块即可。
那么有没有更好的方法来替代它呢?这就要进入正题了,我们接下来要说的分布式系统项目。
二、分布式系统
分布式系统就是将应用拆成多个模块,分别部署在不同的服务器上,并通过网络进行通信和协作,所有的服务器协同工作共同提供给服务,同时,根据节点的并发要求,对一个节点可以再做节点模块集群部署,如下图的server1模块使用量多,可以部署在多台服务器上,server3使用量和并发量少,只需要部署在一台服务器上就好了,如下图所示
分布式系统有着很多的优点:
- 垂直拆分,可以将一个庞大的单位系统按照模块拆分成多个独立的服务或组件,如上图所示,将聊天系统拆分成用户管理模块、好友管理模块、消息管理模块、后台管理模块等,分别部署在不同的服务节点上。
- 极强的可扩展性,随着业务的增长从而灵活地增加资源以应对高负载。比如增加更多的服务器或者节点来提升系统的处理能力。只要解决节点间的数据一致性、通信、协调等问题就行。
- 高可用性,即使某个节点或者组件发生故障,系统仍然可以继续提供服务,通过多副本部署(运行多个实例),负载均衡等实现。
- 故障隔离,由于模块化设计,当系统中的某一部分出现错误或者崩溃时,其他部分仍然可以正常工作。
集群化部署(Cluster Deployment) 是指将多个相同功能的节点或服务实例部署在一起,共同处理来自客户端的请求。通过负载均衡器将请求分发给不同的节点,可以有效提高系统的处理能力、可用性和容错性。
就拿上面的例子来说:
假设我们的好友管理发生了问题,如果不是分布式系统(不做模块的拆分)的话,那我们在获取好友列表的时候就会崩溃,从而获取不到好友列表,整个的应用也会崩溃。而如果做了故障隔离,只是我们获取不了好友列表,但是其他的模块还在运行,我们照样可以执行发消息、登录、注册等操作。
接下来我们就是要实现这么一个分布式网络通信系统框架,我们所要面临的问题主要有:
- 该系统的模块需要如何划分,如果划分不合理会导致大量的重复代码。
- 系统的各个模块都运行在不同的机器上,那么各个模块之间该如何协作呢?比如我在机器一上的模块想要调用部署在机器2上的模块中的一个业务方法,我该怎么做到呢?
三、RPC通讯原理
再来了解一下RPC原理,RPC是Remote Procedure Call的缩写,即远程过程调用,可以使得一台服务器上的服务通过网络调用另一台服务器上的服务,就是我们上面说的使得部署在不同机器上的模块之间可以协作。所以说rpc框架其实就是封装了网络调用的过程中的一些细节,只暴露出一些操作简单的接口以供用户使用,这样就可以让调用远程服务看起来像调用本地服务一样简单,这样的好处就是程序开发者不用过多关注网络通信,将更多的精力放在业务的开发上。
分布式网络通信的RPC的原理图如图所示:
分析:
- 首先从客户端(用户)说起,当客户端发起一个调用请求,客户端是通过本地的代理来访问其他服务器上的方法,这个代理通常是在客户端和服务端建立起来的一个桥梁,可以将远程方法转化成本地方法调用。(这个代理就是图中的User-Stub,我们到后面做解释)
- 代理封装请求:即接着这个客户端代理将客户端对远程对象的调用封装成一种标准格式的消息(这种消息是在客户端和服务端共同协定的,方便对消息进行序列和反序列化,消息包括需要调用的那个服务下面的哪个方法还有参数是什么)。
- 网络发送:然后将消息通过网络发送给服务端。
- 服务端解析请求:服务端接收到消息之后对消息进行解析,根据消息內容找到对应服务下的对应方法,传入参数并得到结果。
- 服务端封装响应:服务端将处理好的结果按照协定的格式进行打包。
- 网络发送:将打包好的响应发送给客户端。
- 代理解析响应:消息通过网络返回给客户端代理,代理收到消息后对消息进行解析并将结果返回给用户。
在上图中的黄色部分显而易见是消息数据的打包和解析,也就是数据的序列化和反序列化,我们这里使用的是Protobuf.
在绿色部分即网络部分,主要负责的是网络部分,包括寻找rpc服务主机,发起调用请求和响应处理结果。
红色方框即我们所要实现的RPC通信框架。
RPC的主要优势在于,客户端和服务端可以用不同的编程语言来写,只要他们都支持相同的 接口定义语言(IDL)和序列化协议(如 Protobuf、Thrift、gRPC 等),就可以做到无障碍通信。
四、技术栈
下面介绍一下我们这个项目所依赖的技术栈,如下:
- 集群和分布式概念及原理
- RPC远程过程调用原理及实现
- Protobuf数据序列化和反序列化协议
- Muduo网络库编程
- 基于异步实现的日志系统
- conf配置文件的读取
- Zookeeper分布式一致性协调服务应用及其编程
- CMake构建项目集成编译环境
- 一键编译化脚本
这也是为什么我们叫Mprpc的原因 ,因为使用了Protobuf和Muduo库
五、Protobuf的使用
在本项目中我们需要使用到protobuf来作消息的序列化和反序列化,如上面的项目原理图中的User-Stub和Server-Stub
关于protobuf的使用大家可以移步Protobuf详解一文中学习,文中包含了protobuf的一些基本的使用方法和protobuf存储的原理。但是这对于本项目来说还不够,因为我们前边提到了 User-Stub和Server-Stub这两个东西,那么在这里我们就对它进行分析。
5.1定义描述方法的类型
首先我们定义如下的 .proto 文件,其中定义的消息类型我们就不做解释了,重点是看看最后的使用 service 关键字来定义描述rpc方法的类型。
syntax ="proto3";
package fixbug;
option cc_generic_services = true;
message ResultCode
{
int32 errcode=1;
bytes errmsg=2;
}
message LoginRequest
{
bytes name=1;
bytes pwd=2;
}
message LoginResponse
{
ResultCode result=1;
bool success=2;//登陆是否成功
}
message RegisterRequset
{
uint32 id=1;
bytes name=2;
bytes pwd=3;
}
message RegisterResponse
{
ResultCode result=1;
bool success=2;//登陆是否成功
}
service UserServiceRpc
{
rpc Login(LoginRequest) returns(LoginResponse);
rpc Register(RegisterRequset)returns(RegisterResponse);
}
最后这段代码就是 接口定义语言的片段(IDL,描述软件组件之间接口的语言),含义为:
- 使用 service 关键字定义了一个名为 UserServiceRpc 的服务,比如我们的用户管理服务
- 用户管理服务里面有两个方法,分别是 Login(登录) 和 Register(注册) 方法
- rpc 关键字用来明确指出这是一个可以通过网络远程过程调用的方法,rpc 这个关键字在定义远程过程调用的方法时是有着特定含义和作用的,它不是可以随意替换的。
- 我们在 .proto 文件中还定义了 LoginRequest(登录请求消息类型)、LoginResponse(登录响应消息类型)和RegisterRequset(注册请求消息类型)、RegisterResponse(注册响应消息类型)
- rpc方法的作用为:客户端可以通过远程调用 Login() 方法,传入一个 LoginRequest 对象(如用户名、密码等),服务端处理后返回一个 LoginResponse 对象(如 token、用户信息等)。Register方法也是类似。
注意:
1、
option cc_generic_services = true;
是用于控制C++服务生成方式的,默认是关闭的,我们需要主动设置为true,它适用于支持动态方法调用、反射机制,适用于中间件或插件系统,本项目中需要设置为true.
2、
protobuf不支持什么rpc功能,他只是对rpc方法的一个描述,通过这个描述它就可以去做这个rpc请求所携带的参数的序列化和反序列化
可以把 Protobuf 看作是一个“菜谱说明书”,它告诉你这道菜需要哪些材料(参数)、最终成品长什么样(返回值),但它不会自己下厨做饭(执行网络通信)。真正负责做饭的是厨师(即 RPC 框架)。
Protobuf 的作用 不是做网络通信,而是为 RPC 提供: 接口定义语言(IDL) 定义服务接口、方法名、参数、返回类型 数据结构定义 使用 message
定义请求和响应的数据格式序列化与反序列化 将数据结构转换为字节流在网络上传输
5.2生成的方法类
接下来我们使用 protoc 编译完成之后,进入生成的 .pb.h和.pb.cc文件中,我们只拿 Login()方法举例子来说,就可以看到protobuf自动生成了两个类如下:
在Protobuf详解一文中我们也提到了生成的这两种类中包含了很多的接口,如根据我们在.proto文件中定义的 Loginrequest 消息类型类中的 name 和 pwd ,该类中就自动生成了关于name和pwd的接口方法如下
还有一些序列化和反序列化的接口如: SerializeToString()等,它们都是可以通过LoginRequest这个类所实例化出的对象调用的。且正如我们看到的自动为我们生成的类都继承自 google::protobuf::Message类,这个类又继承自一个更为轻量的 MessageLite 类。
5.3生成的服务类
可以看到会自动生成两个文件 .pb.h 和 .pb.cc 文件,我们进入 .pb.h文件中查看,可以看到它protobuf自动为我们生成了两个类如下:
看到这两个类我们有没有觉得很眼熟,其实他就是我们在RPC框架的原理图中看到的两个类。
1、 首先对于UserServiceRpc,它是用在服务的发起端(服务器),可以看到它是继承自 Service 类的,且类中的两个接口的名称刚好对应了我们在 .proto文件中定义的 service 方法中的Login方法和Register方法,说明这是有关系的,还注意到他们都是虚函数,说明这都是需要子类进行重写的。还需要注意到的是一个ServiceDescriptor* GetDescriptor()方法,它是对服务的描述,可以获取服务的相关信息,比如服务的名字,服务中包含几个方法等。还有一个MethodDescriptor* descriptor() 函数,从名字看来他就是一个对方法进行描述的接口包括方法的名字、根据下标取方法等。
2、 接着还有一个类UserServiceRpc_Stub:就是上面说的客户端的代理类,用在客户端,我们可以先暂时理解为:当我们的客户端需要发起远程rpc调用请求的时候,底层会做很多事情,这些事情都是通过代理类UserService_Stub帮我们做的。它继承自UserServiceRpc,需要注意的是这个类是有着默认构造函数的,需要传入channel对成员变量进行初始化,这是一个很关键的点。
该类中也有两个接口因为是继承自UserServiceRpc,所以它们也是虚函数,需要重写,我们再看看这两个接口的实现,如下
可以看到非常相似,都是调用了channel的CallMethod方法,只不过一个method()传入的是1一个是0罢了。所以意思就是无论是UserServiceRpc_Stub类调用Login还是Register哪一个方法,最后都是调用的channel的CallMethod方法,所以传入的0和1也就是用来区分调用的是哪个方法而已。
那什么是RpcChannel呢?
可以看到RpcChannel也是很简单,它是一个抽象类,有一个纯虚函数CallMethod,这也意味着我们必须是实现一个类从RpcChannel继承而来,然后对其虚函数进行重写。
关于项目中需要使用到的protobuf的相关我们暂时就只说这么多,包括上面创建的各种类和接口的参数我们在后面写项目的时候,边写边解释吧。
到这里,该项目的准备工作和预备知识就基本已经铺垫完成了,接下来就要开始实现了!请看下一篇博客!