C++轻量级RPC框架的设计与实现

C++轻量级RPC框架的设计与实现

https://github.com/Meha555/tinyrpc

项目概述

RPC是远程过程调用(Remote Procedure Call)的缩写,可以通过网络从远程服务器上请求服务(调用远端服务器上的函数并获取返回结果)。简单来说,客户端程序可以像调用本地函数一样直接调用运行在服务端的函数。

请添加图片描述

ZooKeeper

ZooKeeper在这里作为服务方法的管理配置中心,负责管理服务方法提供者对外提供的服务方法。每个服务方法提供者提前将本端对外提供的每个服务方法名及自己的通信地址信息 <IP:Port> 注册到ZooKeeper。当Caller发起远端调用时,会先拿着自己想要调用的服务方法名询问ZooKeeper,ZooKeeper告知Caller想要调用的服务方法在哪台服务器上(ZooKeeper返回目标服务器的IP:Port给Caller),Caller便向目标服务器Callee请求服务方法调用。服务方在本地执行相应服务方法后将结果返回给Caller。

注意:有可能需要每个服务提供者把自己提供的每个服务方法的通信地址都注册到zookeeper,因为服务可能分布式部署。服务端对象在客户端的stub在调用远程方法时,每次都需要获取远程方法的ip:port

ProtoBuf

ProtoBuf能提供对数据的序列化和反序列化,ProtoBuf可以用于结构化数据的串行序列化,并且以Key-Value格式存储数据,因为采用二进制格式,所以序列化出来的数据比较少,作为网络传输的载体效率很高。

Caller和Callee之间的数据交互就是借助ProtoBuf完成,具体的使用方法和细节后面会进一步拓展。

Muduo

Muduo库是基于(Multi-)Reactor模型的多线程网络库,在RPC通信框架中涉及到网络通信。另外我们可以服务提供方实现为IO多线程,实现高并发处理远端服务方法请求。

从零开始搭建RPC框架

我们要实现的就是:

image

RPC通信过程中的代码调用流程图大致就是下面这样

请添加图片描述

通信层实现

RPC通信示例

明确我们要干什么,先从一个例子入手分析:

请添加图片描述

业务场景为Caller调用远端方法Login和Register。Callee中的Login函数接收一个LoginRequest消息体,执行完Login逻辑后将处理结果填写进LoginResponse消息体,再返回给Caller。调用Register函数过程同理。

Callee对外提供远端可调用方法Login和Register,要在user.proto中进行注册(service UserServiceRpc)。在Callee中的Login方法接受LoginRequest message,执行完逻辑后返回LoginResponse message给Caller。

注意UserServiceRpc就是我们所说的服务名,而Login和Register就是方法名。

接着使用protoc来编译这个.proto文件生成user.cc和user.h文件了。user.pb.cc和user.pb.h里面提供了两个非常重要的类供c++程序使用,其中UserServiceRpc_Stub类给caller使用,UserServiceRpc给callee使用。caller可以调用UserServiceRpc_Stub::Login(...)发起远端调用,而callee则继承UserServiceRpc类并重写UserServiceRpc::Login(...)函数,实现Login函数的处理逻辑。

另外我们在user.proto中注册了通信的消息体(LoginRequest、LoginResponse、RegisterResponse(其中嵌套了ResultCode)),这些注册的消息体也会由protoc生成对应的C++类和业务代码友好交互。

到目前为止是一般的RPC框架所做的事情,比如 libwayland。即实现了端到端的RPC通信。但是我们这里想要做的还要更进一步,就是实现RPC服务的注册和发现。要实现这个功能,我们就需要在客户端发起RPC调用后,从调用信息中获取服务对象名和方法名,从而实现服务的发现

protoc提供的基本RPC框架

参考:

ProtoBuf虽然是一个序列化协议,但是其protoc代码生成工具支持开启 cc_generic_services 选项来让protoc生成实现RPC通信所需的代码框架模板(包含了发送请求到服务端,服务端返回响应的代码框架,即端到端),而不仅仅是protobuf协议序列化和反序列化的代码。

生成的服务端接口代码

给定一个服务定义

service MyService {
  rpc Foo(MyRequest) returns(MyResponse);
}

protoc将生成一个类MyService来表示此服务。MyService将为服务定义中定义的每个方法提供一个虚方法,但不是纯虚方法。默认实现只是使用错误消息(指示方法未实现)调用controller->SetFailed(),然后调用done回调。在实现自己的服务时,必须对此生成的类进行子类化并根据需要实现其方法。此外,MyService 类的构造函数访问权限是 protectedprotected 的特点是外部不可见,内部可见且继承可见,所以将构造函数设置为 protected 也就意味着无法直接实例化类,但是构造函数能被派生类调用,也就意味着派生类继承后能正常完成实例化。所以这种做法常用于设计抽象基类。

class MyServiceImpl : public MyService { // 需要自行继承生成的MyService来扩充这个类
 public:
  MyServiceImpl() {}
  ~MyServiceImpl() {}

  // implements MyService 补充实现业务的方法---------------------------------------

  void Foo(google::protobuf::RpcController* controller,
           const MyRequest* request,
           MyResponse* response,
           Closure* done) {
    // ... read request and fill in response ... 
      // 从request对象中取出入参
      // 调用业务逻辑的实现
      // 填充response作为返回响应
    done->Run();
  }
};

此外还会自动生成以下几个成员方法:

  • GetDescriptor:返回服务的ServiceDescriptor。服务描述符包含了服务的名称、rpc方法的数量以及方法的描述符等。而方法的描述符则包含了方法的名称、方法的输入输出描述符。
  • CallMethod:根据提供的方法描述符确定正在调用的方法,并直接调用它,将请求和响应消息对象向下转换为正确的类型。
  • GetRequestPrototypeGetResponsePrototype:返回给定方法的正确类型的请求或响应的默认实例。

还会生成以下静态方法

  • static ServiceDescriptor descriptor():返回类型的描述符,其中包含有关此服务有哪些方法以及它们的输入和输出类型的信息。
生成的客户端存根代码

protoc还会生成每个服务接口的“存根”实现,客户端可以使用它向实现该服务的服务器发送请求。对于MyService服务(如上所示),将定义存根实现MyService_Stub

MyService_StubMyService的子类,它还实现了以下方法

  • MyService_Stub(RpcChannel* channel):构造一个新的存根,它在给定的通道上发送请求。
  • MyService_Stub(RpcChannel* channel, ChannelOwnership ownership):构造一个新的存根,它在给定的通道上发送请求,并且可能拥有该通道。如果ownershipService::STUB_OWNS_CHANNEL,则当存根对象被删除时,它也将删除通道。
  • RpcChannel* channel():返回此存根的通道,如传递给构造函数。

存根还将服务的每个方法实现为 RpcChannel::CallMethod 的包装器,其中只会调用 channel->CallMethod()

实现一次RPC通信

Protocol Buffer 库不包含 RPC 实现。但是,它包含将生成的类连接到您选择的任何任意 RPC 实现所需的所有工具,一共3个类需要自己实现:

  • Service :基于protobuf序列化协议的 RPC 服务的抽象基本接口。

  • RpcChannel :RPC通信链路的抽象接口,只能由 Service_Stub 来调用。

  • RpcController :用于控制单次RPC调用(比如获取错误)。

  1. 将向 RPC 服务器实现注册 MyServiceImpl 的实例。(如何做到这一点取决于实现.)

  2. 要调用远程 MyServiceImpl,首先需要连接一个 RpcChannel。如何构造通道同样取决于你的 RPC 实现。这里我们以一个假设的 MyRpcChannel 为例:

MyRpcChannel channel("rpc:hostname:1234/myservice");
MyRpcController controller;
MyServiceImpl::Stub stub(&channel);
FooRequest request;
FooResponse response;

// ... fill in request ...

stub.Foo(&controller, request, &response, NewCallback(HandleResponse));

wire format定义

RPC通信交互的数据在发送前需要用ProtoBuf进行二进制序列化,并且在通信双方收到后要对二进制序列化数据进行反序列化。

前面说到,我们想知道双方通信的数据中哪些是服务对象名、远程方法名、实参列表,所以需要规定一个RPC wire format,双方通信时发送的都是固定结构的消息体。【参考wayland wire format】

确定好wire format后,我们的通信层框架就可以解析数据报,切割出各字段,然后就知道是要调用哪个服务的哪个方法,传入了什么参数,然后在通信层向该地址的服务对象发起最终的RPC调用即可

这里我设计的wire format消息格式如下(TLV编码):

image

我们有一个protobuf类型的结构体消息RpcHeader,这个RpcHeader有三个字段,分别是服务对象名,服务函数名和函数参数长度。通过SerializeToString能把这个RpcHeader序列化为二进制数据(字符串形式存储)。同时我们的函数参数是可变的,长度不确定的,所以不能和RpcHeader一起封装,否则有多少个函数就会有多少个RpcHeader。我们为每一个不同的函数参数列表定义不同的protobuf结构体消息。然后对这个protobuf结构体消息进行序列化(字符串形式存储),现在我们得到了两个序列化后的二进制字符串,拼接起来就是我们要发送的消息send_str了。

还有个问题,我们怎么知道send_str中哪部分属于RpcHeader,哪部分属于函数参数?所以我们需要再send_str前面加一个固定的4字节uint32整数来表示序列化后的RpcHeader数据的长度。这样我们就能切分开RpcHeader和函数参数的二进制数据了。然后我们对属于RpcHeader的二进制数据反序列化后又能取出函数参数长度字段,进而得知函数参数的长度

这里仍然使用Protobuf作为序列化方案,是因为它非常高效易用,且需要担心序列化成二进制数据后,每个字段的分界问题,这个protobuf都会给你处理好。

业务层实现

整体流程

因为我们这里会使用Protobuf作为序列化方案,因此不必采用上述类似TLV编码的格式,只需要保留关键字段即可:

message RpcHeader {
    bytes service_name = 1;
    bytes method_name = 2;
    uint32 args_size = 3;
}

具体来说,用户通过 Stub 发起RPC请求,会填充一个 google::protobuf::Message 子类类型的 request 对象,其中包含了用户的实参。然后我们在框架通信层将 RpcHeader 和这个 request 对象打包到一起,发送给实现 RPC 服务器的“daemon”—— RpcProvider (类似于 wl_registry ),然后 RpcProvider 就可以查找注册在其上的所有服务,调用指定的那个服务的指定方法,然后原路返回即可,response 对象将在 RpcChannel 中组装返回给 Stub。

image

RpcProvider实现

RpcProvider 负责:

  1. 服务发布:将各 google::protobuf::Service 的本地服务方法注册到ZooKeeper上,并接受来自caller的远端服务方法调用,路由调用到指定的服务方法,并返回结果给caller。
  2. 开启服务:开启对远端调用的网络监听

局限性:这里我设计的是 RpcProvider 在每台机器上都只有1个实例,且其管理的每个服务在这台机器上也只有1各实例。所以如果要用docker的话,则RpcProvider也得部署在docker里面。所以对于 RpcProvider 里面的 server_map 的修改也不需要加分布式锁。

ZooKeeper

ZooKeeper是一个分布式服务框架,为分布式应用提供一致性协调服务的中间件。在这个项目中,callee将【对外提供的服务对象及其方法】以及【网络地址信息】注册在ZooKeeper服务上,caller则通过访问ZooKeeper在整个分布式环境中获取自己想要调用的远端服务对象方法【在哪一台设备上(网络地址信息)】,并向该设备直接发送服务方法调用请求。

ZooKeeper作为服务中心真的好吗?他有什么缺点啊?

ZooKeeper最大特点就是强一致性,只要ZooKeeper上面有一个节点发生了更新,都会要求其他节点一起更新,保证每个节点的数据都是完全实时同步的,在所有节点上的数据没有完全同步之前不干其他事。

拿ZooKeeper搞点小项目其实还能应对,但是如果分布式环境中提供服务的和访问服务的机器越来越多,变化越来越频繁时,ZooKeeper为了维持这个强一致性需要付出很多代价,最后ZooKeeper服务注册中心会承受不住压力而崩溃。

ZooKeeper是怎么存储数据(服务对象和方法)?

ZooKeeper相当于是一个特殊的文件系统,不过和普通文件系统不同的是,这些节点都可以设置关联的数据,而文件系统中只有文件节点可以存放数据,目录节点不行。ZooKeeper内部为了保持高吞吐和低延迟,再内存中维护了一个树状的目录结构,这种特性使ZooKeeper不能存放大量数据,每个节点存放数据的上线为1M。
请添加图片描述

  1. 注册中心的管理者会在ZooKeepr下创建一个服务根路径,可以根据接口来命名(我上面的图就随便给了一个斜杠 “/” 作为根路径)。在这个路径下面可以再创建服务提供方目录与服务调用方目录。
  2. 服务提供方注册时,发起注册时,会在服务提供方目录中创建一个临时节点,这个节点存储服务提供方的注册信息,就比如上图中我的UserServiceRpc/Login节点中存的就是提供这个方法的服务器的IP端口号。
  3. 服务调用方发起订阅时,服务调用方目录会创建一个临时节点,节点中存储服务调用方信息。
  4. 当服务提供方目录中节点发生了任何变化时(新增节点,移除节点,节点上数据变动等),ZooKeeper就会通知发起订阅的服务调用方。
Watcher机制

服务提供方目录中节点发生了任何变化,ZooKeeper怎么通知订阅的服务调用方的,这里就涉及到Watcher机制了。

首先要知道为什么要有Watcher机制的存在,假如客户端保存了若干服务对象方法对应的服务提供者的地址信息,假如某个服务提供者挂了,在ZooKeeper中表现为服务提供方的某节点消失了。这个时候就需要告知客户端,这个节点消失了,无法继续提供服务了,没有资格继续被客户端缓存在本地了。

从下面的代码中要先知道一个事情,我们在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中可以将这个semm_zhandle取出来使用。

global_watcher函数的参数type和state分别是ZooKeeper服务端返回的事件类型和连接状态。在gloabl_watcher函数中发现状态已经是ZOO_CONNECTED_STATE说明服务端为这个节点已经建立好了和客户端callee的会话。此时调用sem_post(sem)解除主线程阻塞(解除ZkClient::Start()中的阻塞)。

这个同步机制保证了,当ZkClient::Start()执行完后,callee端确定和zookeeper服务端建立好了连接!!

  1. 针对每一个znode的操作,都会有一个watcher。
  2. 当监控的某个对象(znode)发生了变化,则触发watcher事件。父节点、子节点增删改都能触发其watcher。具体有哪些watcher事件,后面会贴一张表,自己去看看!
  3. ZooKeeper中的watcher是一次性的,触发后立即销毁。

ZooKeeper客户端(Callee)首先将Watcher注册到服务端,同时把Watcher对象保存到客户端的Watcher管理器中。当ZooKeeper服务端监听到ZooKeeper中的数据状态发生变化时,服务端主动通知客户端(告知客户端事件类型和状态类型),接着客户端的Watch管理器会触发相关Watcher来回调相应处理逻辑(GlobalWatcher),从而完成整体的数据发布/订阅流程。

请添加图片描述

Watcher的设置和获取在开发中很常见,不同的操作会收到不同的watcher信息。更多内容还是自行google吧,我自己还只有半桶水的功夫。日后会继续学习,专门对ZooKeeper做一个全面细致的剖析

客户端Stub实现

由protobuf生成的供caller调用的RPC方法其实里面都调用了channel_->CallMethod(...)函数。继续深入下去发现这里面的channel_RpcChannel类,RpcChannel类是一个虚类,里面有一个虚方法CallMethod(),也就是说,我们用户需要自己实现一个继承于RpcChannel的派生类,这个派生类要实现CallMethod()的定义。

我们实现的派生类是RpcChannel,其CallMethod()方法会将服务名方法名进行组装,并用protobuf提供的序列化方法序列化,然后通过服务名方法名查找ZooKeeper服务器上提供该服务方法的RpcProvider的地址信息,然后返回。接着再将请求的服务方法及其参数组装并序列化,向RpcProvider发起tcp连接请求,连接建立后将序列化的数据发送给RpcProvider,RpcProvider再路由到特定的Service,然后再等待接收来自RpcServer的返回消息体。

日志模块

借助生产者消费者模型,用户打日志将要写的日志都压入消息队列中,后台单独开一个守护线程负责将消息队列的日志写入到磁盘文件中。具体实现如下。由于此模块与RPC无关,目前替换成了其他开源库。

#pragma once

// 异步写日志队列
template <typename T>
class LockQueue
{
public:
    // 业务调用会生产日志到缓冲队列,可能来自多个业务线程
    void Push(const T &data)
    {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_queue.push(data);
        m_condvariable.notify_one();
    }

    // 守护线程消费队列中的日志,然后写到日志文件
    T Pop()
    {
        std::unique_lock<std::mutex> lock(m_mutex);
        // 日志队列为空,线程进入wait
        m_condvariable.wait(lock, [&m_queue]() { !m_queue.empty(); });
        T data = m_queue.front();
        m_queue.pop();
        return data;
    }

private:
    std::queue<T> m_queue; 
    std::mutex m_mutex;
    std::condition_variable m_condvariable;
};

// Mprpc框架提供的日志系统
class Logger
{
public:
	enum LogLevel
	{
	    INFO,  // 普通信息
	    ERROR, // 错误信息
	};
    // 获取单例
    static Logger &GetInstance()
	{
	    static Logger ins;
	    return ins;
	}

    // 设置日志级别
    void SetLogLevel(LogLevel level)
	{
	    m_loglevel = level;
	}
    // 写日志
    void Log(std::string msg)
	{
	    m_lckQue.Push(msg);
	}

private:
    LogLevel m_loglevel;             // 记录日志级别
    LockQueue<std::string> m_lckQue; // 日志缓冲队列

    Logger()
	{
	    // 启动专门的写日志线程
	    std::thread([&]() {
	        for (;;) {
	            // 获取当前的日期,写入相应的日志文件中 a+
	            time_t now = time(nullptr);
	            tm *nowtm = localtime(&now);
	
	            char file_name[128];
	            sprintf(file_name, "%d-%d-%d-log.txt", nowtm->tm_year+1900, nowtm->tm_mon+1, nowtm->tm_mday);
	
	            FILE *fp = fopen(file_name, "a+");
	            if (fp == nullptr) {
	                std::cout << "logger file:" << file_name << " open error!" << std::endl;
	                exit(EXIT_FAILURE);
	            }
	
	            std::string msg = m_lckQue.Pop();
	
	            char time_buf[128] = {0};
	            sprintf(time_buf, "%d:%d:%d =>[%s] ",
	                nowtm->tm_hour, nowtm->tm_min, nowtm->tm_sec, (m_loglevel == INFO ? "INFO" : "ERROR"));
	            msg.insert(0, time_buf);
	            msg.append("\n");
	
	            fputs(msg.c_str(), fp);
	            fclose(fp);
	        } }).detach(); // 分离线程,实现守护线程
	}
    Logger(const Logger &) = delete;
    Logger(Logger &&) = delete;
};

// 定义宏
#define LOG_INFO(fmt, ...)                        \
    do {                                                      \
        Logger &logger = Logger::GetInstance();            \
        logger.SetLogLevel(INFO);                         \
        char buf[1024] = {0};                              \
        snprintf(buf, 1024, fmt, ##__VA_ARGS__); \
        logger.Log(buf);                                   \
    } while (0);

#define LOG_ERR(fmt, ...)                         \
    do {                                                      \
        Logger &logger = Logger::GetInstance();            \
        logger.SetLogLevel(ERROR);                          \
        char buf[1024] = {0};                              \
        snprintf(buf, 1024, fmt, ##__VA_ARGS__); \
        logger.Log(buf);                                   \
    } while (0);

小结

RPC通信过程

image

服务注册过程

image

其他

客户端调用异步RPC,提高客户端吞吐量

要实现异步的调用,应该有2种方式:

  • 提供一个事件循环+消息队列的机制,所有的调用和参数返回都是往队列里面Post事件,比如Qt、wayland和gRPC
  • 提供回调机制,发送RPC调用时就要把通知回调也一并发送过去。目前来看protobuf是通过 Closure 来作为 wl_callback 来实现的异步。

有没有想过一个问题,假如我一段代码中发起了四个RPC远端调用,假如每一个远端调用都要20ms,那顺序执行下来最快也要80ms吧。在这段时间里,客户端代码基本上都处于cpu挂起状态,cpu利用率比较低下,这就是同步调用的鸡肋之处。要是能实现异步调用,当发起远端调用后就继续主代码的运行逻辑,等远端调用结果回来之后,再去拿结果回来。
 最理想最理想的情况下,就是这四个远端调用之间彼此没有什么依赖,而且再拿到结果之前主代码都可以继续运行,在主代码需要这四个结果做进一步处理之前,这四个结果就已经上交给主代码了。这种完美的情况下,四个远端调用只要20ms。这就是,我们的客户端吞吐量最好的情况下可能提升4倍。
 怎么实现客户端异步调用?由于我个人在项目中并没有实现这块,但是我个人建议去参考这个链接,https://melonshell.github.io/2020/01/25/tech4_rpc/。
 还是稍微提一下大致思路,调用端的异步可以通过调用远端函数的时候注入回调来实现,调用段端发起一次异步请求之后继续处理和RPC调用无关的主代码逻辑,当需要这个RPC请求的结果的时候,假如此时另外一个线程已经从远端服务器上拿到了结果,这个时候可以另外一个线程会执行这个回调,相当于把结果上交给调用这个回调的线程。

服务端异步

服务端的IO线程收到来自远端发来的数据包(远端调用请求数据),之后进行拆包和消息解码反序列化等,再通过解码出来的服务对象方法调用业务逻辑处理函数,有没有思考过这些操作都是在哪个线程中执行的呢?

对于二进制数据包的拆解、解码和反序列化过程应该在IO线程里面处理,而业务逻辑处理应该在专门的业务线程池的线程中处理,如果都在IO线程里面处理的话,那其他IO线程就会受到影响,进而影响整个网络的并发性能。

这时候又来新问题了。那就是业务线程池数量,就算多给业务线程池配几条线程,如果业务逻辑比较耗时,耗时业务也会挤压其他服务。所以最好是为服务端业务处理逻辑也引入异步处理机制,把一个串行的业务逻辑尽量解耦成能异步处理的逻辑,最终结果以回调的方式响应给调用端。

TODO

对于服务端,只能看到自己本地的 ServerInfo 注册表,以及在有服务上线/下线时在zk中修改节点;

对于客户端,只能看到自己本地创建的 Stub 对象。而 Stub 对象会查zk来找到服务对象注册在哪个节点上从而发起网络通信。

健康检测

负载均衡

异常重试机制

熔断限流

启动

关闭

本文作者:3to4

本文链接:https://www.cnblogs.com/3to4/p/18698236

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @ 2025-02-04 15:24  3的4次方  阅读(5)  评论(0编辑  收藏  举报