【C++】基于C++的RPC分布式网络通信框架(二)
目录
- 前言
- 1. 环境配置与运行
- 2. 什么是RPC?
- 3. 介绍一下RPC项目(`面试题:★★★★★`)
- 4. RPC调用过程
- 4.1 RPC调用过程(简单版)
- 4.2 RPC调用过程复杂版(`面试题:★★★`)
- 5. 代码层面RPC
- 5.1 业务层代码实现
- 5.1.1 ProtoBuf协议数据结构定义
- 5.1.2 Example代码:calluserservice.cc
- 5.1.3 Example代码:userservice.cc
- 5.2 RpcServer实现
- 5.2.1 RpcProvider
- 5.2.2 RpcProvider::NotifyService(google::protobuf::Service *service)
- 5.2.3 ZooKeeper
- 5.2.3.1 ZooKeeper是怎么存储数据(服务对象和方法)(`面试题:★★★`)
- 5.2.3.2 Watcher机制(`面试题:★★★`)
- 5.2.4 RpcProvider::Run()
- 5.2.5 RpcProvider::OnMessage(…)
- 5.3 日志模块
- 6. 面试相关问题
- 6.1 这个项目你遇到的难点是什么,你是怎么解决的?(`面试题:★★★★★`)
前言
在网上关于RPC分布式网络通信框架的文章数不胜数,原本不打算写这篇文章,但是网上的文章总给人一种残缺美,我是一个追求完美的人,所以还是打算站在巨人的肩膀上去完善大神的两篇文章。并补充一些关于这个项目的面试细节(挖个坑 等我找到工作后)。
大神的两篇文章:
RPC框架基础概念篇
C++实现轻量级RPC分布式网络通信框架
1. 环境配置与运行
关于RPC项目的环境配置和运行,这里就不加赘述了,在上一篇文章中,我已经进行了详细的讲解。对这方面不了解的同学可以看一下上一篇文章:
【C++】基于C++的RPC分布式网络通信框架(一)
本文将会把重点放在框架梳理、源码讲解、知识点剖析上。
2. 什么是RPC?
对于刚接触这个项目的小伙伴来说通常是一脸懵逼,这里给出一个通俗易懂的理解
假如说你要买零食,在以前的经济模式下,你会直接去附近的超市,直接进行购买,你买的零食会全部来自于这家超市。在如今的电商模式下,你只需要打开淘宝,然后选择你需要的商品,比如方便面,薯片等,这些商品来自五湖四海,你不需要关注他们怎么给你送过来的,你只需要到快递站根据快递单号去拿就可以了(也就是屏蔽了相关的通信细节,根据协商一致的协议获取结果),就和你直接去超市买的感觉是一样的,这就是RPC。
在理解了上面这里给出一个比较官方的解释:
RPC(Remote Procedure Call,远程过程调用)是一种计算机通信协议,允许程序在不同的计算机或网络节点上执行代码。通过RPC,客户端可以调用远程服务器上的函数或方法,就像调用本地函数一样,透明地隐藏了网络传输和协议细节。
了解一下就行,没有哪个面试官会问什么是RPC,但面试官一上来就会让你介绍一下自己的项目,这个时候就需要你对项目进行一个简要的概括。于是就引出了我们的第二个问题。
3. 介绍一下RPC项目(面试题:★★★★★)
这个项目是基于C++实现的一个RPC分布式网络通信框架项目。它可以将任何单体架构系统的本地方法调用重构为基于TCP网络通信的RPC远程方法调用。网络层采用了高并发的muduo网络库实现。这使得网络lO层和RPC方法调用处理层进行代码解耦变得更加容易,并且具有良好的并发性能。RPC方法调用使用了protobuf进行相关数据的序列化和反序列化,zookeeper提供了服务注册和发现。
在这之后会根据这个面试题引出一些列面试问题
这段简述包含RPC项目的三个核心技术模块:
- protobuf:序列化和反序列化
protoBuf能提供对数据的序列化和反序列化,ProtoBuf可以用于结构化数据的串行序列化,并且以Key-Value格式存储数据,因为采用二进制格,所以序列化出来的数据比较少,作为网络传输的载体效率很高。 - zookeeper: 服务注册和发现
zooKeeper在这里作为服务方法的管理配置中心,负责管理服务方法提供者对外提供的服务方法。 - muduo: 网络通信
muduo库网络是基于多Reactor多线程模型的网络库,在RPC通信框架中涉及到网络通信。另外服务提供方可以实现为IO多线程,实现高并发处理远端服务方法请求。
4. RPC调用过程
4.1 RPC调用过程(简单版)
这三个核心模块够成了RPC的整个调用过程,为了方面理解,我们先来看一个简单版本,也就是不包含zookeeper的调用过程。

- 步骤一:本地服务调用方发起远端调用需要向RPC框架传入请求的服务对象名(可以理解为类对象)、函数方法名(可以理解为类中的成员函数)、函数参数这三样东西
- 步骤二:将服务对象名,函数方法名,以及函数参数通过protobuf进行序列化(也就是二进制编码)。
- 步骤三:客户端通过Socket 套接字进行TCP通信发送字节流,远端服务提供方通过muduo网络库接受字节流
- 步骤四:将字节流通过protobuf进行反序列化得到服务对象名,方法名和函数参数,
在这里会涉及到一个TCP粘包问题 - 步骤五:根据解析出的服务对象名、方法名和函数参数调用服务器函数,得到调用结果
- 步骤六:将调用结果序列化
- 步骤七:将序列化结果进行字节流传输
- 步骤八:服务调用方收到字节流,进行反序列化,得到最最终的结果
——————————————————————————————————————————————————
TCP粘包和拆包问题如何解决?
通过自定义消息头+消息体的方式来解决

首先我们有一个protobuf类型的结构体消息RpcHeader,这个RpcHeader有三个字段,分别是服务对象名,服务函数名和函数参数长度。通过SerializeToString能把这个RpcHeader序列化为二进制数据(字符串形式存储)。同时我们的函数参数是可变的,长度不确定的,所以不能和RpcHeader一起封装,否则有多少个函数就会有多少个RpcHeader。我们为每一个不同的函数参数列表定义不同的protobuf结构体消息。然后对这个protobuf结构体消息进行序列化(字符串形式存储),现在我们得到了两个序列化后的二进制字符串,拼接起来就是我们要发送的消息send_str了。
但是还有个问题,我们怎么知道send_str中哪部分属于RpcHeader,哪部分属于函数参数?所以我们需要在send_str前面加一个固定的4字节uint32整数来表示序列化后的RpcHeader数据的长度。这样我们就能切分开RpcHeader和函数参数的二进制数据了。然后我们对属于RpcHeader的二进制数据反序列化后又能取出函数参数长度字段,进而得知函数参数的长度!

————————————————————————————————————————————————
上面就是不包含服务注册和发现情况下的一个简易的RPC远程调用的流程,这个过程建议大家多看几遍理解一下,在充分掌握了上面这个RPC调用过程之后,我们加入服务的注册和发现,因此上面这幅图就变成了下面这个样子。
4.2 RPC调用过程复杂版(面试题:★★★)

假设在分布式环境下,服务提供方提前将对外提供的远端服务方法注册到 ZooKeeper 上,服务调用方要调用分布式环境中服务名为A方法名为F 的远端函数时,先询问 ZooKeeper 请求的服务名为A方法名为F 的远端函数在哪些机器上。接着,服务调用方获取提供服务名为A方法名为F 的远端函数所在服务器的 IP和Port,收到服务提供方的 IP和Port 后,将请求服务方法及参数序列化打包并用发送给服务提供方。服务提供方收到序列化数据后,进行反序列化得到服务名 、方法名及方法参数,找到要调用的服务方法并执行。执行完成后,服务提供方将结果序列化后返回给远端服务调用方,服务调用方收到序列化结果并反序列化,最终获得函数调用结果并上交至业务层。
5. 代码层面RPC
仅仅了解以上这些应付面试时远远不够的,因此还需要对代码进行进一步的剖析。下面给出RPC通信过程中的代码调用大致流程图

5.1 业务层代码实现
5.1.1 ProtoBuf协议数据结构定义
RPC通信交互的数据在发送前需要用protoBuf进行二进制序列化,并且在通信双方收到后要对二进制序列化数据进行反序列化。双方通信时发送的都是固定结构的消息体,比如登录请求消息体(用户名+密码),注册请求消息体(用户id+用户名+消息体)。
为了节约文章篇幅,我将user.proto代码截图拼在了一起。

本项目业务场景为:
服务调用方(Caller) 调用远端方法Login和Register。服务提供方(Callee) 对外提供远端可调用方法Login和Register,要在user.proto中进行注册(service UserServiceRpc)。在服务提供方中的Login方法接受LoginRequest message,执行完逻辑后返回LoginResponse message给服务调用方。Register方法同理。注意UserServiceRpc就是我们所说的服务名,而Login和Register就是方法名。
接着使用protoc来编译这个.proto文件
protoc user.proto -I ./ -cpp_out=./user
然后就能生成user.cc和user.h文件了。user.cc和user.h里面提供了两个非常重要的类供c++程序使用,其中UserServiceRpc_Stub类给服务调用方使用,UserServiceRpc给服务提供方使用。服务调用方可以调用UserServiceRpc_Stub::Login(...)发起远端调用,而服务提供方则继承UserServiceRpc类并重写UserServiceRpc::Login(...)函数,实现Login函数的处理逻辑。
另外我们在user.proto中注册了通信的消息体(LoginRequest、LoginResponse、RegisterResponse(其中嵌套了ResultCode)),这些注册的消息体也会由protoc生成对应的C++类和业务代码友好交互。
结合5.1.2节和5.1.3节的业务代码好好理解一下。
服务调用方调用Login函数,服务提供方执行服务端Login函数并返回结果,服务调用方获取返回结果。
5.1.2 Example代码:calluserservice.cc
服务调用方端代码:服务调用方向服务提供方发起远端调用,即服务调用方想要调用处于服务提供方中的Login函数。
#include <iostream>
#include "mprpcapplication.h"
#include "user.pb.h"
#include "mprpcchannel.h"
int main(int argc, char **argv)
{MprpcApplication::Init(argc, argv);//MprpcApplication类提供了解析argc和argv参数的方法,我们在终端执行这个程序的时候,需要通过-i参数给程序提供一个配置文件,这个配置文件里面包含了一些通信地址信息(后面提到)fixbug::UserServiceRpc_Stub stub(new MprpcChannel());//这一步操作后面会讲,这里就当是实例化UserServiceRpc_Stub对象吧。UserServiceRpc_Stub是由user.proto生成的类,我们之前在user.proto中注册了Login方法,fixbug::LoginRequest request;request.set_name("zhang san");request.set_pwd("123456");//回想起我们的user.proto中注册的服务方法:// rpc Login(LoginRequest) returns(LoginResponse);// callee的Login函数需要参数LoginRequest数据结构数据fixbug::LoginResponse response;// callee的Login函数返回LoginResponse数据结构数据stub.Login(nullptr, &request, &response, nullptr); //caller发起远端调用,将Login的参数request发过去,callee返回的结果放在response中。if (0 == response.result().errcode()) std::cout << "rpc login response success:" << response.sucess() << std::endl;elsestd::cout << "rpc login response error : " << response.result().errmsg() << std::endl;//打印response中的内容,别忘了这个result和success之前在user.proto注册过return 0;
}
5.1.3 Example代码:userservice.cc
服务提供方代码:服务提供方提供服务调用方想要调用的Login函数。
#include <iostream>
#include <string>
#include "user.pb.h"
#include "mprpcapplication.h"
#include "rpcprovider.h"
class UserService : public fixbug::UserServiceRpc // 使用在rpc服务发布端(rpc服务提供者)
{
public:bool Login(std::string name, std::string pwd){/**** 业务层代码 ****/std::cout << "doing local service: Login" << std::endl;std::cout << "name:" << name << " pwd:" << pwd << std::endl;return false;}void Login(::google::protobuf::RpcController* controller,const ::fixbug::LoginRequest* request,::fixbug::LoginResponse* response,::google::protobuf::Closure* done){/**** callee要继承UserServiceRpc并重写它的Login函数 ****/std::string name = request->name();std::string pwd = request->pwd();//request存着caller发来的Login函数需要的参数bool login_result = Login(name, pwd);//处理Login函数的逻辑,这部分逻辑单独写了一个函数。处于简化目的,就只是打印一下name和pwd。fixbug::ResultCode *code = response->mutable_result();code->set_errcode(0);code->set_errmsg("");response->set_sucess(login_result);//将逻辑处理结果写入到response中。done->Run();//将结果发送回去}
};int main(int argc, char **argv)
{MprpcApplication::Init(argc, argv);//想要用rpc框架就要先初始化RpcProvider provider;// provider是一个rpc对象。它的作用是将UserService对象发布到rpc节点上,暂时不理解没关系!!provider.NotifyService(new UserService());// 将UserService服务及其中的方法Login发布出去,供远端调用。// 注意我们的UserService是继承自UserServiceRpc的。远端想要请求UserServiceRpc服务其实请求的就是UserService服务。而UserServiceRpc只是一个虚类而已。provider.Run();// 启动一个rpc服务发布节点 Run以后,进程进入阻塞状态,等待远程的rpc调用请求return 0;
}
5.2 RpcServer实现
RpcServer负责将服务端服务方法注册到ZooKeeper上,并接受来自服务调用方的远端服务方法调用,并返回结果给服务调用方。
5.2.1 RpcProvider
当我们在user.proto注册了服务名为UserServiceRpc的Login方法时,对user.proto进行protoc编译得到user.cc和user.h,这组c++文件提供了一个类叫UserServiceRpc,该类中有一个虚方法Login如下所示:
/*** user.pb.h ****/
virtual void Login(::PROTOBUF_NAMESPACE_ID::RpcController* controller,const ::fixbug::LoginRequest* request,::fixbug::LoginResponse* response,::google::protobuf::Closure* done);
这是protobuf为我们提供的接口,需要服务提供方重写这个Login函数。所以在Example代码(5.1.3节)中,我们定义了继承UserServiceRpc类的派生类UserService。并在UserService重写了这个Login函数。
接着我们在主函数中实例化了一个RpcProvider对象provider。(该类是Rpc框架提供的专门发布RPC服务方法的网络对象类。)接着调用了provider.NotifyService(new UserService)。
接下来可以引入RpcProvider类了,这个类对外界就提供了NotifyService和Run成员方法,我们在userservice.cc文件中的main函数调用了NotifyService(new UserService()),NotifyService函数可以将UserService服务对象及其提供的方法进行预备发布。发布完服务对象后再调用Run()就将预备发布的服务对象及方法注册到ZooKeeper上并开启了对远端调用的网络监听(服务调用方通过tcp向c服务提供方请求服务,服务提供方当然要监听这一事件了)
rpcprovider.h
class RpcProvider
{
public:void NotifyService(google::protobuf::Service *service);// 通过该函数可以发布RPC服务方法void Run();// 开启提供rpc远程网络调用服务(利用muduo库提供的网络模块来监听网络事件,即监听来自远端的服务方法调用)
private:muduo::net::EventLoop m_eventLoop;//创建一个EventLoop对象。struct ServiceInfo{// service服务类型信息google::protobuf::Service *m_service; // 保存服务对象std::unordered_map<std::string, const google::protobuf::MethodDescriptor*> m_methodMap; // 保存服务方法};std::unordered_map<std::string, ServiceInfo> m_serviceMap;// 存储注册成功的服务对象和其服务方法的所有信息void OnConnection(const muduo::net::TcpConnectionPtr&);// 新的socket连接回调void OnMessage(const muduo::net::TcpConnectionPtr&, muduo::net::Buffer*, muduo::Timestamp);// 已建立连接用户的读写事件回调void SendRpcResponse(const muduo::net::TcpConnectionPtr&, google::protobuf::Message*);// Closure的回调操作,用于序列化rpc的响应和网络发送
};
这份.h文件中有什么你需要知道和了解的?
首先涉及muduo库的使用不进行详细讲解,你要知道EventLoop是什么东西。另外Muduo提供的网络模块监听到连接事件并处理完连接逻辑后会调用OnConnection函数,监听到已建立的连接发生可读事件后会调用OnMessage函数。
要知道我们的UserService是继承自UserServiceRpc,而UserServiceRpc又是继承自google::protobuf::Service类。这里就体现了c++多态的设计思想。毕竟RpcProvider作为Rpc通信框架的一部分,是服务于业务层的,当然不能定义一个依赖于业务层的函数了。意思就是这里不能写成void NotifyService(UserService *service);。所以protobuf就提供了google::protobuf::Service基类来描述服务对象。传递对象的时候传递基类指针指向派生类实例,使Rpc框架中定义的类方法解耦于业务层。我这里补充这一点是因为,这个项目比较简单,估计看我代码的大多都是c++稍微入门的朋友,所以我比较体贴的提一下这里的设计思想。希望能启发到初学者们共同进步一下。
在protobuf中使用google::protobuf::MethodDescriptor类来描述一个方法,在protobuf中用google::protobuf::Service类来描述一个服务对象。
我们需要关注的是m_serviceMap和Service_Info结构体,其中Service_Info结构体内定义了一个服务对象,以及这个服务对象内提供的方法们(以std::unordered_map形式存储)。回顾一下我们之前在user.proto中注册了两个rpc远端调用方法Login和Register,这两个方法在程序中都是用google::protobuf::MethodDescriptor类来描述的。一台服务器上可能会提供多个Service服务对象,所以m_serviceMap就是存储多个Service_Info结构体的。
总结一下,服务提供方接收来自服务调用方的请求服务方法。服务提供方根据服务方法名从m_serviceMap中定位到服务调用方请求的服务对象及方法对象,然后进行调用。
5.2.2 RpcProvider::NotifyService(google::protobuf::Service *service)
将传入进来的服务对象service进行预备发布。其实说直白点就是将这个service服务对象及其提供的方法的Descriptor描述类,存储在RpcProvider::m_serviceMap中。这个函数里面的内容我觉得有前面的铺垫是很容易看懂的。里面涉及到的很多方法都是从protobuf生成的,配合注释一看也能看懂。
rpcprovider.cc
/*
文件:
*/
void RpcProvider::NotifyService(google::protobuf::Service *service)
{ServiceInfo service_info;// 获取了服务对象的描述信息const google::protobuf::ServiceDescriptor *pserviceDesc = service->GetDescriptor();// 获取服务的名字std::string service_name = pserviceDesc->name();// 获取服务对象service的方法的数量int methodCnt = pserviceDesc->method_count(); //定义多少个远端能调用的函数// std::cout << "service_name:" << service_name << std::endl;LOG_INFO("service_name:%s", service_name.c_str());for (int i=0; i < methodCnt; ++i){// 获取了服务对象指定下标的服务方法的描述(抽象描述) UserService Login// 这里可是框架!!!不能写出什么具体的类啊 方法啊const google::protobuf::MethodDescriptor* pmethodDesc = pserviceDesc->method(i);std::string method_name = pmethodDesc->name();service_info.m_methodMap.insert({method_name, pmethodDesc});LOG_INFO("method_name:%s", method_name.c_str());}service_info.m_service = service;m_serviceMap.insert({service_name, service_info});
}
5.2.3 ZooKeeper
ZooKeeper是一个分布式服务框架,为分布式应用提供一致性协调服务的中间件。在这个项目中,服务提供方将对外提供的服务对象及其方法以及网络地址信息注册在ZooKeeper服务上,服务调用方则通过访问ZooKeeper在整个分布式环境中获取自己想要调用的远端服务对象方法在哪一台设备上(网络地址信息),并向该设备直接发送服务方法调用请求。
ZooKeeper提供了两个库,分别是zookeeper_st(单线程库)和zookeeper_mt(多线程库),一般使用zookeeper_mt库提供的API。
采用zookeeper_mt库的zookeeper客户端使用了三个线程:
- 主线程:用户调用API的线程。
- IO线程:负责网络通信的线程。
- completion线程:对于异步请求(Zookeeper中提供的异步API,一般都是以zoo_a开头的api)以及watcher的响应回调,io线程会发送给completion线程完成处理。关于watcher机制后面会介绍。
5.2.3.1 ZooKeeper是怎么存储数据(服务对象和方法)(面试题:★★★)
ZooKeeper相当于是一个特殊的文件系统,不过和普通文件系统不同的是,这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据,目录节点不行。ZooKeeper内部为了保持高吞吐和低延迟,再内存中维护了一个树状的目录结构,这种特性使ZooKeeper不能存放大量数据,每个节点存放数据的上线为1M。

服务对象名在ZooKeeper中以永久性节点的形式存在,当RpcServer与ZooKeeper断开连接后,整个节点还是会存在。方法对象名则以临时性节点存在,RpcServer与ZooKeeper断开后临时节点被删除。临时节点上带着节点数据,在本项目中,节点数据就是提供该服务方法的RpcServer的通信地址(IP+Port)。
我们把和ZooKeeper server交互的各种操作方法都封装在了zookeeperutil.cc/h文件中。下面给出zookeeperutil.h头文件,具体实现建议去看源码。 本质上就是对zookeeper库的zoo_init、zoo_create、zoo_get等方法的封装,为RpcServer提供简易的接口,实现RpcServer连接ZooKeeper(ZkClient::Start())、RpcServer在ZooKeeper上创建节点(ZkClient::Create(...))、RpcServer根据节点路径path(/服务名/方法名)从ZooKeeper服务器上获取节点中携带的数据(ZkClient::GetData(path))。
zookeeperutil.h
#pragma once#include <semaphore.h>#include <zookeeper/zookeeper.h>#include <string>// 封装的zookeeper客户端类class ZkClient{public:ZkClient();~ZkClient();void Start();// zkclient启动连接zkservervoid Create(const char *path, const char *data, int datalen, int state=0);// 在zkserver上根据指定的path创建znode节点std::string GetData(const char *path);// 根据参数指定的znode节点路径,或者znode节点的值private:zhandle_t *m_zhandle;// zk的客户端句柄};
ZkClient::GetData(..)和ZkClient::Start(..)自己去看代码挺容易看懂的,就是把服务名和方法名组织成路径通过zookeeper提供的api和zookeeper服务器进行交互。自己去看看怎么实现的吧,这里就不赘述了。
5.2.3.2 Watcher机制(面试题:★★★)
watcher机制:ZooKeeper客户端对某个znode建立一个watcher事件,当该znode发生变化时,这些Zookeeper客户端会收到Zookeeper服务端的通知,然后Zookeepr客户端根据znode的变化来做出业务上的改变。
下面一整段话请搭配着下面给出的代码一起看:
从下面的代码中要先知道一个事情,我们在ZkClient::Start()函数中调用了zookeeper_init(...)函数,并且把全局函数global_watcher(...)传了进去。zookeeper_init(...)函数的功能是异步建立rpcserver和zookeeper连接,并返回一个句柄赋给m_zhandle(客户端通过该句柄和服务端交互)。如何理解异步建立,就是说当程序在ZkClient::Start()函数中获得了zookeeper_init(..)函数返回的句柄后,连接还不一定已经建立好。因为发起连接建立的函数和负责建立连接的任务不在同一个线程里完成。(之前说过ZooKeeper有三个线程)
所以调用完zookeeper_init函数之后,下面还定义了一个同步信号量sem,并且调用sem_wait(&sem)阻塞当前主线程,等ZooKeeper服务端收到来自客户端callee的连接请求后,服务端为节点创建会话(此时这个节点状态发生改变),服务端会返回给客户端callee一个事件通知,然后触发watcher回调(执行global_watcher函数)
ZkClient::Start()函数中有一句调用:zoo_set_context(m_zhandle, &sem); ,我们将刚才定义的同步信号量sem通过这个zoo_set_context函数可以传递给m_zhandle进行保存。在global_watcher中可以将这个sem从m_zhandle取出来使用。
global_watcher函数的参数type和state分别是ZooKeeper服务端返回的事件类型和连接状态。在gloabl_watcher函数中发现状态已经是ZOO_CONNECTED_STATE说明服务端为这个节点已经建立好了和客户端callee的会话。此时调用sem_post(sem)解除主线程阻塞(解除ZkClient::Start()中的阻塞)。
这个同步机制保证了,当ZkClient::Start()执行完后,callee端确定和zookeeper服务端建立好了连接!!
zookeeperutil.cc
// 全局的watcher观察器 zkserver给zkclient的通知void global_watcher(zhandle_t *zh, int type,int state, const char *path, void *watcherCtx){if (type == ZOO_SESSION_EVENT) {// 回调的消息类型是和会话相关的消息类型if (state == ZOO_CONNECTED_STATE) {// zkclient和zkserver连接成功sem_t *sem = (sem_t*)zoo_get_context(zh);sem_post(sem);}}}void ZkClient::Start(){//MprpcApplication类实现了对配置文件test.conf的解析,我们提前将rpcserver和zookeeper的ip地址和端口号都写在了这个配置文件中,当执行整个程序的时候通过命令行参数指定这个配置文件,std::string host = MprpcApplication::GetInstance().GetConfig().Load("zookeeperip"); //获取ZooKeeper服务端的IP地址和端口号,自己看源码std::string port = MprpcApplication::GetInstance().GetConfig().Load("zookeeperport");//获取ZooKeeper服务端的IP地址和端口号,自己看源码std::string connstr = host + ":" + port;//组织IP:port地址信息m_zhandle = zookeeper_init(connstr.c_str(), global_watcher, 30000, nullptr, nullptr, 0); //只是创建了一个本地的句柄if (nullptr == m_zhandle) //这个返回值不代表连接成功或者不成功{std::cout << "zookeeper_init error!" << std::endl;exit(EXIT_FAILURE);}sem_t sem;sem_init(&sem, 0, 0);zoo_set_context(m_zhandle, &sem); //给这个句柄添加一些额外的信息sem_wait(&sem); //阻塞结束后才连接成功!!!std::cout << "zookeeper_init success!" << std::endl;}
最后对Watcher机制稍微总结一下
Watcher实现由三个部分组成:
- ZooKeeper服务端
- ZooKeeper客户端
- 客户端的ZkWatchManager对象
Watcher机制类似于设计模式中的观察者模式,也可以看作是观察者模式在分布式场景下的实现方式。
- 针对每一个znode的操作,都会有一个watcher。
- 当监控的某个对象(znode)发生了变化,则触发watcher事件。
- 父节点、子节点增删改都能触发其watcher。
ZooKeeper中的watcher是一次性的,触发后立即销毁。ZooKeeper客户端(Callee)首先将Watcher注册到服务端,同时把Watcher对象保存到客户端的WatcherManager中。当ZooKeeper服务端监听到ZooKeeper中的数据状态发生变化时,服务端主动通知客户端(告知客户端事件类型和状态类型),接着客户端的Watch管理器会触发相关Watcher来回调相应处理逻辑(GlobalWatcher),从而完成整体的数据发布/订阅流程。

5.2.4 RpcProvider::Run()
有了上面ZooKeeper的铺垫,这里看懂RpcProvider::Run()函数也不是什么难事了,该函数的作用是将待发布的服务对象及其方法发布到ZooKeeper上,同时利用Muduo库提供的网络模块开启对RpcServer的(IP, Port)的监听。下面代码结合注释好好研究一下也是能研究懂的
/***
rpcprovider.cc
***/
void RpcProvider::Run()
{std::string ip = MprpcApplication::GetInstance().GetConfig().Load("rpcserverip");uint16_t port = atoi(MprpcApplication::GetInstance().GetConfig().Load("rpcserverport").c_str());//MprpcApplication类实现了对配置文件test.conf的解析,我们提前将rpcserver和zookeeper的ip地址和端口号都写在了这个配置文件中,当执行整个程序的时候通过命令行参数指定这个配置文件,muduo::net::InetAddress address(ip, port);// 将rpcserver的套接字地址信息用Muduo库提供的类进行封装muduo::net::TcpServer server(&m_eventLoop, address, "RpcProvider");// 创建TcpServer对象,Muduo基础,不赘述!server.setConnectionCallback(std::bind(&RpcProvider::OnConnection, this, std::placeholders::_1));server.setMessageCallback(std::bind(&RpcProvider::OnMessage, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));// 绑定连接回调和消息读写回调方法,属于Muduo基础,不赘述!server.setThreadNum(4);// 设置muduo库的SubEventLoop线程数量// 把当前rpc节点上要发布的服务全部注册到ZooKeeper上面,让caller可以从ZooKeeper上发现服务// session timeout 30s Caller 网络I/O线程 1/3 * timeout 时间发送ping消息ZkClient zkCli; //实例化一个ZooKeeper对象zkCli.Start(); //连接到ZooKeeper服务器// service_name为永久性节点 method_name为临时性节点for (auto &sp : m_serviceMap) {std::string service_path = "/" + sp.first;//service_path = "/UserServiceRpc"zkCli.Create(service_path.c_str(), nullptr, 0);//在ZooKeeper上创建/注册 /UserServiceRpc 节点for (auto &mp : sp.second.m_methodMap){std::string method_path = service_path + "/" + mp.first;//method_path = "/UserServiceRpc/Login"//method_path = "/UserServiceRpc/Register"char method_path_data[128] = {0};sprintf(method_path_data, "%s:%d", ip.c_str(), port);//把ip和端口号作为这个节点的携带的数据。zkCli.Create(method_path.c_str(), method_path_data, strlen(method_path_data), ZOO_EPHEMERAL);// ZOO_EPHEMERAL表示znode是一个临时性节点// 在ZooKeeper上创建/注册 "/UserServiceRpc/Login"节点以及"/UserServiceRpc/Register"节点}}// rpc服务端准备启动,打印信息std::cout << "RpcProvider start service at ip:" << ip << " port:" << port << std::endl;// 启动网络服务server.start();m_eventLoop.loop();
}
5.2.5 RpcProvider::OnMessage(…)
在RpcProvider::Run()函数中用Muduo库提供网络模块监听Callee端的rpcserver的端口。当Caller端发起远程调用的时候, 会对callee的rpcserver发起tcp连接,rpcserver接受连接后,开启对客户端连接描述符的可读事件监听。caller将请求的服务方法及参数发给callee的rpcserver,此时rpcserver上的muduo网络模块监听到该连接的可读事件,然后就会执行OnMessage(...)函数逻辑。
void RpcProvider::OnMessage(const muduo::net::TcpConnectionPtr &conn, muduo::net::Buffer *buffer, muduo::Timestamp)
{/* 1. 网络上接收的远程rpc调用请求的字符流 Login args *//* 2. 对收到的字符流反序列化 *//* 3. 从反序列化的字符流中解析出service_name 和 method_name 和 args_str参数 *//* 4. m_serviceMap是一个哈希表,我们之前将服务对象和方法对象保存在这个表里面,根据service_name和method_name可以从m_serviceMap中找到服务对象service和方法对象描述符method。*//* 5. 将args_str解码至request对象中之后request对象就可以这样使用了:request.name() = "zhangsan" | request.pwd() = "123456"*/google::protobuf::Message *response = service->GetResponsePrototype(method).New();// 定义一个空的response,用于下面的函数service->CallMethod()将caller请求的函数的结果填写进里面。google::protobuf::Closure *done = google::protobuf::NewCallback<RpcProvider,const muduo::net::TcpConnectionPtr&, google::protobuf::Message*>(this, &RpcProvider::SendRpcResponse, conn, response);service->CallMethod(method, nullptr, request, response, done); //service是我们的UserService, CallMethod是Service基类中的函数
}// Closure的回调操作,用于序列化rpc的响应和网络发送
void RpcProvider::SendRpcResponse(const muduo::net::TcpConnectionPtr& conn, google::protobuf::Message *response)
{std::string response_str;if (response->SerializeToString(&response_str)) // response进行序列化 // 序列化成功后,通过网络把rpc方法执行的结果发送会rpc的调用方conn->send(response_str); elsestd::cout << "serialize response_str error!" << std::endl; conn->shutdown(); // 模拟http的短链接服务,由rpcprovider主动断开连接
}
上面的OnMessage()我省略了很多代码,具体的可以自己去看一下源代码,省略部分的代码主要就是对caller发送来的服务方法请求和参数进行反序列化和解析,并从之前保存的m_serviceMap哈希表中找到服务调用方请求的服务对象service和服务方法描述符method。
整个OnMessage()函数代码最重要的就是要理解最后两句调用的含义!这个google::protobuf::NewCallback<…>(…)函数到底是什么牛马?简单来讲,这个NewCallback<...>(...)函数会返回一个google::protobuf::Closure类的对象,该Closure类其实相当于一个闭包。这个闭包捕获了一个成员对象的成员函数,以及这个成员函数需要的参数。然后闭包类提供了一个方法Run(),当执行这个闭包对象的Run()函数时,他就会执行捕获到的成员对象的成员函数,也就是相当于执行void RpcProvider::SendRpcResponse(conn, response);,这个函数可以将reponse消息体发送给Tcp连接的另一端,即服务调用方。
在OnMessage()函数最后,调用了service->CallMethod(…)函数。这个函数是什么来头?简单来说,你打开service.h,也就是google::protobuf::Service类所在的文件,我们看这个类里面有一个虚函数CallMethod,这个虚函数其实在UserServiceRpc类已经被实现了,而这个UserServiceRpc类是由protobuf生成的。这里我建议看一下源码UserServiceRpc::CallMethod处的源码,很容易理解的。不然我在这里干着讲挺抽象的。UserServiceRpc::CallMethod函数可以根据传递进来的方法描述符method来选择调用注册在user.proto上的哪一个函数(Login)由于我们用派生类UserService继承了UserServiceRpc并且重写了Login函数的实现。所以当我们调用service->CallMethod()的时候,调用的其实是UserService中的Login函数。多漂亮的多态思想啊。
void Login(::google::protobuf::RpcController* controller,const ::fixbug::LoginRequest* request,::fixbug::LoginResponse* response,::google::protobuf::Closure* done)
{std::string name = request->name();std::string pwd = request->pwd();///protobuf直接把网络上的字节流反序列化成我们能直接识别的LoginRequest对象// 做本地业务bool login_result = Login(name, pwd);///调用上面的Login函数(本地业务)/// 把响应写入 包括错误码、错误消息、返回值fixbug::ResultCode *code = response->mutable_result();code->set_errcode(0);code->set_errmsg("");response->set_sucess(login_result);done->Run(); //还记得刚才讲到的闭包类Closure吗?里面封装了一个成员函数
}
在Login()函数最后调用了一句done->Run(),实际上就是调用了之前闭包中封装的成员函数RpcProvider::SendRpcResponse(conn, response);将reponse消息体作为Login处理结果发送回服务调用方。
5.3 日志模块
如果对这块内容不熟悉,简历上面可以不写,这样被问到了概率很小,还有就是对于日模块可以直接使用第三方库glog等,我所下载的github代码用的就是glog,如果自己实现的话可以采用互斥锁+队列,但一旦把这个写上去的话,面试官可以问的东西可就太多了,比如线程同步相关的一系列问题,要不要写上去看你自己对线程同步的掌握情况吧。
6. 面试相关问题
在大神写的两篇博客里,其实还提到了其他的一些问题比如负载均衡、健康检测、熔断限流问题等,但就我目前的一些面试的情况来讲,很多问题都没有被问到,可能是我比较菜,面的都不是字节腾讯这种大厂,很多都是偏聊天性质的面试,有一个常问的问题就是 这个项目你遇到的难点是什么,你是怎么解决的? 这个问题被问了五六次了吧。还有就是面试官通常会根据你这个项目去延展出一些问题,不会局限在项目的本身。
6.1 这个项目你遇到的难点是什么,你是怎么解决的?(面试题:★★★★★)
难点1:沾包/拆包问题:通过自定义通信协议(消息头+消息体)解决,确保数据完整性。
难点2:Zookeeper的学习与使用:学习Watcher机制,监听节点变化,实现动态服务注册与发现。将Zookeeper作为注册中心,提供高可用性和实时更新能力。
难点3:Protobuf的序列化和反序列化:熟悉Protobuf的定义文件、编译工具和API使用。
