Skip to content

插件化多协议网关的 C++ 设计实践 ​

很多系统的对外接入需求是相似的:外部客户端的协议五花八门(HTTP、私有 TCP、之后可能还有 MQTT、WebSocket),内部服务不应该直接暴露给外部,而且每接入一个新服务、每支持一个新协议,都不应该修改网关核心代码再重新编译。本文整理一套满足这些约束的 C++ 网关设计:分层插件化架构,接入端协议与业务逻辑都做成运行时动态加载的插件,核心框架只负责路由、配置与生命周期。文中接口定义与流程图来自一个实际网关项目的设计文档,整理时做了适当抽象,适合准备自研协议网关或 API 接入层的开发者参考。

总体架构 ​

网关分为三层,每层职责单一:

  • 接入层(Access Layer):监听端口、解析协议,把外部请求转换为内部统一的 Context/Message;
  • 核心框架(Core Framework):配置管理、插件加载、路由分发、线程池等公共能力;
  • 业务层(Business Layer):处理具体业务逻辑,与后端服务通信。

两条插件抽象是整个架构的关键:

  • AccessPlugin 面向协议差异:一种协议一个插件,负责"外部协议 → 统一 Context"的转换;
  • BusinessPlugin 面向业务差异:一类业务一个插件,负责"统一 Context → 后端调用 → 响应填充"。

核心框架不认识任何具体协议和业务,两者只通过 Context 交互。新协议、新业务都以动态库形式在运行期接入,核心代码不动。

统一消息模型:Context 与 Message ​

所有协议的请求进入网关后,都被封装成统一的 Context + Message:

cpp
// 消息体:与协议无关的内容抽象
enum class ContentType { TEXT, BINARY, JSON, XML, PROTOBUF, UNKNOWN };

struct Message {
    ContentType contentType;                 // 内容类型
    uint64_t id;                             // 消息 ID(请求-响应匹配)
    unordered_map<string, string> headers;   // 消息头
    string body;                             // 消息体
    int64_t timestamp;                       // 时间戳
    unordered_map<string, string> metadata;  // 元数据
};

// 请求上下文:贯穿整个处理流程的"护照"
class Context {
protected:
    string _id;                     // 上下文唯一标识
    ProtocolType _protocol;         // 协议类型(HTTP/TCP)
    ServiceType _serviceType;       // 服务类型(Service/Action/Topic)
    string _from;                   // 请求来源(客户端地址)
    string _to;                     // 请求目标(URL 路径)
    atomic_bool _isAlive{true};
    unordered_map<string, string> _metadata;  // 路由参数等元数据
    any _userData;                  // 自定义数据

    int _responseCode;
    MessagePtr _requestMessage;
    MessagePtr _responseMessage;

public:
    // 由协议特定的 Context 子类实现
    virtual bool sendResponse(bool isLast = true);
    virtual bool sendError(int errorCode, const string& errorMessage = "");
    virtual bool publishMessage();            // Topic 模式推送
};

Context 在各模块间的流转是固定的:接入插件创建并填充请求信息,路由器把提取的路径参数写进 _metadata,业务插件读取请求、填充响应,最后通过 sendResponse() 把结果按原协议送回客户端。

sendResponse 的 isLast 参数承担着连接语义:true 表示这是最后一条消息(一次性的请求-响应),false 表示中间消息、连接继续保持(Action 的多次进度反馈、Topic 的持续推送都依赖它)。协议特定的差异被隔离在 Context 子类里:

cpp
// HTTP 上下文:短连接一次性返回;长连接走 SSE
class HttpContext : public Context {
    bool sendResponse(bool isLast = true) override {
        if (isLast) {
            response_status(_httpCtx, _responseCode, _responseMessage->body);
            _httpCtx->close();               // 请求结束,关闭连接
        } else {
            _httpCtx->writer->SSEvent(_responseMessage->body);  // SSE 推送
        }
        return true;
    }
};

// TCP 上下文:按网关消息协议封包后写回连接
class TcpContext : public Context {
    bool sendResponse(bool isLast = true) override {
        if (!isSessionValid()) return false;   // 连接已断开

        proto::Message protoMsg;               // Header + Body 结构
        auto* header = protoMsg.mutable_header();
        header->set_message_id(getMetadata("message_id"));
        header->set_is_last(isLast);
        header->set_code(_responseCode);
        protoMsg.set_body(_responseMessage->body);

        string serialized;
        protoMsg.SerializeToString(&serialized);
        return send(serialized);
    }
};

同一个业务插件不需要感知自己服务的是 HTTP 客户端还是 TCP 客户端——它只管填充 _responseMessage 并调用 sendResponse,协议差异由 Context 子类消化。

接入层:协议适配 ​

AccessPlugin 的接口刻意保持小:

cpp
class AccessPlugin {
    virtual bool init(const YAML::Node& config,
                      std::shared_ptr<hv::EventLoop> eventLoop) = 0;
    virtual bool start(int port) = 0;
    virtual void stop() = 0;
    virtual std::string getName() const = 0;
    virtual ProtocolType getProtocol() const = 0;

    bool routeCtx(const ContextPtr& ctx);   // 把请求交给路由层
    void setRouteCtxCallback(...);          // 框架注入的路由回调
};

几个设计点:

  • 事件循环由框架统一创建、插件复用。init 接收框架的 hv::EventLoop(libhv),接入插件不自己起 IO 线程,网络事件集中在同一个事件循环里处理。
  • HTTP 插件基于 libhv 的 HttpServer 实现,收到的 HTTP 请求被转换为 Context:URL 路径写入 _to(供路由),header 与 body 装入 Message。
  • TCP 插件实现全双工通信,线上报文格式为"4 字节小端长度 + Protobuf 序列化数据"。Protobuf 部分是一个简单的信封结构,头部携带路由所需的最小信息:
protobuf
message MessageHeader {
  string message_id = 1;  // 消息唯一标识(响应匹配)
  int64  timestamp  = 3;
  string url        = 4;  // 请求路径(路由用)
  int32  code       = 11; // 响应状态码
  bool   is_last    = 13; // 是否最后一条消息
}

message Message {
  MessageHeader header = 1;
  bytes         body   = 2;
}

把 URL 放进 TCP 消息头是整个统一模型成立的前提:这样一来,无论 HTTP 还是 TCP,路由依据都是同一个"路径",路由层不需要为不同协议维护两套匹配逻辑。

TCP 侧还需要自己做连接与请求上下文管理:一条连接(channel)上可以承载多个请求上下文,每个上下文有唯一 ID;连接关闭时清理该连接下的全部上下文,响应完成后释放对应上下文。这套生命周期管理在 HTTP 协议里由协议本身承担,在自定义 TCP 协议里就要自己实现。

路由:模式匹配与参数提取 ​

路由规则由配置驱动:

cpp
struct RouteRule {
    string pattern;          // 匹配模式:/api/{namespace}/{service}/{cmd}
    string businessName;     // 命中后交给哪个业务插件
    ServiceType serviceType; // 服务类型过滤(可选)

    bool matchAndExtractParams(const ContextPtr& ctx) const;
    shared_ptr<regex> _cachedRegex;       // 预编译正则缓存
    vector<string> _cachedParamNames;     // 参数名缓存
};

模式语法支持三类:精确路径、{param} 路径参数、* 通配符。加载时把模式编译成正则并缓存,{param} 变成捕获组:

text
/api/{namespace}/{service}/{cmd}
  →  ^/api/([^/]+)/([^/]+)/([^/]+)$
参数名列表 = [namespace, service, cmd]

匹配时按优先级从高到低遍历规则:先比对协议类型,再用缓存正则匹配路径,命中后把捕获组的值写入 Context 的 _metadata(如 namespace=core、service=param、cmd=query),然后把请求交给 businessName 指向的业务插件;找不到插件或所有规则都不匹配,直接回错误响应。规则数量不多时线性遍历即可,正则预编译缓存保证了单次匹配只是一次 regex_match 的开销。过滤器链(认证、限流)在架构上是路由的一环,实现上作为预留扩展未随首版落地。

业务层:配置驱动的服务接入 ​

BusinessPlugin 是业务侧唯一的扩展点:

cpp
class BusinessPlugin {
    virtual std::string getName() const = 0;
    virtual bool init(const YAML::Node& config) = 0;
    virtual bool process(const ContextPtr& ctx) = 0;
    virtual bool stop() = 0;
};

两个内置插件覆盖了三类通信模式:API 插件处理 Service(一次请求一次响应)与 Action(一次请求、多次进度反馈加一次最终结果),Topic 插件处理 Topic(订阅后持续推送)。API 插件的服务清单完全由配置声明,三层结构 namespace → service → cmd:

yaml
business_plugins:
  - name: api
    path: api_plugin.so
    enabled: true
    config:
      services:
        core:                          # 命名空间
          - name: param                # 服务名
            channel: param_service    # 后端服务通道(须与后端注册一致)
            type: service             # service 或 action
            cmd:
              - name: query
                reqType: ParamMsg.QueryReq   # 请求 Protobuf 类型
                rspType: ParamMsg.QueryRsp   # 响应 Protobuf 类型

这个结构直接映射到路由的路径参数:/api/core/param/query 命中后,插件拿 namespace/service/cmd 三段元数据查找配置,找到对应的后端通道与消息类型。新增一个后端服务只需追加一段配置,不触碰任何代码。

reqType/rspType 用于 JSON 与 Protobuf 的自动互转:HTTP 客户端通常发 JSON,后端走 Protobuf,插件在转发前按 reqType 做运行时转换,收到响应再按 rspType 转回 JSON。转换基于 Protobuf 的运行时反射(描述池)完成,不需要为每种消息生成转换代码。转换与后端调用都是耗时操作,提交到线程池异步执行,process 立即返回,不阻塞接入层的事件循环。

Topic 插件的订阅关系按连接维护,相同 topic 的多个订阅连接共享一次对后端的订阅(引用计数):

断开的连接由定时任务清理,对应的后端订阅在引用计数归零后取消。

插件动态加载 ​

插件以动态库形式交付,核心框架用 Boost.DLL 加载(它封装了 Windows .dll 与 Linux .so 的平台差异)。两层的加载协议完全一致:动态库导出一个 extern "C" 的 createPlugin 工厂函数,框架加载后查符号、调用工厂拿到实例,再按 getName() 的返回值注册:

cpp
#define EXPORT_ACCESS_PLUGIN(ClassName)                              \
    extern "C" PLUGIN_EXPORT gateway::AccessPluginPtr createPlugin() \
    {                                                                \
        return std::make_shared<ClassName>();                        \
    }

// 插件实现侧只需一行
EXPORT_ACCESS_PLUGIN(HttpAccessPlugin)

extern "C" 保证符号名不被 C++ 名字修饰,跨编译器查找符号才可靠;工厂返回智能指针,插件实例的生命周期交给框架管理。加载流程:

PluginManager 同时持有库句柄与插件实例:只存实例不存句柄,库可能在某个 shared_ptr 归零时被卸载,留下悬空代码。单个插件加载失败只记日志跳过,不影响其他插件与网关启动。

请求处理全流程 ​

一次请求的完整路径,把前面的部件串起来:

启动顺序上有一个容易忽略的细节:业务插件先于接入插件初始化。路由命中的终点是业务插件,先让业务层就绪、再开放外部端口,可以避免启动窗口期内请求命中未初始化的插件。

小结 ​

这套设计的核心决策可以归纳为四条:

  1. 两层插件抽象切开两类变化:协议变化止于 AccessPlugin,业务变化止于 BusinessPlugin,核心框架只面对 Context 与两个抽象接口,扩展永远是"加一个动态库加一段配置"。
  2. 统一消息模型是解耦的地基:URL 同时作为 HTTP 与 TCP 的路由依据,isLast 统一表达一次性响应与流式推送,业务代码因此与协议无关。
  3. 配置驱动的服务声明:namespace/service/cmd 三层结构与路由参数一一对应,接入新服务是纯配置变更;JSON 与 Protobuf 的互转交给运行时反射,不为每种消息写转换代码。
  4. 动态加载的工程细节要闭环:extern "C" 工厂符号、库句柄与实例同生命周期、单插件失败不拖垮网关,这三点缺一不可。

过滤器链、路由热更新、更多协议(MQTT、WebSocket)都留在了扩展点上:接入层再添一个插件即可,这正是插件化架构要兑现的承诺。

最近更新

基于 VitePress 构建