接口隔离 + 命名绑定 + 消息总线
插件架构的第一原则:插件之间不能有头文件依赖。
那问题来了——插件 A 怎么调用插件 B 的功能?
不能 include 对方的头文件,不能直接用 QObject 的类名。如果插件 A 想调插件 B 里的一个函数,你连函数签名都拿不到。
我见过有人这么干:把全插件的头文件塞到一个公共目录里。插件 A include 插件 B 的头文件,编译链接都过了,上线运行 3 个月,有一天插件 B 重构了某个接口,插件 A 没同步编译,运行到那个调用时直接崩。
还有更狠的:用dlopen+ 函数名指针,写了一个巨大的手写分发表,每加一个接口就要改三个文件。
Aether 没走这些弯路。它用三种方案覆盖了三种跨模块通信场景。每一种都对应一个真实的工程痛点。
一、接口通信:有头文件时的标准做法
最简单的场景:插件 A 和插件 B 共享公共头文件。
不是让它们互相 include,而是把公共接口拉到 common 层。
// ❌ 插件 A 直接 include 插件 B 的头文件#include"plugin_b/DeviceManager.h"// 耦合死了,B 一改 A 就崩// ✅ 公共接口放在 common 层,双方都依赖它// common/interfaces/IDeviceManager.hclassIDeviceManager{public:virtual~IDeviceManager()=default;// 纯虚函数定义契约,不暴露实现细节virtualboolstartDevice(conststd::string&id)=0;virtualboolstopDevice(conststd::string&id)=0;virtualstd::vector<std::string>deviceList()const=0;};插件 B 实现这个接口,插件 A 通过对象池获取:
// 插件 B:注册实现到对象池classDeviceManagerImpl:publicIDeviceManager{public:boolstartDevice(conststd::string&id)override{// 真正的设备启动逻辑returnm_driver->open(id);}// ... 其他实现};// 插件 B 初始化时注册voidPluginB::initialize(){automgr=std::make_shared<DeviceManagerImpl>();objectPool()->addObject("devmgr",mgr);// 注册时不暴露 DeviceManagerImpl 类型// 对象池只知道它是个 std::shared_ptr<void>}// 插件 A:通过接口调用,零实现耦合voidPluginA::doSomething(){automgr=objectPool()->getObject<IDeviceManager>("devmgr");if(mgr){mgr->startDevice("cam_001");// 调的是 IDeviceManager::startDevice// 不是 DeviceManagerImpl::startDevice// 编译期只依赖 common 头文件,不依赖插件 B}}关键设计:插件 A 的代码里没有出现DeviceManagerImpl这个类型。它只依赖IDeviceManager这个纯虚接口。插件 B 换掉实现类,插件 A 不需要重新编译。
对象池的统一性检查:
// 对象池的 getObject 做了 dynamic_cast 兜底template<typenameT>std::shared_ptr<T>getObject(conststd::string&name){autoit=m_objects.find(name);if(it==m_objects.end())returnnullptr;// dynamic_cast 做运行时类型校验// 如果注册的对象不继承 IDeviceManager,返回空returnstd::dynamic_pointer_cast<T>(it->second);}这套方案的核心约束:接口必须提前定义在 common 层。如果两个插件是同一个团队开发的,接口能提前约定好,这是最清晰的做法。但如果插件 A 和插件 B 是两个不同团队开发的,或者插件 B 还没写好接口定义呢?
那就得用第二种方案了。
二、命名绑定 + MethodDispatcher:无头文件也能调
真实场景:插件 A 知道插件 B 有一个函数叫 “device.start”,但它不知道插件 B 里对应的是哪个 C++ 类。
你不能说"你们团队先把接口定义好",因为插件 B 的团队可能还在迭代,接口三天一改。你也不能说"那你等他们稳定了再对接",因为项目 deadline 不等你。
Aether 的解法:MethodDispatcher,一个基于字符串名字的函数调用中心。
每个插件在注册服务时,可以附带一个 MethodDispatcher,把所有对外暴露的函数注册成字符串名字:
// 插件 B:注册服务 + 可调用方法voidPluginB::initialize(){autodriver=std::make_shared<CameraDriver>();autodisp=std::make_shared<MethodDispatcher>();// 把成员函数注册成字符串名字// 调用方不需要知道 CameraDriver 是什么类型disp->on("device.start",&CameraDriver::start);disp->on("device.stop",&CameraDriver::stop);disp->on("device.status",&CameraDriver::status);// 服务 + 分发表一起注册到 IoC 容器container()->bindNamed("camera",driver,disp);}MethodDispatcher 内部怎么工作的?核心代码不到 50 行:
// MethodDispatcher 核心原理:类型擦除 + 名字索引classMethodDispatcher{public:usingInvokeFn=std::function<std::any(void*instance,conststd::vector<std::any>&)>;// 注册:把成员函数指针擦除类型,存为 InvokeFntemplate<typenameSvc,typenameRet,typename...Args>voidon(conststd::string&name,Ret(Svc::*method)(Args...)){m_methods[name]=[method](void*inst,auto&&args){// 把 void* 转回真正的类型——这里面有风险// 所以务必保证 instance 的类型和注册时一致auto*svc=static_cast<Svc*>(inst);returncallHelper(svc,method,args,std::index_sequence_for<Args...>{});};}// 调用:通过名字找函数,void* 传实例std::anyinvoke(conststd::string&name,void*instance,std::vector<std::any>args={}){returnm_methods.at(name)(instance,std::move(args));}private:// 名字到函数的映射——这就是整个分发表std::unordered_map<std::string,InvokeFn>m_methods;};插件 A 调用时,只需要知道 “camera” 这个服务名和 “device.start” 这个方法名:
// 插件 A:不知道 CameraDriver 的类名,但能调它的函数voidPluginA::onUserClickStart(){// 从 IoC 容器获取 camera 服务的调用句柄autohandle=container()->makeNamed("camera");// 通过字符串名字调用,参数用 std::any 传autook=handle.call("device.start",{std::any{std::string("cam_001")}});// 返回值的类型也是 std::any,调用方自己处理}这套方案的取舍:你失去的是编译期类型安全检查,换来的是零头文件依赖。插件 B 今天改了CameraDriver::start的参数,插件 A 不需要重新编译。只要运行时参数传对了,就能正常工作。
但有一个大坑——字符串拼写错了怎么办?
答案是"跑不起来才知道"。这也就是为什么 Aether 没有只用这一种方案。有接口定义能力的时候,还是用第一种。MethodDispatcher 是给"真的没法共用头文件"的场景准备的。
三、消息总线:一对多广播通信
前两种方案解决的都是"一对一"调用。但插件系统里还有一种更常见场景:
一个事件发生,多个插件都要响应。
比如设备掉线了:UI 插件要弹提示,日志插件要记录,数据采集插件要停采,监控插件要报警。
如果用接口通信,你得写四个接口、四个注册。如果设备管理器每次状态变更都单独调一遍,代码会变成这样:
// ❌ 耦合式通知:设备管理器知道太多外部插件了voidDeviceManager::onDisconnected(conststd::string&id){// 设备管理器不该知道有这些插件存在m_ui->showAlert(id+" 已断开");m_logger->log("设备断开: "+id);m_collector->stop(id);m_monitor->reportOffline(id);}这段代码最大的问题不是重复——是设备管理器必须知道所有下游插件的类型。每加一个关心设备状态的插件,就要改 DeviceManager 的代码。这违反了开闭原则。
Aether 的消息总线解决了这个问题:
// ✅ 消息总线:设备管理器只管发消息,不管谁收voidDeviceManager::onDisconnected(conststd::string&id){// 只发一条消息,谁收谁自己注册MessageBus::publish("device.offline",DeviceEvent{id,timestamp()});}// 其他插件各自注册关心的消息// UI 插件voidUIPlugin::initialize(){MessageBus::subscribe("device.offline",[](constDeviceEvent&e){showAlert(e.deviceId+" 已断开");});}// 日志插件voidLogPlugin::initialize(){MessageBus::subscribe("device.offline",[](constDeviceEvent&e){Log::error("设备断开: {}",e.deviceId);});}// 数据采集插件voidCollectorPlugin::initialize(){MessageBus::subscribe("device.offline",[](constDeviceEvent&e){Collector::stopCollect(e.deviceId);});}核心变化:设备管理器不再依赖任何下游插件。它不知道 UI 插件是否存在,不知道日志系统是否启动。它只做一件事——设备掉线了,发一条消息。谁爱收谁收。
消息总线的实现也不复杂:
// 消息总线核心:主题订阅 + 广播分发classMessageBus{public:// 订阅:按主题名注册回调template<typenameEvent>staticvoidsubscribe(conststd::string&topic,std::function<void(constEvent&)>cb){// 类型擦除存起来,收到消息时 dynamic_cast 校验handlers()[topic].push_back({std::any{std::move(cb)},std::type_index(typeid(Event))});}// 发布:遍历所有订阅者,逐个调用template<typenameEvent>staticvoidpublish(conststd::string&topic,constEvent&event){for(auto&h:handlers()[topic]){// 类型校验:订阅时注册的类型必须匹配if(h.type==std::type_index(typeid(Event))){autocb=std::any_cast<std::function<void(constEvent&)>>(h.handler);cb(event);}}}private:structHandler{std::any handler;std::type_index type;};staticstd::unordered_map<std::string,std::vector<Handler>>&handlers(){staticstd::unordered_map<std::string,std::vector<Handler>>instance;returninstance;}};使用时的感觉:新写一个插件,想监听设备状态变化——不用改任何现有代码,只要在initialize()里加一行subscribe就行。完全符合开闭原则。
四、三种方案怎么选
很多人看完会说"消息总线最灵活,那就全用消息总线呗"。
别。每种方案有它的生态位。
| 场景 | 用哪种 | 为什么 |
|---|---|---|
| 插件 A 必须调 B 的特定函数 | 接口通信 | 编译期类型检查,最安全 |
| 一个事件需要多个插件响应 | 消息总线 | 一对多天然适合广播 |
| 调用方和被调方不同团队,接口不稳定 | 命名绑定 | 零头文件依赖,改接口不用联编 |
| 调用方和被调方同一团队,接口稳定 | 接口通信 | 类型安全 > 灵活性 |
| A 决定 B 做什么,B 做完不用告诉 A | 消息总线 | 异步 + 解耦 |
| A 决定 B 做什么,B 必须返回结果给 A | 接口通信 | 需要同步返回值 |
决策树长这样:
需要返回值? ├─ 是 → 需要编译期类型检查? │ ├─ 是 → 接口通信 │ └─ 否 → 命名绑定 └─ 否 → 需要多个接收者? ├─ 是 → 消息总线 └─ 否 → 接口通信 或 命名绑定(看有没有头文件)在 Aether 的实际代码里,三种方案不是互斥的。经常出现"一个插件注册了接口供调用,同时订阅了消息总线的若干主题"。接口通信处理"谁调谁",消息总线处理"谁通知谁"。命名绑定处理"知道名字但不知道类型的跨团队调用"。
三个各管各的,不冲突。
评论区聊两句
你的项目跨模块通信怎么做的?用过 RPC?还是直接 import/include?还是也搞了一套消息总线?遇到过"接口改了但调用方没同步"的线上事故吗?评论区说说你的经历和踩过的坑。
觉得这篇有用的,转发给团队里正在搞插件化的同事。点个"在看",下周二更新不迷路。
下篇预告
Phase 3 结束。这三篇(13、14、15)把 Aether 的插件通信方案拆干净了:跨线程、信号槽、接口隔离。
下一篇开始 Phase 4,回归构建系统。很多人在 Qt Creator 的 CMake 构建上踩过坑——多插件编译、自定义命令、安装规则、测试集成。这次不说理论,直接给你一套经过项目验证的 CMake 模板,复制就能用。
CMake 实战续集来了。