用 Protobuf 描述服务、Muduo 承载并发网络、ZooKeeper 做服务发现, 编译成静态库
libmprpc.a供其他 C++ 后端项目直接接入 RPC 能力。
mprpc 是一个可被其他项目复用的 RPC 框架:业务方用 .proto 定义服务,
框架负责把"本地方法调用"变成跨进程的网络调用——序列化、组帧、服务发现、
收发、拆帧、分发回业务实现,全部封装在静态库里。配套的两个示例服务
(UserService / FriendService)演示了"提供方发布服务、调用方像调本地函数
一样发起 RPC"的完整闭环。
技术深度集中在三处,后文每一处都配有可点击的代码锚点:
- 并发网络模型 — 服务端用 Muduo 多 Reactor 承载并发连接;
调用方手写了带超时的非阻塞
connect与完整收发,贴近 socket 底层。 - 协议与服务边界设计 — 自定义二进制帧解决 TCP 粘包,复用 Protobuf
Service/Stub调用模型,并用MprpcController把"框架层失败"与 "业务层失败"清晰分离。 - 异步日志子系统 — 生产者/消费者模型让业务线程零阻塞;有界队列 "丢最旧 + 计数"在洪峰下保证内存有界;优雅停机保证退出前尾部日志不丢。
服务注册与发现交给 ZooKeeper:提供方启动时注册地址,调用方运行时发现地址, ZooKeeper 只做注册中心、不转发业务流量。
不依赖第三方客户端库,直接基于 socket 实现了带超时的非阻塞 connect:
切到 O_NONBLOCK 后用 poll 等待可写,再用 getsockopt(SO_ERROR) 确认连接
结果,完整处理了 EINPROGRESS / EINTR / ETIMEDOUT。发送侧用循环
SendAll 应对短写,地址解析走 getaddrinfo 兼容主机名/IPv4/IPv6。
深度在:把"连接超时控制"这件容易被一句
connect()糊弄过去的事做对了。 锚点 —ConnectWithTimeout·SendAll·ResolveEndpoint
服务端组合 Muduo TcpServer,I/O 线程数可配置(RPC_IO_THREADS),由多个
Reactor 承载并发连接。onMessage 里按 [4字节 headerSize][RpcHeader][args]
的帧格式拆包,并在数据不足一个完整帧时直接返回、等待下次回调——正确处理了
TCP 粘包/半包。
深度在:网络框架的事件回调与"应用层定帧"的边界处理。 锚点 —
RpcProvider::onMessage·ExecuteRpcRequest
MprpcController 只承载框架层的失败(连接超时、序列化失败、网络错误),
业务层的成败由 Protobuf 响应里的错误码字段表达。调用方先查
controller.Failed(),再看业务返回——两类错误不混淆。
深度在:接口契约设计——让调用方能区分"RPC 没打通"和"业务拒绝了你"。 锚点 —
MprpcController· 调用方用法
- 业务线程零阻塞:日志走生产者/消费者模型,业务线程只做一次入队,
写盘/flush 交给独立消费线程。
锚点 —
RpcLogger::Log→ 队列Push - 有界队列 + 丢最旧并计数:洪峰下内存有界;被丢弃的条数会被统计,并以
[RPC][WARN] dropped N ...写进同一个日志 sink——既不无声丢失,也绝不 反压业务线程。锚点 —RpcLockQueue· 丢弃可见化 - 优雅停机:
停止标志 → 排空队列 → flush → fclose → join,进程退出前 把尾部日志全部落盘;生命周期由调用方显式Shutdown()控制,不依赖静态 析构顺序。锚点 —Shutdown· 故意泄漏单例的原因 - 配置热路径优化:消费线程用"原子版本号(epoch) + 互斥锁"读已锁定配置,
每行仅一次无锁原子读,只有配置真正变更那一刻才加锁重读。
锚点 —
refreshConfig
一次 stub.Login(req, resp) 从调用方到提供方再回来的完整主线:
调用方 提供方
stub.Login(req, resp)
│ Protobuf Stub 转发
▼
MprpcChannel::CallMethod [mprpcchannel.cc#L255]
│ ① request 序列化 → argsString
│ ② 组帧 [headerSize][RpcHeader(service/method/args_size)][args]
│ ③ 查 ZooKeeper 发现 provider 地址(多实例则 round-robin 选一个)
│ ④ 非阻塞 connect(带超时) + SendAll
└──────────── TCP ────────────► RpcProvider::onMessage [mprpcprovider.cc#L207]
│ ⑤ 拆帧,解析出 service / method / args
│ ⑥ 查本地路由表 m_serviceInfoMap
│ ⑦ argsString 反序列化为具体 request
▼
service->CallMethod(...)
│ Protobuf 分发到业务实现
▼
UserService::Login ← 你的业务代码
│ 填 response,done->Run()
▼
SendRpcResponse [mprpcprovider.cc#L294]
recv 直到对端关闭 ◄──────────── TCP ──────────────┘ ⑧ response 序列化回包,关闭连接
│
▼
response->ParseFromString → 业务先查 controller.Failed(),再看业务错误码
关键角色:Stub/Channel(调用方组帧+发现+收发)、RpcProvider(提供方 拆帧+分发+回包)、ZooKeeper(只做服务发现)、业务 Service(真正逻辑)。 逐步骤的详细时序见 docs/rpc-call-flow-notes.md。
启动 userservice 后,提供方把每个方法注册为 ZooKeeper 顺序临时节点:
[RPC][INFO] register service: UserServiceRpc
[RPC][INFO] register method: UserServiceRpc::Login
[RPC][INFO] register method: UserServiceRpc::Register
[RPC][INFO] zkclient start success! endpoints=127.0.0.1:2181
[RPC][INFO] register rpc provider node service=UserServiceRpc method=Login \
path=/mprpc/UserServiceRpc/Login/providers/provider-0000000007 target=127.0.0.1:9000
[RPC][INFO] rpc provider start bind=127.0.0.1:9000 advertise=127.0.0.1:9000 threads=4
调用方发起 RPC,服务发现 → 连接 → 拿到业务结果:
[RPC][INFO] rpc connect success! service=UserServiceRpc method=Login target=127.0.0.1:9000
rpc login response success:1
[RPC][INFO] rpc connect success! service=UserServiceRpc method=Register target=127.0.0.1:9000
rpc register response success:1
以上为真实运行输出,已省略 ZooKeeper C SDK 的启动环境噪音。
向日志队列灌入 20 万条消息制造洪峰,队列有界、丢最旧,丢弃数被实时统计并 写进同一 sink;洪峰一过又恢复正常——丢失对调用方完全可见、可量化:
[RPC][INFO] bounded-drop seq=0
[RPC][WARN] dropped 274 log message(s) due to full queue
[RPC][INFO] bounded-drop seq=275
[RPC][WARN] dropped 70 log message(s) due to full queue
[RPC][INFO] bounded-drop seq=346
...
[RPC][INFO] bounded-drop seq=199999 ← 洪峰退去,尾部连续无丢弃
同一套机制在停机场景下保证尾部不丢:推入 N 条后调用 Shutdown(),
消费线程排空队列、flush、fclose,落盘行数与推入数一致(尾条完整)。
请求帧:[4 字节 headerSize (网络字节序)][RpcHeader][args]。RpcHeader 由
Protobuf 定义(src/rpcherder.proto):
message RpcHeader {
bytes service_name = 1; // 服务名,如 "UserServiceRpc"
bytes method_name = 2; // 方法名,如 "Login"
uint32 args_size = 3; // 业务参数序列化后的字节数
}业务服务用标准 .proto 定义即可,框架不侵入业务消息
(example/user.proto):
service UserServiceRpc {
rpc Login(LoginRequest) returns (LoginResponse);
rpc Register(RegisterRequest) returns (RegisterResponse);
}响应帧目前是裸 Protobuf 字节流,调用方靠对端关闭连接(recv == 0)判定边界
——这是短连接模型的有意取舍,见下方设计决策表与 Roadmap。
调用方进程 ZooKeeper 提供方进程
┌───────────────┐ ┌──────────────┐ ┌────────────────┐
│ Stub │ 发现地址 │ 注册中心 │ 注册节点 │ RpcProvider │
│ └ MprpcChannel│◄──────────►│ (仅服务发现) │◄───────────│ └ Muduo Server│
│ 序列化+组帧 │ └──────────────┘ │ 拆帧+分发 │
└───────┬───────┘ └───────┬────────┘
│ ▼
│ TCP [headerSize][RpcHeader][args] 业务 Service 实现
└────────────────────────────────────────────────────────┘
◄──────────── TCP [response bytes] ──────────────
| 设计决策 | 取舍与理由 |
|---|---|
复用 Protobuf Service/Stub |
不自造 IDL,业务方零学习成本;天然获得跨语言消息定义与高效序列化 |
| 自定义 4 字节定长头 + Protobuf 帧头 | 用最小协议解决 TCP 粘包;帧头本身也是 Protobuf,扩展字段无需改解析逻辑 |
| ZooKeeper 只做服务发现 | 注册中心不进数据面,业务流量直连提供方,避免注册中心成为转发瓶颈 |
| 提供方用顺序临时节点注册 | 临时节点随会话消失自动摘除故障实例;顺序节点天然支持同一方法多实例 |
| 调用方 round-robin 选址 | 多实例间均摊请求,负载均衡逻辑在调用方、可控可测 |
| 日志"丢最旧 + 计数"而非阻塞 | 日志绝不能拖慢 RPC 热路径;洪峰下宁可有界丢弃,但丢失必须可观测 |
| 当前为短连接(一次连接一次调用) | 实现简单、无需 response 定帧;长连接/连接池作为有意的演进项,见 Roadmap |
# 0. 依赖(Ubuntu / WSL):编译器、cmake、protobuf、zookeeper C 客户端
sudo apt install -y build-essential cmake pkg-config \
libprotobuf-dev protobuf-compiler libzookeeper-mt-dev
# Muduo 需自行安装到 /usr/local(头文件 + libmuduo_net / libmuduo_base)
# 1. 启动注册中心(仅依赖服务跑在容器里,框架代码本地编译调试)
docker compose up -d zookeeper # 或 ./scripts/deps-up.sh
# 2. 构建(框架 + 示例)
cmake -S . -B build
cmake --build build -j"$(nproc)"
# 产物:可执行文件在 build/bin/,静态库在 build/lib/libmprpc.a
# 3. 跑通端到端(两个终端)
./build/bin/userservice -i config/local/userservice.conf # 终端 A:提供方
./build/bin/calluserservice -i config/local/client.conf # 终端 B:调用方
# 预期:rpc login response success:1 / rpc register response success:1把框架交付给其他项目复用(导出头文件 + 静态库到 dist/mprpc/):
./scripts/pack.sh # = 构建 + cmake --install build --prefix dist/mprpc
# 之后:头文件在 dist/mprpc/include/,静态库在 dist/mprpc/lib/libmprpc.a运行时用 -i <conf> 指定配置;所有键均支持环境变量覆盖,优先级
env > 配置文件 > 内置默认:
| 键 | 说明 |
|---|---|
RPC_BIND_IP / RPC_PORT |
提供方监听地址 |
RPC_ADVERTISE_HOST |
注册到 ZooKeeper 的对外地址(默认同 bind_ip) |
RPC_IO_THREADS |
Muduo I/O 线程数(默认 4) |
ZK_ENDPOINTS / MPRPC_ZK_NAMESPACE |
ZooKeeper 地址 / 服务注册根命名空间 |
RPC_CONNECT/SEND/RECV_TIMEOUT_MS |
调用方连接 / 发送 / 接收超时 |
MPRPC_LOG_MODE |
日志输出:stdout / file(按天滚动) |
MPRPC_LOG_DIR / MPRPC_LOG_NAME |
file 模式日志目录 / 服务标识 |
MPRPC_LOG_QUEUE_CAP |
日志队列容量上限(满则丢最旧并计数,0 为不限) |
file 模式日志路径:
<MPRPC_LOG_DIR>/<YYYY-MM-DD>-<MPRPC_LOG_NAME>.log。
src/ 框架核心(provider / channel / controller / config / zookeeper / logger)
src/include/ 框架公开头文件(随静态库一起导出)
example/ 示例:userservice / friendservice(提供方)与 call*(调用方)
config/local/ 本地运行配置(-i <conf>)
scripts/ 构建、打包、依赖编排脚本
docs/ 调用链路时序笔记
compose.yaml 依赖服务(ZooKeeper)编排
当前版本已打通"定义服务 → 注册发现 → 跨进程调用 → 框架/业务错误分离"的完整 闭环,并把网络、协议、异步日志三块做扎实。下一步按价值分阶段推进:
- 连接复用与长连接:当前为短连接(一次连接一次调用),计划引入连接池与
长连接;长连接需为响应补
response_size定帧、为并发请求补request_id——设计要点已在 docs/rpc-call-flow-notes.md 中梳理。 - 自动化测试:把现有手动验证的日志"不丢尾 / 有界丢弃"场景固化为可重复 运行的单元/集成测试,纳入 CMake 与 CI。
- 服务发现增强:调用方对 provider 列表做本地缓存 + Watch 增量更新, 减少每次调用都查询 ZooKeeper 的开销。
- 可观测性:补充调用耗时、成功率等指标,便于接入监控。