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