插件不能直接调别的插件?3种通信方案绝了

📅 2026/8/22 12:05:05
插件不能直接调别的插件?3种通信方案绝了
接口隔离 命名绑定 消息总线插件架构的第一原则插件之间不能有头文件依赖。那问题来了——插件 A 怎么调用插件 B 的功能不能 include 对方的头文件不能直接用 QObject 的类名。如果插件 A 想调插件 B 里的一个函数你连函数签名都拿不到。我见过有人这么干把全插件的头文件塞到一个公共目录里。插件 A include 插件 B 的头文件编译链接都过了上线运行 3 个月有一天插件 B 重构了某个接口插件 A 没同步编译运行到那个调用时直接崩。还有更狠的用dlopen 函数名指针写了一个巨大的手写分发表每加一个接口就要改三个文件。Aether 没走这些弯路。它用三种方案覆盖了三种跨模块通信场景。每一种都对应一个真实的工程痛点。一、接口通信有头文件时的标准做法最简单的场景插件 A 和插件 B 共享公共头文件。不是让它们互相 include而是把公共接口拉到 common 层。// ❌ 插件 A 直接 include 插件 B 的头文件#includeplugin_b/DeviceManager.h// 耦合死了B 一改 A 就崩// ✅ 公共接口放在 common 层双方都依赖它// common/interfaces/IDeviceManager.hclassIDeviceManager{public:virtual~IDeviceManager()default;// 纯虚函数定义契约不暴露实现细节virtualboolstartDevice(conststd::stringid)0;virtualboolstopDevice(conststd::stringid)0;virtualstd::vectorstd::stringdeviceList()const0;};插件 B 实现这个接口插件 A 通过对象池获取// 插件 B注册实现到对象池classDeviceManagerImpl:publicIDeviceManager{public:boolstartDevice(conststd::stringid)override{// 真正的设备启动逻辑returnm_driver-open(id);}// ... 其他实现};// 插件 B 初始化时注册voidPluginB::initialize(){automgrstd::make_sharedDeviceManagerImpl();objectPool()-addObject(devmgr,mgr);// 注册时不暴露 DeviceManagerImpl 类型// 对象池只知道它是个 std::shared_ptrvoid}// 插件 A通过接口调用零实现耦合voidPluginA::doSomething(){automgrobjectPool()-getObjectIDeviceManager(devmgr);if(mgr){mgr-startDevice(cam_001);// 调的是 IDeviceManager::startDevice// 不是 DeviceManagerImpl::startDevice// 编译期只依赖 common 头文件不依赖插件 B}}关键设计插件 A 的代码里没有出现DeviceManagerImpl这个类型。它只依赖IDeviceManager这个纯虚接口。插件 B 换掉实现类插件 A 不需要重新编译。对象池的统一性检查// 对象池的 getObject 做了 dynamic_cast 兜底templatetypenameTstd::shared_ptrTgetObject(conststd::stringname){autoitm_objects.find(name);if(itm_objects.end())returnnullptr;// dynamic_cast 做运行时类型校验// 如果注册的对象不继承 IDeviceManager返回空returnstd::dynamic_pointer_castT(it-second);}这套方案的核心约束接口必须提前定义在 common 层。如果两个插件是同一个团队开发的接口能提前约定好这是最清晰的做法。但如果插件 A 和插件 B 是两个不同团队开发的或者插件 B 还没写好接口定义呢那就得用第二种方案了。二、命名绑定 MethodDispatcher无头文件也能调真实场景插件 A 知道插件 B 有一个函数叫 “device.start”但它不知道插件 B 里对应的是哪个 C 类。你不能说你们团队先把接口定义好因为插件 B 的团队可能还在迭代接口三天一改。你也不能说那你等他们稳定了再对接因为项目 deadline 不等你。Aether 的解法MethodDispatcher一个基于字符串名字的函数调用中心。每个插件在注册服务时可以附带一个 MethodDispatcher把所有对外暴露的函数注册成字符串名字// 插件 B注册服务 可调用方法voidPluginB::initialize(){autodriverstd::make_sharedCameraDriver();autodispstd::make_sharedMethodDispatcher();// 把成员函数注册成字符串名字// 调用方不需要知道 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:usingInvokeFnstd::functionstd::any(void*instance,conststd::vectorstd::any);// 注册把成员函数指针擦除类型存为 InvokeFntemplatetypenameSvc,typenameRet,typename...Argsvoidon(conststd::stringname,Ret(Svc::*method)(Args...)){m_methods[name][method](void*inst,autoargs){// 把 void* 转回真正的类型——这里面有风险// 所以务必保证 instance 的类型和注册时一致auto*svcstatic_castSvc*(inst);returncallHelper(svc,method,args,std::index_sequence_forArgs...{});};}// 调用通过名字找函数void* 传实例std::anyinvoke(conststd::stringname,void*instance,std::vectorstd::anyargs{}){returnm_methods.at(name)(instance,std::move(args));}private:// 名字到函数的映射——这就是整个分发表std::unordered_mapstd::string,InvokeFnm_methods;};插件 A 调用时只需要知道 “camera” 这个服务名和 “device.start” 这个方法名// 插件 A不知道 CameraDriver 的类名但能调它的函数voidPluginA::onUserClickStart(){// 从 IoC 容器获取 camera 服务的调用句柄autohandlecontainer()-makeNamed(camera);// 通过字符串名字调用参数用 std::any 传autookhandle.call(device.start,{std::any{std::string(cam_001)}});// 返回值的类型也是 std::any调用方自己处理}这套方案的取舍你失去的是编译期类型安全检查换来的是零头文件依赖。插件 B 今天改了CameraDriver::start的参数插件 A 不需要重新编译。只要运行时参数传对了就能正常工作。但有一个大坑——字符串拼写错了怎么办答案是跑不起来才知道。这也就是为什么 Aether 没有只用这一种方案。有接口定义能力的时候还是用第一种。MethodDispatcher 是给真的没法共用头文件的场景准备的。三、消息总线一对多广播通信前两种方案解决的都是一对一调用。但插件系统里还有一种更常见场景一个事件发生多个插件都要响应。比如设备掉线了UI 插件要弹提示日志插件要记录数据采集插件要停采监控插件要报警。如果用接口通信你得写四个接口、四个注册。如果设备管理器每次状态变更都单独调一遍代码会变成这样// ❌ 耦合式通知设备管理器知道太多外部插件了voidDeviceManager::onDisconnected(conststd::stringid){// 设备管理器不该知道有这些插件存在m_ui-showAlert(id 已断开);m_logger-log(设备断开: id);m_collector-stop(id);m_monitor-reportOffline(id);}这段代码最大的问题不是重复——是设备管理器必须知道所有下游插件的类型。每加一个关心设备状态的插件就要改 DeviceManager 的代码。这违反了开闭原则。Aether 的消息总线解决了这个问题// ✅ 消息总线设备管理器只管发消息不管谁收voidDeviceManager::onDisconnected(conststd::stringid){// 只发一条消息谁收谁自己注册MessageBus::publish(device.offline,DeviceEvent{id,timestamp()});}// 其他插件各自注册关心的消息// UI 插件voidUIPlugin::initialize(){MessageBus::subscribe(device.offline,[](constDeviceEvente){showAlert(e.deviceId 已断开);});}// 日志插件voidLogPlugin::initialize(){MessageBus::subscribe(device.offline,[](constDeviceEvente){Log::error(设备断开: {},e.deviceId);});}// 数据采集插件voidCollectorPlugin::initialize(){MessageBus::subscribe(device.offline,[](constDeviceEvente){Collector::stopCollect(e.deviceId);});}核心变化设备管理器不再依赖任何下游插件。它不知道 UI 插件是否存在不知道日志系统是否启动。它只做一件事——设备掉线了发一条消息。谁爱收谁收。消息总线的实现也不复杂// 消息总线核心主题订阅 广播分发classMessageBus{public:// 订阅按主题名注册回调templatetypenameEventstaticvoidsubscribe(conststd::stringtopic,std::functionvoid(constEvent)cb){// 类型擦除存起来收到消息时 dynamic_cast 校验handlers()[topic].push_back({std::any{std::move(cb)},std::type_index(typeid(Event))});}// 发布遍历所有订阅者逐个调用templatetypenameEventstaticvoidpublish(conststd::stringtopic,constEventevent){for(autoh:handlers()[topic]){// 类型校验订阅时注册的类型必须匹配if(h.typestd::type_index(typeid(Event))){autocbstd::any_caststd::functionvoid(constEvent)(h.handler);cb(event);}}}private:structHandler{std::any handler;std::type_index type;};staticstd::unordered_mapstd::string,std::vectorHandlerhandlers(){staticstd::unordered_mapstd::string,std::vectorHandlerinstance;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 实战续集来了。