1. 项目概述与设计哲学上次我们聊了聊为什么要在C里从零造一个RPC框架的轮子以及搭建了最基础的开发环境。今天我们正式进入核心环节——项目设计。这就像盖房子在动手砌砖之前你得先有一张清晰的蓝图知道客厅在哪、承重墙怎么放、水电管线怎么走。对于RPC框架来说这个“蓝图”就是它的架构设计它直接决定了框架的性能上限、扩展性、以及我们后续编码的顺畅程度。很多人一上来就急着写网络通信的代码结果写到一半发现序列化方案不匹配或者线程模型卡死了不得不推倒重来浪费大量时间。我的经验是在C这种系统级语言里做基础设施设计阶段多花一天时间思考编码阶段就能省下一周甚至更久的调试和重构成本。我们这次的设计目标很明确要做一个高性能、易用、且具备良好扩展性的轻量级RPC框架。高性能意味着低延迟和高吞吐这是C的看家本领易用意味着对业务开发者友好接口简洁扩展性则要求核心组件如序列化、传输协议可插拔方便未来适配不同场景。整个框架将围绕几个核心抽象展开消息Message、编解码器Codec、传输通道Channel、桩Stub和服务端引擎Server。我们会采用经典的Reactor网络模型配合线程池来处理高并发使用协议缓冲区Protocol Buffers作为默认的接口定义和序列化方案因为它兼具高效和跨语言的优势。当然设计上会为替换其他序列化库如MessagePack、JSON留好接口。接下来我们就逐一拆解这些核心模块的设计思路和背后的权衡。2. 核心架构与模块划分一个RPC调用本质上是客户端发起一个对远程服务的请求并等待其响应的过程。在这个过程中数据需要被封装、传输、解析和执行。我们的框架设计就是将这个过程模块化让每个环节职责单一便于维护和升级。2.1 整体架构图与数据流虽然我们不能画图但可以用文字清晰地描述整个流程。想象一下数据从客户端到服务端的旅程客户端侧业务代码调用一个本地桩Stub方法。序列化桩Stub将方法名和参数打包成一个请求消息Request Message并通过编解码器Codec将其序列化为字节流。网络发送字节流通过传输通道Channel发送到网络。Channel管理着底层的TCP连接或未来可能的其他连接。服务端接收服务端的网络层基于Reactor监听到新的数据将其读入缓冲区。反序列化与分发服务端的编解码器Codec从缓冲区中解析出一个完整的请求消息然后根据消息头中的服务名和方法名定位到具体的服务实现Service。执行服务端在某个工作线程来自线程池中调用该服务实现的具体方法。响应方法执行结果被封装成响应消息Response Message再次经过序列化、网络发送沿原路返回给客户端。客户端处理客户端的网络层收到响应反序列化后由桩Stub将结果提取出来返回给最初的业务调用者。在这个流程中我们可以抽象出以下几个核心模块消息Message通信的基本单位包含消息头Header和消息体Body。头里存放元数据如消息ID、类型请求/响应、错误码、体长度等体里存放序列化后的实际请求/响应数据。编解码器Codec负责消息与字节流之间的转换。它需要解决TCP粘包/拆包问题并执行序列化/反序列化。传输通道Channel对一次TCP连接的抽象负责字节流的可靠收发。它是编解码器和网络IO之间的桥梁。桩Stub与骨架Skeleton桩是客户端的代理将本地调用转化为网络消息骨架是服务端的调度器将网络消息分发给正确的服务方法。它们通常由IDL接口定义语言编译器自动生成。服务端引擎Server负责监听端口、接受连接、管理所有客户端Channel并将接收到的请求分发给线程池处理。线程模型协调网络IO线程Reactor线程与业务计算线程Worker线程的工作避免阻塞最大化并发能力。2.2 模块职责与接口设计1. 消息Message设计消息设计的关键在于头部信息的精简与高效。一个典型的头部可以设计为定长结构例如12字节struct RpcHeader { uint32_t magic; // 魔数用于快速校验如0x5A5A5A5A uint32_t msg_id; // 消息ID用于请求-响应匹配 uint32_t body_len; // 消息体长度 // 可以扩展uint8_t type; uint8_t compress_type; uint16_t reserved; };消息体则是一个std::vectorchar或自定义的缓冲区存放序列化后的Protobuf或其他格式数据。将头部设计为定长编解码器可以非常高效地先读取固定长度的头部再根据body_len读取精确长度的体完美解决TCP粘包问题。2. 编解码器Codec设计编解码器需要两个核心接口class RpcCodec { public: // 编码将Message对象序列化为字节流写入buffer void encode(const RpcMessage msg, Buffer* buffer); // 解码尝试从buffer中解析出一个完整的Message。返回解码状态成功、需要更多数据、错误 DecodeState decode(Buffer* buffer, RpcMessage* msg); };这里的Buffer是我们需要自己设计的一个应用层缓冲区类。它需要支持高效的连续内存管理、自动扩容、以及防止内存拷贝的“分散-聚集”读写操作类似于Netty的ByteBuf或Muduo的Buffer。这是性能优化的关键点之一。3. 传输通道Channel设计Channel是连接每个客户端的核心。它持有一个文件描述符socket fd。对应的编解码器实例。输出缓冲区待发送数据和输入缓冲区接收到的数据。一个回调函数映射表用于存储未完成的请求key为msg_idvalue为响应回调函数。 它的核心接口是sendRequest和onMessage当收到完整消息时被调用。4. 线程模型设计Reactor ThreadPool这是C高性能网络服务器的经典模式。Reactor反应器运行在一个或多个独立的IO线程中通常1个就够了除非连接数极多。它使用epollLinux或kqueueBSD等IO多路复用技术监听所有Channel上的读写事件。当某个Channel可读时Reactor线程读取数据到该Channel的输入缓冲区并调用编解码器尝试解码。一旦解码出一个完整的请求消息它并不直接处理而是将这个消息对象包装成一个任务Task投递到线程池的任务队列中。ThreadPool线程池由一组预先创建的工作线程组成。它们不断从任务队列中取出任务执行。对于RPC请求任务工作线程会调用相应的服务方法生成响应再通过Channel发送回去发送操作可能会由Reactor线程代为执行以避免工作线程直接进行系统调用。这种设计的好处是网络IO与业务处理完全解耦。Reactor线程可以全力处理高并发的网络数据收发IO密集型不会被耗时的业务计算CPU密集型阻塞。工作线程则专注于业务逻辑无需关心网络细节。两者通过一个线程安全的队列通信各司其职效率最高。实操心得线程池任务队列的选择任务队列是连接Reactor线程和Worker线程的桥梁其性能至关重要。我强烈建议使用无锁队列lock-free queue如moodycamel::ConcurrentQueue或自己基于CAS实现一个简单的版本。在早期版本中我曾使用std::queue加std::mutex在高压力测试下锁竞争成为了明显的性能瓶颈。切换到无锁队列后吞吐量提升了近30%。对于C高性能项目在核心数据通路上去锁是必须考虑的优化手段。3. 关键技术细节与实现选型有了宏观架构我们还需要敲定一些关键的技术实现细节这些选择会深刻影响框架的特性和编码复杂度。3.1 序列化方案为什么是Protobuf序列化是将结构化数据如C对象转换为字节流的过程反序列化则是其逆过程。它是RPC的基石。我们的选择是Google的Protocol Buffers (Protobuf)原因如下高性能与高压缩比Protobuf采用二进制编码体积小序列化/反序列化速度远超XML和JSON。对于内部微服务通信这是关键优势。强类型与接口契约你需要先定义一个.proto文件明确服务接口Service、方法RPC Method以及方法的请求/响应消息格式。这个文件就是客户端和服务端之间强制的、清晰的契约避免了手动拼接解析协议带来的错误。跨语言支持Protobuf官方支持C, Java, Python, Go等主流语言并且生态中有大量第三方语言支持。这意味着用我们框架写的C服务可以轻松地被Java或Python客户端调用反之亦然。向后兼容性Protobuf的字段采用optionalproto3中所有字段默认optional和编号机制新增字段不会破坏老版本程序的解析非常适合长期演进的业务。当然我们不会把框架和Protobuf强绑定。在设计编解码器接口时我们会将序列化/反序列化操作抽象为虚函数或模板参数。默认使用Protobuf但用户可以通过实现特定的Serializer特质类接入MessagePack、FlatBuffers甚至JSON。一个简单的.proto文件示例syntax proto3; package echo; service EchoService { rpc Echo (EchoRequest) returns (EchoResponse); } message EchoRequest { string message 1; } message EchoResponse { string message 1; }IDL编译器protoc会根据这个文件生成C的桩Stub和骨架Skeleton代码里面包含了消息类和RPC调用接口为我们省去了大量样板代码。3.2 连接管理与超时重试在网络编程中连接不是永远可靠的。框架必须妥善处理连接建立、断开、重连以及请求超时。连接池对于客户端为每个服务地址维护一个小的连接池是常见做法可以避免每次RPC调用都经历TCP三次握手的开销。我们的Channel可以设计为可重用的当连接断开时Channel进入“无效”状态下次调用前尝试重连。超时控制这是保证系统韧性的重要机制。每个RPC请求都应该有一个超时时间。实现上当通过Channel::sendRequest发送请求时我们会创建一个定时器例如使用std::chrono和一个小根堆管理的定时器队列在超时时间到达时触发超时回调清理对应的响应回调并可能向调用者返回超时错误。重试策略超时后是否重试、重试几次这属于业务策略。框架可以提供简单的支持例如在Stub层提供一个“最大重试次数”的选项。但重试必须小心特别是对于非幂等的写操作。更复杂的策略如退避重试可以留给上层业务或专门的客户端策略库实现。3.3 错误处理与状态码一个健壮的RPC框架必须有清晰的错误传递机制。错误可能发生在各个层面网络层连接失败、连接断开、读写超时。协议层解码失败、魔数校验错误、消息体长度非法。应用层服务不存在、方法不存在、参数反序列化失败、业务逻辑错误。我们需要定义一个统一的错误码枚举并随着响应消息一起返回。Protobuf消息中可以包含一个status_code和error_msg字段。框架层面需要捕获底层异常如网络异常、序列化异常并将其转换为恰当的错误码而不是让程序崩溃。4. 核心数据结构与类图设计现在让我们用代码结构来具象化之前的设计。以下是核心类的声明仅为示意非完整实现// buffer.h - 应用层缓冲区 class Buffer { public: void append(const char* data, size_t len); void retrieve(size_t len); const char* peek() const; size_t readableBytes() const; // ... 其他如ensureWritableBytes, prepend等高效操作 private: std::vectorchar buffer_; size_t readerIndex_; size_t writerIndex_; }; // message.h - RPC消息 struct RpcMessage { RpcHeader header; std::shared_ptrgoogle::protobuf::Message body; // 使用protobuf基类指针 // 或者使用类型擦除的any_ptr以支持其他序列化方式 }; // codec.h - 编解码器抽象 class RpcCodec { public: using MessagePtr std::shared_ptrRpcMessage; using ProtobufMessagePtr std::shared_ptrgoogle::protobuf::Message; virtual void encode(const RpcMessage msg, Buffer* buf) 0; virtual DecodeState decode(Buffer* buf, RpcMessage msg) 0; virtual ~RpcCodec() default; }; // protobuf_codec.h - 基于Protobuf的实现 class ProtobufRpcCodec : public RpcCodec { public: void encode(const RpcMessage msg, Buffer* buf) override; DecodeState decode(Buffer* buf, RpcMessage msg) override; private: bool parseFromBuffer(const char* data, int len, google::protobuf::Message* msg); }; // channel.h - 传输通道 class RpcChannel : public std::enable_shared_from_thisRpcChannel { public: using ResponseCallback std::functionvoid(const RpcMessage); RpcChannel(EventLoop* loop, int sockfd); // 关联到一个EventLoop(Reactor)和socket ~RpcChannel(); void sendRequest(const ProtobufMessagePtr request, const std::string method_full_name, ResponseCallback cb); void onMessage(const Buffer buf); // 由EventLoop在可读事件中调用 private: void handleResponse(const RpcMessage msg); EventLoop* loop_; int fd_; std::unique_ptrBuffer inputBuffer_; std::unique_ptrBuffer outputBuffer_; std::unique_ptrRpcCodec codec_; std::unordered_mapuint32_t, ResponseCallback pendingCalls_; // msg_id - callback std::atomicuint32_t nextMsgId_{1}; }; // server.h - 服务端 class RpcServer { public: RpcServer(EventLoop* loop, const InetAddress listenAddr); void registerService(google::protobuf::Service* service); // 注册服务实例 void setThreadNum(int numThreads); // 设置工作线程数 void start(); private: void onConnection(const TcpConnectionPtr conn); void onMessage(const TcpConnectionPtr conn, Buffer* buf); void handleRequest(const RpcMessage reqMsg, const RpcChannelPtr channel); EventLoop* baseLoop_; // 主Reactor线程 TcpServer server_; // 基于Reactor的TCP服务器需要实现或借用如muduo ThreadPool threadPool_; // 业务线程池 std::unordered_mapstd::string, google::protobuf::Service* services_; // 服务名-服务实例 };类关系解读RpcServer持有TcpServer和ThreadPool。TcpServer负责监听和接受连接每个连接对应一个TcpConnection而每个TcpConnection可以关联一个RpcChannel。RpcChannel是核心它持有RpcCodec用于编解码并管理pendingCalls_用于匹配请求与响应。EventLoop是Reactor模式的核心循环负责事件分发。TcpConnection的读写回调最终会调用到RpcChannel::onMessage。业务逻辑位于google::protobuf::Service的实现类中由ThreadPool中的工作线程执行。5. 性能考量与优化点设计阶段就必须考虑性能否则等代码写完再优化会事倍功半。零拷贝Zero-copy这是网络编程的“圣杯”。理想情况下数据从网卡到用户缓冲区再到应用层消息对象最好不发生内存拷贝。在我们的架构中使用readv/writev系统调用配合分散-聚集IO可以将数据直接读到/从多个缓冲区如我们的Buffer内部可能由多块内存组成中存取。在编解码时尽量直接在Buffer的现有内存上进行操作避免将数据拷贝到临时std::string或std::vector中再处理。Protobuf 的ParseFromArray和SerializeToArray可以直接在字节数组上工作。内存池频繁地创建和销毁小对象如RpcMessage、小的Buffer会导致内存碎片和分配器压力。可以考虑为高频使用的对象实现一个简单的对象池Object Pool复用已分配的内存。缓冲区设计前面提到的Buffer类其内部采用std::vectorchar并在头部预留空间prependable space是非常经典的设计。这样在序列化消息头时可以直接“向前”写入避免了移动消息体。同时自动扩容策略要合理避免频繁扩容。线程间通信Reactor线程与Worker线程之间通过任务队列通信。除了使用无锁队列任务本身最好也是轻量的只包含必要的指针和ID避免传递大数据结构。例如任务可以只包含RpcMessage的智能指针和RpcChannel的弱引用。注意事项异步回调与资源生命周期C异步编程的核心挑战之一是资源生命周期管理。当一个请求发出后其回调函数可能在未来的某个时刻在IO线程中被调用。此时回调函数所依赖的对象如发起请求的Stub、包含的业务数据必须仍然存活。我们必须善用std::shared_ptr和std::weak_ptr。 例如RpcChannel::pendingCalls_中存储的回调函数如果直接捕获thisChannel指针一旦连接提前断开Channel被销毁回调执行时就会访问野指针。正确的做法是在构造回调时捕获Channel的weak_ptr在执行回调前尝试将其提升为shared_ptr如果提升失败说明Channel已不存在则直接丢弃该回调。这是编写健壮异步C代码的必备技巧。6. 开发路线图与下一步至此我们完成了整个RPC框架的顶层设计。回顾一下我们确定了架构基于Reactor ThreadPool的异步模型。核心模块Message, Codec, Channel, Server各司其职。关键技术Protobuf序列化、定长消息头解决粘包、连接与超时管理。性能基石零拷贝思想、缓冲区设计、无锁队列、智能指针管理生命周期。在接下来的实现篇中我们将首先实现最基础的Buffer类和EventLoop事件循环或集成一个轻量级的网络库如asio的独立模式或muduo的核心部分。实现ProtobufRpcCodec和RpcMessage。实现RpcChannel并完成请求-响应的匹配逻辑。实现RpcServer和线程池完成服务注册与请求分发。最后利用protoc插件或模板技术实现桩Stub的自动生成让客户端调用像调用本地函数一样简单。设计决定上限实现决定下限。有了这份详细的设计蓝图我们后续的编码工作就会像按图施工一样清晰。在真正开始写代码前我建议你反复审视这个设计思考是否有遗漏的边界情况比如服务器在处理请求时崩溃客户端如何感知并尝试在纸上画出更详细的序列图。磨刀不误砍柴工扎实的设计是项目成功的一半。