C++实现Raft算法之更多的细节(clerk与RPC)

本篇细节讲解的是clerk和RPC原理的讲解

clerk

clerk相当于是一个外部的客户端,其作用就是向整个raft集群发起命令并接收响应。

clerk需要与kvServer建立网络链接,那么既然已经实现了已经简单的RPC,那么使用RPC来完成这个过程。

clerk本身的过程非常简单,需要注意的是,对于RPC返回对端不是leader的话,就需要另外再调用另一个kvServer的RPC重试,直到遇到leader。

clerk调用代码如下,调用本身非常简单,client.put和client.get即可:

cpp 复制代码
int main() {
    // 创建一个Clerk对象 client
    Clerk client;
    // 初始化Clerk对象,加载配置文件 "test.conf"
    client.Init("test.conf");
    // 获取当前时间并记录为开始时间
    auto start = now();
    // 定义一个整数变量 count,初始化为 500
    int count = 500;
    // 将 count 的值赋给 tmp 变量
    int tmp = count;
    // 进入一个循环,循环执行 500 次(从 tmp 为 499 到 0)
    while (tmp--) {
        // 调用 client 的 Put 方法,将键 "x" 的值设置为 tmp 的字符串表示
        client.Put("x", std::to_string(tmp));
        // 调用 client 的 Get 方法获取键 "x" 对应的值,赋值给 get1 变量
        std::string get1 = client.Get("x");
        // 打印获取到的值 get1,格式化输出字符串
        std::printf("get return :{%s}\r\n", get1.c_str());
    }
    // 程序正常结束
    return 0;
}

可以再看看client.Init函数,这个函数的作用是连接了所有raftKvServer节点,方式依然是通过RPC的方式,这个是raft节点之间相互连接的过程是一样的:

cpp 复制代码
//这是个Clerk::Init方法的实现
//主要功能是从配置文件中加载Raft节点的IP和端口信息,之后创建与每个节点的连接
// 初始化客户端,加载配置文件并连接 Raft 节点
void Clerk::Init(std::string configFileName) {

    // 创建一个 MprpcConfig 对象,用于加载配置文件
    MprpcConfig config;

    // 加载配置文件,读取配置文件中的内容
    config.LoadConfigFile(configFileName.c_str());

    // 创建一个 vector,用来存储每个 Raft 节点的 IP 和端口
    std::vector<std::pair<std::string, short>> ipPortVt;

    // 遍历所有可能的 Raft 节点,直到没有更多节点为止
    for (int i = 0; i < INT_MAX - 1; ++i) {
        
        // 构造节点名称 "node0", "node1", ..., "nodeN"
        std::string node = "node" + std::to_string(i);

        // 从配置中读取该节点的 IP 地址
        std::string nodeIp = config.Load(node + "ip");

        // 从配置中读取该节点的端口号字符串
        std::string nodePortStr = config.Load(node + "port");

        // 如果该节点的 IP 地址为空,说明没有更多节点,跳出循环
        if (nodeIp.empty()) {
            break;
        }

        // 将该节点的 IP 地址和端口号(转换为 short 类型)添加到 vector 中
        // atoi 用于将字符串转换为整型,如果失败返回 0,这里可以考虑自己实现一个更安全的转换方法
        ipPortVt.emplace_back(nodeIp, atoi(nodePortStr.c_str())); // 这里注意:atoi 并不检查错误情况
    }

    // 遍历刚才保存的所有 Raft 节点的 IP 和端口信息,进行连接操作
    for (const auto &item : ipPortVt) {

        // 获取每个节点的 IP 和端口
        std::string ip = item.first; 
        short port = item.second;

        // 2024-01-04 todo:bug fix
        // 使用当前节点的 IP 和端口创建一个新的 raftServerRpcUtil 对象
        // raftServerRpcUtil 是一个类,负责与指定 IP 和端口的 Raft 服务器建立通信
        auto* rpc = new raftServerRpcUtil(ip, port);

        // 将创建的 rpc 对象存储到 m_servers 向量中
        // 使用智能指针 (shared_ptr) 来管理生命周期,确保对象在不再使用时被正确销毁
        m_servers.push_back(std::shared_ptr<raftServerRpcUtil>(rpc));
    }
}

再看看put函数:

cpp 复制代码
//代码实现了Clerk::PutAppend方法,处理了向Raft集群中的领导节点发送PutAppend请求,并且具有错误重试机制。
// PutAppend 方法:向 Raft 集群的领导节点发送 Put 或 Append 操作。
// key:键,value:值,op:操作类型(Put 或 Append)
void Clerk::PutAppend(std::string key, std::string value, std::string op) { 
    // 增加请求 ID,用于区分不同的请求
    m_requestId++;  // 每次请求都会递增请求 ID,确保请求的唯一性

    // 保存当前请求的 requestId
    auto requestId = m_requestId;

    // 获取当前认为的 Raft 领导节点的 ID,初始化为 m_recentLeaderId
    auto server = m_recentLeaderId;

    // 进入一个循环,不断尝试发送请求,直到成功
    while (true) {
        
        // 创建 PutAppend 请求参数对象 args,设置请求的各个字段
        raftKVRpcProctoc::PutAppendArgs args;
        args.set_key(key);          // 设置 key
        args.set_value(value);      // 设置 value
        args.set_op(op);            // 设置操作类型:Put 或 Append
        args.set_clientid(m_clientId); // 设置客户端 ID
        args.set_requestid(requestId); // 设置请求 ID

        // 创建 PutAppend 回复对象 reply,用于存储响应结果
        raftKVRpcProctoc::PutAppendReply reply;

        // 向 Raft 节点发起 PutAppend 请求,使用当前的领导节点(server)
        bool ok = m_servers[server]->PutAppend(&args, &reply);

        // 如果请求失败或返回的是 ErrWrongLeader(非领导节点),需要重试
        if (!ok || reply.err() == ErrWrongLeader) {

            // 打印调试信息,说明请求失败,尝试切换到新的领导节点重试
            DPrintf("【Clerk::PutAppend】原以为的leader:{%d}请求失败,向新leader{%d}重试  ,操作:{%s}", server, server + 1, op.c_str());
            
            // 如果请求失败(rpc 失败),打印相应的失败原因
            if (!ok) {
                DPrintf("重试原因 ,rpc失败 ,");
            }

            // 如果返回的错误是 ErrWrongLeader,说明当前节点不是领导节点,需要切换到新的领导节点
            if (reply.err() == ErrWrongLeader) {
                DPrintf("重试原因:非leader");
            }

            // 选择下一个 Raft 节点进行重试,使用模运算循环切换
            server = (server + 1) % m_servers.size();  // 选择下一个节点进行重试
            continue;  // 继续进行下一次请求
        }

        // 如果请求成功且返回的错误为 OK,说明操作已成功
        if (reply.err() == OK) {
            // 保存当前成功的领导节点 ID,以便下次直接联系该节点
            m_recentLeaderId = server;
            return;  // 成功后返回,不再重试
        }
    }
}

这里可以注意。

m_requestId++; m_requestId每次递增。

m_recentLeaderId; m_recentLeaderId是每个clerk初始化的时候随机生成的。

这两个变量的作用是为了维护上一篇所述的"线性一致性"的概念。

server = (server+1)%m_servers.size(); 如果失败的话就让clerk循环节点进行重试。

RPC

项目使用到的RPC高度依赖protobuf

RPC是一种使得分布式系统中的不同模块之间能够透明地进行远程调用的技术,使得开发者可以更方便地构建分布式系统,而不用过多关注底层通信细节,调用另一台机器的方法会表现的像调用本地的方法一样

那么无论对外表现如何,只要设计多个主机之间的通信,必不可少的就是网络通讯这一步

我们看看一次RPC请求到底干了什么?

首先看下【准备:请求参数、返回参数(这里返回参数的值没有意义)、调用哪个方法】这一步,这一步需要发起者自己完成,如下:

在填充完请求值和返回值之后,就可以实际调用方法了。

我们点进去看看:

cpp 复制代码
void FiendServiceRpc_Stub::GetFriendsList(::PROTOBUF_NAMESPACE_ID::RpcController* controller,
                              const ::fixbug::GetFriendsListRequest* request,
                              ::fixbug::GetFriendsListResponse* response,
                              ::google::protobuf::Closure* done) {
  channel_->CallMethod(descriptor()->method(0),
                       controller, request, response, done);
}

可以看到这里相当于是调用了channel_->CallMethod方法,只是第一个参数变成了descriptor()->method(0),其他参数都是我们传进去的参数没有改变,而这个descriptor()->method(0)存在的目的其实就是为了表示我们到底是调用的哪个方法。

到这里远端调用的东西就齐活了:方法、请求参数、响应参数。

还记得在最开始生成stub的我们写的是:fixbug::FiendServiceRpc_Stub stub(new MprpcChannel(ip, port, true));,因此这个channel_本质上是我们自己实现的MprpcChannel类,而channel_->CallMethod本质上就是调用的MprpcChannel的CallMethod方法

我们简单看下这个CallMethod方法干了什么?按照

这样的方式将所需要的参数来序列化,序列化之后再通过send函数循环发送即可

可能的改进:在代码中send_rpc_str.insert(0, std::string((char *)&header_size, 4));我们可以看到头部长度固定是4个字节,那么这样的设计是否合理?如果不合理如何改进呢?

到了这一步,所有的报文已经发送到了对端,即接收RPC的一方,那么此时应该在对端进行:这一系列的步骤。

这一系列步骤的主要函数发生在:RpcProvider::OnMessage

我们看下这个函数干了什么?

首先根据上方序列化的规则进行反序列化,解析出相关的参数。

然后根据你要调用的方法名去找到实际的方法调用即可。

相关函数是在NotifyService函数中中提前注册好了,因此这里可以找到然后调用。

在这个过程中使用了protobuf提供的closure绑定了一个回调函数用于在实际调用完方法之后进行反序列化相关操作。

为啥这么写就算注册完反序列化的回调了呢?肯定是protobuf为我们提供了相关的功能,在后面代码流程中也会看到相对应的过程。

cpp 复制代码
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);,为什么这个方法就可以调用到本地的方法呢?

这个函数会因为多态实际调用生成的pb.cc文件中的CallMethod方法。

cpp 复制代码
void FiendServiceRpc::CallMethod(const ::PROTOBUF_NAMESPACE_ID::MethodDescriptor* method,
                             ::PROTOBUF_NAMESPACE_ID::RpcController* controller,
                             const ::PROTOBUF_NAMESPACE_ID::Message* request,
                             ::PROTOBUF_NAMESPACE_ID::Message* response,
                             ::google::protobuf::Closure* done)

我们看下这个函数干了什么?

cpp 复制代码
switch(method->index()) {
    case 0:
      GetFriendsList(controller,
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<const ::fixbug::GetFriendsListRequest*>(
                 request),
             ::PROTOBUF_NAMESPACE_ID::internal::DownCast<::fixbug::GetFriendsListResponse*>(
                 response),
             done);
      break;
    default:
      GOOGLE_LOG(FATAL) << "Bad method index; this should never happen.";
      break;
}

这个函数和上面讲过的FiendServiceRpc_Stub::GetFriendsList方法有似曾相识的感觉。都是通过xxx->index来调用实际的方法。

正常情况下校验会通过,即触发case 0。

然后会调用我们在FriendService中重写的GetFriendsList方法。

cpp 复制代码
// 重写基类方法
void GetFriendsList(::google::protobuf::RpcController *controller,
                    const ::fixbug::GetFriendsListRequest *request,
                    ::fixbug::GetFriendsListResponse *response,
                    ::google::protobuf::Closure *done) {
    uint32_t userid = request->userid();
    std::vector<std::string> friendsList = GetFriendsList(userid);
    response->mutable_result()->set_errcode(0);
    response->mutable_result()->set_errmsg("");
    for (std::string &name: friendsList) {
        std::string *p = response->add_friends();
        *p = name;
    }
    done->Run();
}

这个函数逻辑比较简单:调用本地的方法,填充返回值response。

然后调用回调函数done->Run();,还记得我们前面注册了回调函数吗?

cpp 复制代码
google::protobuf::Closure *done = google::protobuf::NewCallback<RpcProvider,
                                                                const muduo::net::TcpConnectionPtr &,
                                                                google::protobuf::Message *>(this,
                                                                                             &RpcProvider::SendRpcResponse,
                                                                                             conn, response);

在回调真正执行之前,我们本地方法已经触发了并填充完返回值了。

此时回看原来的图,我们还需要序列化返回结果和将序列化后的数据发送给对端。

done->Run()实际调用的是:RpcProvider::SendRpcResponse。

这个方法比较简单,不多说了。

到这里,RPC提供方的流程就结束了。

从时间节点上来说,此时应该对端来接收返回值了,还在 MprpcChannel::CallMethod部分:

cpp 复制代码
/*
从时间节点来说,这里将请求发送过去之后rpc服务的提供者就会开始处理,返回的时候就代表着已经返回响应了
*/
// 接收rpc请求的响应值
char recv_buf[1024] = {0};
int recv_size = 0;
if (-1 == (recv_size = recv(m_clientFd, recv_buf, 1024, 0)))
{
    close(m_clientFd); m_clientFd = -1;
    char errtxt[512] = {0};
    sprintf(errtxt, "recv error! errno:%d", errno);
    controller->SetFailed(errtxt);
    return;
}
// 反序列化rpc调用的响应数据
// std::string response_str(recv_buf, 0, recv_size); // bug:出现问题,recv_buf中遇到\0后面的数据就存不下来了,导致反序列化失败
// if (!response->ParseFromString(response_str))
if (!response->ParseFromArray(recv_buf, recv_size))
{
    char errtxt[1050] = {0};
    sprintf(errtxt, "parse error! response_str:%s", recv_buf);
    controller->SetFailed(errtxt);
    return;
}

将接受到的数据按照情况实际序列化成response即可。

这里就可以看出现在的RPC是不支持异步的,因为在MprpcChannel::CallMethod方法中发送完数据后就会一直等待着去接收。

protobuf库中充满了多态,因此推荐大家阅读的时候采用debug的方式。

注:因为目前RPC的网络通信采用的是muduo,muduo支持函数回调,即在对端发送信息来之后就会调用注册好的函数,函数注册代码在:

cpp 复制代码
m_muduo_server->setMessageCallback(std::bind(&RpcProvider::OnMessage, this, std::placeholders::_1,
                                        std::placeholders::_2, std::placeholders::_3));
相关推荐
凡人的AI工具箱24 分钟前
40分钟学 Go 语言高并发:RPC服务开发实战
开发语言·后端·性能优化·rpc·golang
Hacker_LaoYi1 小时前
TCP/IP协议图--TCP/IP基础
网络·网络协议·tcp/ip
运维自动化&云计算1 小时前
华为交换机与锐捷交换机端口链路聚合的配置
服务器·网络·华为
羊村懒哥1 小时前
linux-安全-iptables防火墙基础笔记
linux·网络·安全
星原飞火1 小时前
2-2-18-13 QNX系统架构之原生网络(Qnet)
网络·车载系统·系统架构·qnx·blackberry·qnet
CodeGrindstone1 小时前
Muduo网络库剖析 --- 架构设计
网络·c++·网络协议·tcp/ip
9毫米的幻想1 小时前
【C++】—— set 与 multiset
开发语言·c++·rpc
爱学测试的李木子2 小时前
Jmeter —— jmeter利用取样器中http发送请求
网络协议·jmeter·http
linnux领域2 小时前
使用ensp搭建内外互通,使用路由跨不同vlan通信。
网络
夏子曦2 小时前
网络——Socket与WebSocket
网络·websocket·网络协议