【C++ 基于微服务的即时通讯系统】etcd + brpc 从安装到服务注册发现全链路实战


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言:
  • [一. etcd 的安装与使用](#一. etcd 的安装与使用)
  • [二、etcd 的二次封装:服务注册与发现组件](#二、etcd 的二次封装:服务注册与发现组件)
    • [2.1 封装思路](#2.1 封装思路)
    • [2.2 服务注册客户端 Registry](#2.2 服务注册客户端 Registry)
    • [2.3 服务发现客户端 Discovery](#2.3 服务发现客户端 Discovery)
  • [三、etcd 封装组件测试](#三、etcd 封装组件测试)
    • [3.1 服务注册端测试](#3.1 服务注册端测试)
    • [3.2 服务发现端测试](#3.2 服务发现端测试)
    • [3.3 测试流程](#3.3 测试流程)
  • [四、brpc 的安装与核心介绍](#四、brpc 的安装与核心介绍)
    • [4.1 brpc 框架简介](#4.1 brpc 框架简介)
    • [4.2 brpc 编译安装](#4.2 brpc 编译安装)
    • [4.3 核心类与接口梳理](#4.3 核心类与接口梳理)
      • [1. Protobuf 相关](#1. Protobuf 相关)
      • [2. 服务端相关](#2. 服务端相关)
      • [3. 客户端相关](#3. 客户端相关)
  • [五、brpc 基础使用样例与测试](#五、brpc 基础使用样例与测试)
    • [5.1 proto 接口定义](#5.1 proto 接口定义)
    • [5.2 服务端实现](#5.2 服务端实现)
    • [5.3 客户端实现](#5.3 客户端实现)
    • [5.4 编译与测试](#5.4 编译与测试)
  • [六、brpc 信道管理封装](#六、brpc 信道管理封装)
    • [6.1 封装思路](#6.1 封装思路)
    • [6.2 单服务信道管理:ServiceChannel](#6.2 单服务信道管理:ServiceChannel)
    • [6.3 全局服务信道管理:ServiceManager](#6.3 全局服务信道管理:ServiceManager)
    • [6.4 数据结构选型:为什么用 vector 而不是哈希表](#6.4 数据结构选型:为什么用 vector 而不是哈希表)
  • [七、etcd + brpc 全链路联调](#七、etcd + brpc 全链路联调)
    • [7.1 联调整体思路](#7.1 联调整体思路)
    • [7.2 服务端改造](#7.2 服务端改造)
    • [7.3 客户端改造](#7.3 客户端改造)
    • [7.4 联调测试与踩坑记录](#7.4 联调测试与踩坑记录)
  • 八、核心要点总结
  • 结尾:

前言:

做 C++ 微服务架构,绕不开两个核心问题:服务之间怎么找到彼此?服务之间怎么高效通信?在我们这套即时通讯系统里,我们选择了 etcd 做服务注册发现中心 ,brpc 做 RPC 通信框架。这俩是国内 C++ 微服务项目的经典搭配,性能够强、生态成熟,踩坑也有大量前人经验可以参考。

本文就顺着项目落地的完整路径,从 etcd 安装、客户端使用、业务封装,到 brpc 编译、基础调用、信道管理,最后把两个组件完全打通,实现完整的「服务注册-服务发现-RPC 调用」全链路。全程附带源码和实战踩坑记录,跟着走一遍就能直接落地到自己的项目里。


一. etcd 的安装与使用

1.1 etcd 基础介绍

etcd 是 Go 语言编写的分布式、高可用一致性键值存储系统,底层基于 Raft 算法保证集群数据一致,常用来做配置共享和服务发现,相比 Zookeeper 更加轻量化。

它的核心能力很简单:存键值对 + 监控变化。

  • 服务启动时,把自己的地址作为键值对写入 etcd,完成「服务注册」
  • 调用方从 etcd 读取键值对,拿到服务地址,完成「服务发现」
  • 配合租约 + 保活机制:服务绑定一个有有效期的租约,定期续租;如果服务挂了,续租停止,租约到期后 etcd 自动删除对应数据
  • 配合 watch 机制:调用方可以监控指定目录,数据有新增、删除时,etcd 会实时推送事件通知客户端

这一套组合下来,就实现了微服务里最核心的「自动注册、自动发现、故障自动摘除」。

1.2 etcd 安装与配置

安装与启动

Ubuntu 下可以直接用 apt 安装,简单快捷:

复制代码
# 安装etcd
sudo apt-get install etcd

# 启动服务
sudo systemctl start etcd

# 设置开机自启
sudo systemctl enable etcd

核心配置说明

配置文件路径为 /etc/default/etcd,几个核心参数解释:

复制代码
# 节点名称,默认default
ETCD_NAME="etcd1"
# 数据存储目录
ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
# 客户端监听地址,2379是客户端默认端口
ETCD_LISTEN_CLIENT_URLS="[http://0.0.0.0:2379](http://0.0.0.0:2379),[http://127.0.0.1:2379](http://127.0.0.1:2379)"
# 对外宣告的客户端地址,外网访问要填公网IP
ETCD_ADVERTISE_CLIENT_URLS="http://你的公网IP:2379"

端口说明:2379 是客户端访问端口 ,用于业务读写数据;2380 是集群节点间通信端口,单节点部署用不到。

验证与版本坑

安装完成后用 etcdctl 验证,这里有个新手必踩的坑: 默认 etcdctl 使用 v2 API,执行 put 命令会报错 No help topic for 'put'。

解决方法是配置环境变量,指定使用 v3 API:

复制代码
sudo vi /etc/profile
# 末尾添加
export ETCDCTL_API=3

# 加载配置
source /etc/profile

测试读写:

复制代码
etcdctl put mykey "this is awesome"
etcdctl get mykey

能正常写入和读取,说明 etcd 就安装好了。

1.3 etcd-cpp-apiv3 客户端与接口介绍

我们是 C++ 项目,需要对应的 C++ 客户端,这里用的是 etcd-cpp-apiv3。

依赖安装

先把依赖装全,避免编译报错:

复制代码
sudo apt-get install libboost-all-dev libssl-dev
sudo apt-get install libprotobuf-dev protobuf-compiler-grpc
sudo apt-get install libgrpc-dev libgrpc++-dev libcpprest-dev

编译安装客户端

复制代码
git clone [https://github.com/etcd-cpp-apiv3/etcd-cpp-apiv3.git](https://github.com/etcd-cpp-apiv3/etcd-cpp-apiv3.git)
cd etcd-cpp-apiv3
mkdir build && cd build
cmake ..
make -j$(nproc)
sudo make install

安装完成后,头文件在 /usr/include/etcd/,动态库为 libetcd-cpp-api.so。

核心类与接口

给大家梳理下最常用的几个核心类,不用全记,知道各自分工就行:

  • Client:客户端主类,提供键值增删改查、租约、保活、锁等所有接口
  • Response :请求响应对象,is_ok() 判断是否成功,error_message() 获取错误信息,events() 获取 watch 事件列表
  • Value :键值对对象,key() 获取键,as_string() 获取值,is_dir() 判断是否为目录
  • KeepAlive :租约保活对象,Lease() 获取租约 ID;对象析构则停止保活,租约会自动到期
  • Watcher:监控对象,监听指定 key/目录的变化,触发回调函数,支持递归监控目录

核心操作接口:

复制代码
// 写入键值对,可绑定租约
pplx::task<Response> put(const std::string& key, const std::string& value, int64_t leaseId = 0);

// 列出目录下所有键值
pplx::task<Response> ls(const std::string& key);

// 创建租约,ttl为有效期(秒)
pplx::task<Response> leasegrant(int ttl);

// 获取保活对象,自动持续续租
pplx::task<std::shared_ptr<KeepAlive>> leasekeepalive(int ttl);

1.4 基础使用样例

我们写两个最简单的例子:一个写数据注册服务,一个读数据并监控变化。

服务注册端:put.cc

复制代码
#include <etcd/Client.hpp>
#include <etcd/KeepAlive.hpp>
#include <etcd/Response.hpp>
#include <iostream>
#include <thread>

int main(int argc, char* argv[]) {
    std::string etcd_host = "[http://127.0.0.1:2379](http://127.0.0.1:2379)";
    etcd::Client client(etcd_host);

    // 获取3秒租约的保活对象,内部会自动持续续租
    auto keep_alive = client.leasekeepalive(3).get();
    auto lease_id = keep_alive->Lease();

    // 注册用户服务,绑定租约,服务挂了自动删除
    auto resp1 = client.put("/service/user", "127.0.0.1:8080", lease_id).get();
    if (!resp1.is_ok()) {
        std::cout << "注册失败:" << resp1.error_message() << std::endl;
        return -1;
    }

    // 注册好友服务,不绑定租约,永久存在
    auto resp2 = client.put("/service/friend", "127.0.0.1:9090").get();
    if (!resp2.is_ok()) {
        std::cout << "注册失败:" << resp2.error_message() << std::endl;
        return -1;
    }

    std::cout << "服务注册完成,保持运行10秒" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(10));
    return 0;
}

服务发现端:get.cc

复制代码
#include <etcd/Client.hpp>
#include <etcd/Watcher.hpp>
#include <etcd/Response.hpp>
#include <iostream>

void callback(const etcd::Response& resp) {
    if (!resp.is_ok()) {
        std::cout << "监控错误:" << resp.error_message() << std::endl;
        return;
    }

    for (auto const& ev : resp.events()) {
        if (ev.event_type() == etcd::Event::EventType::PUT) {
            std::cout << "服务上线/更新: " 
                      << ev.kv().key() << " -> " << ev.kv().as_string() << std::endl;
        } else if (ev.event_type() == etcd::Event::EventType::DELETE_) {
            std::cout << "服务下线: " 
                      << ev.prev_kv().key() << " -> " << ev.prev_kv().as_string() << std::endl;
        }
    }
}

int main() {
    std::string etcd_host = "[http://127.0.0.1:2379](http://127.0.0.1:2379)";
    etcd::Client client(etcd_host);

    // 先全量获取一次当前所有服务
    auto resp = client.ls("/service").get();
    if (resp.is_ok()) {
        std::cout << "当前已有服务:" << std::endl;
        for (size_t i = 0; i < resp.keys().size(); ++i) {
            std::cout << resp.value(i).key() << " -> " 
                      << resp.value(i).as_string() << std::endl;
        }
    }

    // 递归监控/service目录下所有变化
    etcd::Watcher watcher(client, "/service", callback, true);
    watcher.Wait(); // 阻塞等待事件
    return 0;
}

编译与运行

复制代码
# 编译
g++ -std=c++17 put.cc -o put -letcd-cpp-api -lcpprest
g++ -std=c++17 get.cc -o get -letcd-cpp-api -lcpprest

# 先运行发现端
./get

# 再运行注册端
./put

运行现象:

  1. 启动 get 程序,先打印当前已有的服务
  2. 启动 put 程序,get 端立刻收到两个服务的 PUT 事件
  3. 10 秒后 put 程序退出,大约 3 秒后,get 端收到 user 服务的 DELETE 事件(带租约的自动删除),friend 服务则保留

二、etcd 的二次封装:服务注册与发现组件

原生 etcd 客户端是通用的键值存储接口,直接写在业务里既零散又容易出错。我们把它封装成两个业务专用的类,完全贴合微服务场景。

2.1 封装思路

封装的核心原则:屏蔽底层细节,只暴露业务需要的能力。

  • Registry 服务注册客户端:服务启动时调用,传入服务名和地址,自动完成租约、保活、注册;服务退出自动注销
  • Discovery 服务发现客户端:调用方使用,指定关注的服务根目录,设置上线/下线回调,自动完成全量拉取和实时监控

这样业务层完全不用知道 etcd、租约、watch 这些概念,只管注册和接收通知就行。

2.2 服务注册客户端 Registry

复制代码
#pragma once
#include <etcd/Client.hpp>
#include <etcd/KeepAlive.hpp>
#include <etcd/Response.hpp>
#include <memory>
#include <string>
#include "logger.hpp"

class Registry {
public:
    using ptr = std::shared_ptr<Registry>;

    Registry(const std::string& host) 
        : _client(std::make_shared<etcd::Client>(host))
        , _keep_alive(_client->leasekeepalive(3).get())
        , _lease_id(_keep_alive->Lease()) {}

    ~Registry() {
        _keep_alive->Cancel(); // 析构取消保活,租约到期自动下线
    }

    // 注册服务,key为服务路径,val为访问地址
    bool registry(const std::string& key, const std::string& val) {
        auto resp = _client->put(key, val, _lease_id).get();
        if (!resp.is_ok()) {
            LOG_ERROR("注册服务失败: {}", resp.error_message());
            return false;
        }
        return true;
    }

private:
    std::shared_ptr<etcd::Client> _client;
    std::shared_ptr<etcd::KeepAlive> _keep_alive;
    uint64_t _lease_id;
};

代码解读:

  • 构造时直接创建 3 秒租约并开启保活,不用业务层关心租约细节
  • 析构函数自动取消保活,服务正常退出时,最多 3 秒就会从注册中心摘除
  • 所有注册的 key 都绑定同一个租约,一个服务多个接口也能同时下线

2.3 服务发现客户端 Discovery

复制代码
#pragma once
#include <etcd/Client.hpp>
#include <etcd/Watcher.hpp>
#include <etcd/Response.hpp>
#include <functional>
#include <memory>
#include <string>
#include "logger.hpp"

class Discovery {
public:
    using NotifyCallback = std::function<void(std::string, std::string)>;

    Discovery(const std::string& host, const std::string& basedir,
              const NotifyCallback& put_cb, const NotifyCallback& del_cb)
        : _client(std::make_shared<etcd::Client>(host))
        , _put_cb(put_cb)
        , _del_cb(del_cb) {
        // 1. 先全量拉取一次当前已有服务
        auto resp = _client->ls(basedir).get();
        if (!resp.is_ok()) {
            LOG_ERROR("获取服务列表失败: {}", resp.error_message());
            return;
        }
        for (size_t i = 0; i < resp.keys().size(); ++i) {
            if (_put_cb) _put_cb(resp.key(i), resp.value(i).as_string());
        }

        // 2. 开启监控,后续变化实时回调
        _watcher = std::make_shared<etcd::Watcher>(
            *_client.get(), basedir,
            std::bind(&Discovery::callback, this, std::placeholders::_1),
            true);
    }

private:
    void callback(const etcd::Response& resp) {
        if (!resp.is_ok()) {
            LOG_ERROR("服务监控错误: {}", resp.error_message());
            return;
        }
        for (auto const& ev : resp.events()) {
            if (ev.event_type() == etcd::Event::EventType::PUT) {
                LOG_DEBUG("新增服务: {} - {}", ev.kv().key(), ev.kv().as_string());
                if (_put_cb) _put_cb(ev.kv().key(), ev.kv().as_string());
            } else if (ev.event_type() == etcd::Event::EventType::DELETE_) {
                LOG_DEBUG("下线服务: {} - {}", ev.prev_kv().key(), ev.prev_kv().as_string());
                if (_del_cb) _del_cb(ev.prev_kv().key(), ev.prev_kv().as_string());
            }
        }
    }

    NotifyCallback _put_cb;
    NotifyCallback _del_cb;
    std::shared_ptr<etcd::Client> _client;
    std::shared_ptr<etcd::Watcher> _watcher;
};

代码解读:

  • 构造函数里做了两件事:先全量拉取已有服务,保证启动就有数据;再开启 watch,保证后续变化实时感知
  • 回调类型用 std::function,灵活适配各种业务场景,和上层解耦
  • 递归监控目录,子节点的增删都能收到通知

三、etcd 封装组件测试

封装完之后,我们写两个测试程序,验证注册和发现是否正常工作。

3.1 服务注册端测试

复制代码
#include "etcd.hpp"
#include <gflags/gflags.h>

DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(instance_name, "/echo/instance1", "当前实例名称");
DEFINE_string(access_host, "127.0.0.1:8080", "服务访问地址");

int main(int argc, char* argv[]) {
    google::ParseCommandLineFlags(&argc, &argv, true);

    Registry::ptr rclient = std::make_shared<Registry>(FLAGS_etcd_host);
    rclient->registry(FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);

    std::cout << "服务注册成功,运行中..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(60));
    return 0;
}

注意:实例名必须唯一 ,同一个服务的不同节点要用不同的实例名,比如 /service/echo/instance1、/service/echo/instance2,否则后注册的会覆盖先注册的。

3.2 服务发现端测试

复制代码
#include "etcd.hpp"
#include <gflags/gflags.h>

DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");

void online(const std::string& service_name, const std::string& service_host) {
    std::cout << "服务上线: " << service_name << " -> " << service_host << std::endl;
}

void offline(const std::string& service_name, const std::string& service_host) {
    std::cout << "服务下线: " << service_name << " -> " << service_host << std::endl;
}

int main(int argc, char* argv[]) {
    google::ParseCommandLineFlags(&argc, &argv, true);

    Discovery::ptr dclient = std::make_shared<Discovery>(
        FLAGS_etcd_host, FLAGS_base_service, online, offline);

    std::cout << "服务发现启动,等待事件..." << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(600));
    return 0;
}

3.3 测试流程

  1. 先启动发现端,此时没有服务注册,控制台没有上线日志
  2. 再启动注册端,发现端立刻打印服务上线日志
  3. 关闭注册端,大约 3 秒后,发现端打印服务下线日志

能完整复现这个现象,说明封装的组件就没问题了。

四、brpc 的安装与核心介绍

4.1 brpc 框架简介

brpc 是百度开源的工业级 C++ RPC 框架,也是国内 C++ 后端项目的主流选择。它支持多协议(HTTP、gRPC、Redis、Thrift 等),性能优异,同步异步调用都支持,还有完善的服务治理能力。

和大家常听的 gRPC 简单对比:

维度 brpc gRPC
出品方 百度 Google
语言 C++ 为主 多语言
中文文档 完善 较少
编译难度 低,依赖少 高,子模块多
协议支持 多协议兼容 仅 gRPC

我们项目选 brpc,核心原因就是 C++ 生态友好,中文资料多,编译坑少,和 etcd 搭配做微服务非常成熟。

4.2 brpc 编译安装

安装依赖

复制代码
sudo apt-get install -y git g++ make libssl-dev libprotobuf-dev protobuf-compiler libleveldb-dev

源码编译安装

复制代码
git clone [https://github.com/apache/brpc.git](https://github.com/apache/brpc.git)
cd brpc
mkdir build && cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr ..
make -j$(nproc)
sudo make install

安装完成后,头文件和库文件都会安装到系统目录,直接链接即可使用。

4.3 核心类与接口梳理

brpc 类很多,核心的就几组,给大家梳理清楚:

1. Protobuf 相关

RPC 的接口和数据结构用 protobuf 定义,protoc 会自动生成代码:

  • Service 基类:服务端继承它,重写业务方法
  • Stub 类:客户端用它发起 RPC 调用
  • RpcController :RPC 上下文,Failed() 判断请求是否失败,ErrorText() 获取错误信息
  • Closure :回调对象,异步调用时使用;服务端处理完调用 Run() 才算真正返回响应
  • ClosureGuard :RAII 管理 Closure,析构自动调用 Run(),防止忘记写返回语句

2. 服务端相关

  • Server:服务器核心类,监听端口,分发请求
  • ServerOptions:服务器配置,比如工作线程数、空闲连接超时
  • AddService :注册服务到服务器,第二个参数指定对象所有权,SERVER_DOESNT_OWN_SERVICE 表示我们自己管理对象生命周期
  • RunUntilAskedToQuit():阻塞运行,直到收到 Ctrl+C 退出信号

3. 客户端相关

  • Channel:通信信道,一个 Channel 对应一个服务节点,底层复用 TCP 连接
  • ChannelOptions:信道配置,连接超时、请求超时、重试次数、协议类型
  • Init():初始化信道,指定服务地址和配置

五、brpc 基础使用样例与测试

我们用一个最简单的 Echo 服务,走一遍完整的服务端、客户端开发流程。

5.1 proto 接口定义

先写 main.proto,定义请求、响应和服务接口:

复制代码
syntax = "proto3";
package example;

option cc_generic_services = true;

message EchoRequest {
    string message = 1;
}

message EchoResponse {
    string message = 1;
}

service EchoService {
    rpc Echo(EchoRequest) returns (EchoResponse);
}

生成 C++ 代码:

复制代码
protoc --cpp_out=./ main.proto

会生成 main.pb.h 和 main.pb.cc 两个文件。

5.2 服务端实现

复制代码
#include <brpc/server.h>
#include <butil/logging.h>
#include "main.pb.h"

// 1. 实现服务接口
class EchoServiceImpl : public example::EchoService {
public:
    void Echo(google::protobuf::RpcController* controller,
              const ::example::EchoRequest* request,
              ::example::EchoResponse* response,
              ::google::protobuf::Closure* done) override {
        // RAII管理done,函数退出自动调用Run()
        brpc::ClosureGuard rpc_guard(done);
        
        std::cout << "收到消息: " << request->message() << std::endl;
        std::string str = request->message() + " --这是响应!!";
        response->set_message(str);
    }
};

int main() {
    // 关闭brpc默认日志,避免和我们自己的日志冲突
    logging::LoggingSettings settings;
    settings.logging_dest = logging::LOG_TO_NONE;
    logging::InitLogging(settings);

    // 2. 创建服务器并注册服务
    brpc::Server server;
    EchoServiceImpl echo_service;
    
    int ret = server.AddService(&echo_service, brpc::ServiceOwnership::SERVER_DOESNT_OWN_SERVICE);
    if (ret == -1) {
        std::cout << "添加服务失败!" << std::endl;
        return -1;
    }

    // 3. 配置并启动服务器
    brpc::ServerOptions options;
    options.idle_timeout_sec = -1; // 连接不超时
    options.num_threads = 1;       // 工作线程数
    
    ret = server.Start(8080, &options);
    if (ret == -1) {
        std::cout << "启动服务器失败!" << std::endl;
        return -1;
    }

    std::cout << "服务器启动成功,端口8080" << std::endl;
    server.RunUntilAskedToQuit();
    return 0;
}

5.3 客户端实现

同步调用

复制代码
#include <brpc/channel.h>
#include "main.pb.h"

int main() {
    // 1. 初始化信道
    brpc::ChannelOptions options;
    options.connect_timeout_ms = -1;
    options.timeout_ms = -1;
    options.max_retry = 3;
    options.protocol = "baidu_std";

    brpc::Channel channel;
    int ret = channel.Init("127.0.0.1:8080", &options);
    if (ret == -1) {
        std::cout << "初始化信道失败!" << std::endl;
        return -1;
    }

    // 2. 创建stub对象
    example::EchoService_Stub stub(&channel);

    // 3. 发起RPC调用
    example::EchoRequest req;
    req.set_message("你好~比特");
    
    brpc::Controller cntl;
    example::EchoResponse rsp;
    
    stub.Echo(&cntl, &req, &rsp, nullptr); // nullptr表示同步调用
    if (cntl.Failed()) {
        std::cout << "RPC调用失败: " << cntl.ErrorText() << std::endl;
        return -1;
    }

    std::cout << "收到响应: " << rsp.message() << std::endl;
    return 0;
}

异步调用

异步调用不会阻塞,响应通过回调返回,适合高并发场景:

复制代码
void echo_callback(brpc::Controller* cntl, example::EchoResponse* rsp) {
    std::unique_ptr<brpc::Controller> cntl_guard(cntl);
    std::unique_ptr<example::EchoResponse> rsp_guard(rsp);

    if (cntl->Failed()) {
        std::cout << "RPC调用失败: " << cntl->ErrorText() << std::endl;
        return;
    }
    std::cout << "收到响应: " << rsp->message() << std::endl;
}

int main() {
    // ... 信道初始化和同步一样 ...

    example::EchoRequest req;
    req.set_message("你好~比特");

    // 异步调用:controller和response必须堆上分配
    brpc::Controller* cntl = new brpc::Controller();
    example::EchoResponse* rsp = new example::EchoResponse();
    auto closure = google::protobuf::NewCallback(echo_callback, cntl, rsp);

    stub.Echo(cntl, &req, rsp, closure);

    std::cout << "异步调用已发出" << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(3)); // 等待回调
    return 0;
}

重点提醒:异步调用的 Controller 和 Response 必须用 new 创建,不能用栈变量!因为函数返回后栈对象就销毁了,回调里访问会直接崩溃。

5.4 编译与测试

复制代码
all: server client

server: server.cc main.pb.cc
	g++ -std=c++17 $^ -o $@ -lbrpc -lgflags -lssl -lcrypto -lprotobuf -lleveldb

client: client.cc main.pb.cc
	g++ -std=c++17 $^ -o $@ -lbrpc -lgflags -lssl -lcrypto -lprotobuf -lleveldb

测试流程:

  1. 运行 ./server 启动服务
  2. 运行 ./client 发起调用,控制台打印响应说明调用成功
  3. 异步版本会先打印「异步调用已发出」,再打印响应,验证非阻塞

六、brpc 信道管理封装

基础用法里 Channel 是写死地址的,但微服务里节点是动态的,还需要负载均衡,所以我们要封装一层信道管理。

6.1 封装思路

封装目标:按服务名管理所有节点信道,支持动态增删,自带 RR 轮询负载均衡。

分两层设计:

  1. ServiceChannel:管理单个服务的所有节点信道,负责节点增删、轮询选节点
  2. ServiceManager:管理所有服务的 ServiceChannel,对外只需要「按服务名取一个信道」

上层业务完全不用关心节点地址、有多少个节点,只需要传服务名就能拿到可用信道。

6.2 单服务信道管理:ServiceChannel

复制代码
#pragma once
#include <brpc/channel.h>
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <memory>
#include "logger.hpp"

class ServiceChannel {
public:
    using ChannelPtr = std::shared_ptr<brpc::Channel>;
    using ptr = std::shared_ptr<ServiceChannel>;

    ServiceChannel(const std::string& name)
        : _service_name(name), _index(0) {}

    // 节点上线:新增信道
    void append(const std::string& host) {
        auto channel = std::make_shared<brpc::Channel>();
        brpc::ChannelOptions options;
        options.connect_timeout_ms = -1;
        options.timeout_ms = -1;
        options.max_retry = 3;
        options.protocol = "baidu_std";

        int ret = channel->Init(host.c_str(), &options);
        if (ret == -1) {
            LOG_ERROR("初始化信道失败: {} - {}", _service_name, host);
            return;
        }

        std::unique_lock<std::mutex> lock(_mutex);
        _hosts.insert({host, channel});
        _channels.push_back(channel);
    }

    // 节点下线:删除信道
    void remove(const std::string& host) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _hosts.find(host);
        if (it == _hosts.end()) {
            LOG_WARN("删除节点未找到: {} - {}", _service_name, host);
            return;
        }

        for (auto vit = _channels.begin(); vit != _channels.end(); ++vit) {
            if (*vit == it->second) {
                _channels.erase(vit);
                break;
            }
        }
        _hosts.erase(it);
    }

    // RR轮询获取一个信道
    ChannelPtr choose() {
        std::unique_lock<std::mutex> lock(_mutex);
        if (_channels.empty()) {
            return nullptr;
        }
        int32_t idx = _index++ % _channels.size();
        return _channels[idx];
    }

private:
    std::mutex _mutex;
    int32_t _index;
    std::string _service_name;
    std::vector<ChannelPtr> _channels;       // 用于轮询,顺序访问快
    std::unordered_map<std::string, ChannelPtr> _hosts; // 用于按主机名查找删除
};

6.3 全局服务信道管理:ServiceManager

复制代码
class ServiceManager {
public:
    using ptr = std::shared_ptr<ServiceManager>;

    // 声明关注的服务,只有关注的才会管理
    void declared(const std::string& service_name) {
        std::unique_lock<std::mutex> lock(_mutex);
        _follow_services.insert(service_name);
    }

    // 按服务名获取一个可用信道
    ServiceChannel::ChannelPtr choose(const std::string& service_name) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto sit = _services.find(service_name);
        if (sit == _services.end()) {
            LOG_ERROR("没有可用的服务节点: {}", service_name);
            return nullptr;
        }
        return sit->second->choose();
    }

    // 服务上线回调
    void onServiceOnline(const std::string& service_instance, const std::string& host) {
        std::string service_name = getServiceName(service_instance);
        
        std::unique_lock<std::mutex> lock(_mutex);
        auto fit = _follow_services.find(service_name);
        if (fit == _follow_services.end()) {
            LOG_DEBUG("未关注的服务上线,忽略: {}", service_name);
            return;
        }

        ServiceChannel::ptr service;
        auto sit = _services.find(service_name);
        if (sit == _services.end()) {
            service = std::make_shared<ServiceChannel>(service_name);
            _services.insert({service_name, service});
        } else {
            service = sit->second;
        }

        service->append(host);
        LOG_DEBUG("服务新增节点: {} - {}", service_name, host);
    }

    // 服务下线回调
    void onServiceOffline(const std::string& service_instance, const std::string& host) {
        std::string service_name = getServiceName(service_instance);
        
        std::unique_lock<std::mutex> lock(_mutex);
        auto fit = _follow_services.find(service_name);
        if (fit == _follow_services.end()) {
            LOG_DEBUG("未关注的服务下线,忽略: {}", service_name);
            return;
        }

        auto sit = _services.find(service_name);
        if (sit == _services.end()) {
            LOG_WARN("删除节点时未找到服务: {}", service_name);
            return;
        }

        sit->second->remove(host);
        LOG_DEBUG("服务下线节点: {} - {}", service_name, host);
    }

private:
    // 从实例路径中提取服务名:/service/echo/instance1 -> /service/echo
    std::string getServiceName(const std::string& service_instance) {
        auto pos = service_instance.find_last_of('/');
        if (pos == std::string::npos) return service_instance;
        return service_instance.substr(0, pos);
    }

    std::mutex _mutex;
    std::unordered_set<std::string> _follow_services;
    std::unordered_map<std::string, ServiceChannel::ptr> _services;
};

6.4 数据结构选型:为什么用 vector 而不是哈希表

很多人第一反应会用 unordered_map 存节点,但在这个场景下,vector 其实更合适。核心原因就是访问模式是读远多于写:

  • 高频读:每次 RPC 调用都要选一次节点,是 QPS 级别的操作
  • 低频写:服务上下线是秒/分钟级别的操作

具体对比:

维度 vector unordered_map
轮询访问 O(1),下标取模直接定位,缓存友好 无序,无法直接下标访问,需额外维护迭代器
轮询公平性 顺序稳定,节点调度均匀 顺序不确定,无法保证公平
全量更新 直接 swap 新 vector,效率极高 需逐个增删,开销大
单个删除 O(n),但节点数少,可忽略 O(1),但优势发挥不出来

一句话总结:ServiceChannel 内部用 vector 做轮询,上层服务名映射用 unordered_map,各司其职,性能最优。

七、etcd + brpc 全链路联调

现在把两个组件打通,实现完整的微服务调用链路。

7.1 联调整体思路

完整的调用流程:

  1. 服务端:启动 RPC 服务 -> 向 etcd 注册自己的地址
  2. 客户端:通过 Discovery 监听 etcd -> 服务上下线时同步更新 ServiceManager -> 业务调用时按服务名选信道发起 RPC

这样客户端完全不用硬编码服务地址,节点动态增减完全自动感知。

7.2 服务端改造

在 brpc 服务端的基础上,加上 etcd 注册逻辑:

复制代码
// ... EchoServiceImpl 实现和之前一样 ...

DEFINE_int32(listen_port, 7070, "RPC服务监听端口");
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(instance_name, "/echo/instance1", "实例名称");
DEFINE_string(access_host, "127.0.0.1:7070", "服务访问地址");

int main(int argc, char* argv[]) {
    google::ParseCommandLineFlags(&argc, &argv, true);

    // 关闭brpc日志
    logging::LoggingSettings settings;
    settings.logging_dest = logging::LOG_TO_NONE;
    logging::InitLogging(settings);

    // 启动RPC服务
    brpc::Server server;
    EchoServiceImpl echo_service;
    server.AddService(&echo_service, brpc::ServiceOwnership::SERVER_DOESNT_OWN_SERVICE);

    brpc::ServerOptions options;
    options.idle_timeout_sec = -1;
    options.num_threads = 1;
    server.Start(FLAGS_listen_port, &options);

    // 注册服务到etcd
    Registry::ptr rclient = std::make_shared<Registry>(FLAGS_etcd_host);
    rclient->registry(FLAGS_base_service + FLAGS_instance_name, FLAGS_access_host);

    std::cout << "服务启动并注册完成" << std::endl;
    server.RunUntilAskedToQuit();
    return 0;
}

7.3 客户端改造

在 brpc 客户端的基础上,加上服务发现和信道管理:

复制代码
DEFINE_string(etcd_host, "[http://127.0.0.1:2379](http://127.0.0.1:2379)", "注册中心地址");
DEFINE_string(base_service, "/service", "服务根目录");
DEFINE_string(call_service, "/service/echo", "要调用的服务名");

int main(int argc, char* argv[]) {
    google::ParseCommandLineFlags(&argc, &argv, true);

    // 1. 创建信道管理器,声明关注的服务
    auto sm = std::make_shared<ServiceManager>();
    sm->declared(FLAGS_call_service);

    // 2. 创建服务发现,绑定上下线回调
    auto put_cb = std::bind(&ServiceManager::onServiceOnline, sm.get(), 
                            std::placeholders::_1, std::placeholders::_2);
    auto del_cb = std::bind(&ServiceManager::onServiceOffline, sm.get(), 
                            std::placeholders::_1, std::placeholders::_2);
    Discovery::ptr dclient = std::make_shared<Discovery>(
        FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb);

    // 3. 循环发起RPC调用测试
    while (true) {
        auto channel = sm->choose(FLAGS_call_service);
        if (!channel) {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            continue;
        }

        example::EchoService_Stub stub(channel.get());
        example::EchoRequest req;
        req.set_message("你好~比特~!");

        brpc::Controller* cntl = new brpc::Controller();
        example::EchoResponse* rsp = new example::EchoResponse();
        stub.Echo(cntl, &req, rsp, nullptr);

        if (cntl->Failed()) {
            std::cout << "调用失败: " << cntl->ErrorText() << std::endl;
            delete cntl;
            delete rsp;
            std::this_thread::sleep_for(std::chrono::seconds(1));
            continue;
        }

        std::cout << "收到响应: " << rsp->message() << std::endl;
        delete cntl;
        delete rsp;
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    return 0;
}

7.4 联调测试与踩坑记录

编译

复制代码
all: registry discovery

registry: registry.cc main.pb.cc
	g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest -lbrpc -lssl -lcrypto -lprotobuf -lleveldb

discovery: discovery.cc main.pb.cc
	g++ -std=c++17 $^ -o $@ -lspdlog -lfmt -lgflags -letcd-cpp-api -lcpprest -lbrpc -lssl -lcrypto -lprotobuf -lleveldb

测试流程

  1. 确保 etcd 正常运行
  2. 启动 discovery 客户端,一开始会打印「没有可用的服务节点」
  3. 启动 registry 服务端,客户端很快收到服务上线日志,然后开始正常打印 RPC 响应
  4. 关闭服务端,大约 3 秒后,客户端收到服务下线日志,重新进入等待状态

常见踩坑

  1. 头文件重复包含导致重定义 :所有头文件都要加 #pragma once,尤其是 logger 这种公共组件
  2. 服务注册 key 不唯一:多实例一定要加不同的实例名,否则会互相覆盖
  3. 服务名和实例名不匹配:一定要从完整 key 里提取服务名,不然发现了服务但信道管理器不认
  4. 异步调用对象生命周期:Controller 和 Response 必须堆分配,回调里记得释放
  5. brpc 日志冲突:一定要关闭 brpc 默认日志,不然会和自己的日志系统混在一起

八、核心要点总结

  1. etcd 的灵魂是租约 + watch:租约实现故障自动摘除,watch 实现实时感知,二者配合才是完整的服务注册发现能力。
  2. 二次封装是工程化必备:把通用组件封装成业务接口,屏蔽底层细节,既能减少重复代码,也能降低出错概率。
  3. brpc 的 RAII 设计很重要:ClosureGuard、Channel 这些都遵循 RAII 思想,用好能少写很多样板代码,也更安全。
  4. 数据结构要匹配访问模式:没有绝对好的数据结构,读多写少选 vector、按键查找选哈希表,匹配场景才是最优。
  5. 联调核心是边界对齐:注册的 key 格式、发现的解析逻辑、信道管理的服务名,三者必须对应上,不然就会出现「注册了但发现不了」的玄学问题。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:到这里,etcd + brpc 的完整微服务通信底座就跑通了。这也是我们整个即时通讯系统的核心基础设施,后面的用户管理、好友管理、消息转发等所有子服务,都可以基于这套模式开发,服务之间完全解耦,动态扩缩容也非常方便。接下来我们会基于这套底座,继续拆分各个业务子服务,一步步把整个聊天系统落地。后续也会分享更多 C++ 微服务、高性能后端的实战内容,欢迎关注交流。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
Chenyu_3106 小时前
DPDK、TC 和 XDP:Linux 高性能网络到底在优化什么
linux·服务器·网络·缓存
Lethehong6 小时前
Oracle 加个字段,目标库就报错?KFS 用“先拆后组“打通异构 DDL
数据库·oracle
徐同保7 小时前
免费ssl证书全流程AI配置(含七牛云cdn)
网络·网络协议·ssl
Web极客码7 小时前
锁定真正的磁盘卡顿:我用 OpenCode 配合 eBPF 诊断 NVMe 队列深度延迟与 I/O 写放大
数据库·oracle
lisw057 小时前
制造业信息化:现代工业的发展趋势!
大数据·网络·人工智能
陈让然7 小时前
WSL 桥接设置静态IP(快速)
linux·网络·ubuntu
持敬chijing7 小时前
白盒测试与代码审计-js登录逆向实战
javascript·网络·安全·web安全·网络安全·网络攻击模型
cspttty7 小时前
2027用户研究求职准备:应用统计学如何补齐研究设计与业务表达
大数据·数据库
把我格子衫拿来8 小时前
VTK 9.5 与 Qt 6 联合开发:从嵌入三维窗口到可维护的桌面架构
c++·python·qt·vtk·三维可视化