插件架构设计模式:发现、生命周期、依赖与通信 / Plugin Architecture Patterns for Discovery, Lifecycle, Dependencies, and Communication
📅 创建时间:2026-07-13 🏷️ 标签:#插件架构 #设计模式 #生命周期 #依赖管理 #事件总线 📚 前置知识:plugin interface design, cmake plugin project
📋 本章目标
- 掌握四种插件发现机制及其适用场景
- 理解插件完整生命周期状态机的设计
- 学会管理插件间依赖:拓扑排序与循环检测
- 理解插件间通信的三种模式:中介者、服务定位器、事件总线
- 通过 VS Code / QGIS / 游戏 Mod 系统理解真实架构选择
第1部分:插件发现 —— 宿主怎么找到插件?
1.1 四种发现机制
┌─────────────────────────────────────────────────────────────────────────────┐
│ 四种插件发现机制 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 方法1:约定目录扫描(最常用,推荐) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ plugins/ │ │
│ │ ├── plugin_math.so │ │
│ │ ├── plugin_text.so │ │
│ │ └── disabled/ ← 不想加载的放子目录或不扫描 │ │
│ │ └── plugin_old.so │ │
│ │ │ │
│ │ std::vector<std::string> scan_plugins(const char* dir) { │ │
│ │ for (auto& entry : fs::directory_iterator(dir)) { │ │
│ │ if (entry.path().extension() == ".so" || │ │
│ │ entry.path().extension() == ".dll") { │ │
│ │ results.push_back(entry.path()); │ │
│ │ } │ │
│ │ } │ │
│ │ return results; │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 方法2:配置文件注册 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ // plugins.json │ │
│ │ { │ │
│ │ "plugins": [ │ │
│ │ {"name": "math", "path": "./plugins/libmath.so", │ │
│ │ "enabled": true, "priority": 10}, │ │
│ │ {"name": "text", "path": "./plugins/libtext.so", │ │
│ │ "enabled": true, "priority": 5} │ │
│ │ ] │ │
│ │ } │ │
│ │ 优点:可以排优先级、控制启用/禁用 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 方法3:注册表(Windows only) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 插件安装时在注册表中写 CLSID 和 DLL 路径 │ │
│ │ 宿主通过 COM 或注册表查找 │ │
│ │ 适用于:需要系统级注册的大型桌面软件 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 方法4:环境变量 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ export MYAPP_PLUGIN_PATH=/opt/plugins:/home/user/.myapp/plugins│ │
│ │ 适用于:开发者调试、自定义部署 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第2部分:插件生命周期管理
2.1 状态机设计
┌─────────────────────────────────────────────────────────────────────────────┐
│ 插件生命周期状态机 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ UNLOADED │ ← 初始状态 │
│ └────┬─────┘ │
│ │ dlopen / LoadLibrary │
│ ▼ │
│ ┌──────────┐ │
│ │ LOADED │ ← 库已在内存中,但还没有执行任何初始化 │
│ └────┬─────┘ │
│ │ dlsym create + plugin->init() │
│ ▼ │
│ ┌──────────┐ │
│ │INITIALIZED│ ← 插件已经就绪,可以接受调用 │
│ └────┬─────┘ │
│ │ 正常调用... │
│ │ 或 plugin->shutdown() │
│ ▼ │
│ ┌──────────┐ │
│ │ SHUTDOWN │ ← 清理资源,准备卸载 │
│ └────┬─────┘ │
│ │ dlclose / FreeLibrary │
│ ▼ │
│ ┌──────────┐ │
│ │ UNLOADED │ ← 回到初始状态 │
│ └──────────┘ │
│ │
│ 错误状态: │
│ UNLOADED → LOADED → INITIALIZED 过程中任何步骤失败 │
│ → ERROR(记录错误信息,清理已分配资源) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘2.2 代码实现——带生命周期的接口
class IPlugin {
public:
virtual ~IPlugin() = default;
// 生命周期
virtual bool initialize() = 0; // 初始化(分配资源、注册服务)
virtual void shutdown() = 0; // 关闭(释放资源、注销服务)
// 元信息
virtual const char* getName() = 0;
virtual const char* getVersion() = 0;
// 依赖声明
virtual const char** getDependencies() { return nullptr; }
};// 宿主中的插件管理器
class PluginManager {
struct PluginEntry {
void* handle;
IPlugin* instance;
enum State { UNLOADED, LOADED, INITIALIZED, ERROR } state;
std::string path;
};
public:
bool loadPlugin(const std::string& path) {
auto& entry = entries.emplace_back();
entry.path = path;
entry.state = LOADED;
// Step 1: 加载库
entry.handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!entry.handle) {
entry.state = ERROR;
return false;
}
// Step 2: 获取工厂函数
auto create = (IPlugin*(*)())dlsym(entry.handle, "create_plugin");
if (!create) { dlclose(entry.handle); entry.state = ERROR; return false; }
// Step 3: 创建实例
entry.instance = create();
entry.state = LOADED;
// Step 4: 初始化
if (!entry.instance->initialize()) {
delete entry.instance;
dlclose(entry.handle);
entry.state = ERROR;
return false;
}
entry.state = INITIALIZED;
return true;
}
void shutdownAll() {
for (auto it = entries.rbegin(); it != entries.rend(); ++it) {
if (it->state == INITIALIZED) {
it->instance->shutdown();
delete it->instance; // 插件内部分配,插件内部释放
dlclose(it->handle);
it->state = UNLOADED;
}
}
}
};第3部分:插件间依赖管理
3.1 依赖声明
// 插件声明自己的依赖
extern "C" const char** get_dependencies() {
static const char* deps[] = {"plugin_math", "plugin_database", nullptr};
return deps;
}
extern "C" const char* get_min_version(const char* plugin_name) {
if (strcmp(plugin_name, "plugin_math") == 0) return "1.0.0";
if (strcmp(plugin_name, "plugin_database") == 0) return "2.0.0";
return "0.0.0";
}3.2 拓扑排序加载
┌─────────────────────────────────────────────────────────────────────────────┐
│ 依赖拓扑排序 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 插件依赖图: │
│ │
│ plugin_report ────→ plugin_data ────→ plugin_database │
│ │ ↑ │
│ └────────→ plugin_math ──────────────┘ │
│ │
│ 正确的加载顺序: │
│ 1. plugin_database(没有依赖) │
│ 2. plugin_data(依赖 database) │
│ 3. plugin_math(依赖 database) │
│ 4. plugin_report(依赖 data + math) │
│ │
│ 算法:Kahn's algorithm (BFS 拓扑排序) │
│ • 构建入度表 │
│ • 入度为 0 的插件先加载 │
│ • 加载后减少依赖它的插件的入度 │
│ • 循环依赖 → 加载失败 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘第4部分:插件间通信
4.1 三种通信模式对比
┌─────────────────────────────────────────────────────────────────────────────┐
│ 插件间通信模式 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 模式1:通过宿主中转(Mediator 中介者) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ │
│ │ PluginA ──→ Host (PluginManager) ──→ PluginB │
│ │ │
│ │ Host 提供一个 request(const char* target, const char* msg, │
│ │ void* data) 方法 │
│ │ PluginA 调用 host->request("PluginB", "compute", ¶ms) │
│ │ Host 找到 PluginB,调用 PluginB->handleRequest("compute", ¶ms) │
│ │ │
│ │ ✅ 解耦:插件不直接知道彼此 │
│ │ ❌ Host 成了瓶颈(所有通信都经过它) │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 模式2:服务定位器(Service Locator) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ │
│ │ Host 提供 ServiceRegistry │
│ │ │
│ │ PluginA 注册:registry->register("logger", myLogger) │
│ │ PluginB 查找:auto logger = registry->get<ILogger>("logger") │
│ │ │
│ │ ✅ 灵活:插件可以按需查找服务 │
│ │ ✅ 松耦合:插件只依赖接口类型,不依赖具体插件 │
│ │ ❌ 服务不存在时的处理需要显式处理 │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 模式3:事件总线(Event Bus / Pub-Sub) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ │
│ │ Host 提供 EventBus │
│ │ │
│ │ PluginA 发布:bus->publish("file.saved", {path: "/tmp/test.txt"}) │
│ │ PluginB 订阅:bus->subscribe("file.saved", onFileSaved) │
│ │ PluginC 也订阅:bus->subscribe("file.*", onFileChanged) │
│ │ │
│ │ ✅ 最解耦:发布者和订阅者完全互相不知道 │
│ │ ✅ 一对多通信天然支持 │
│ │ ❌ 调试困难:事件追踪不直观 │
│ │ ❌ 顺序不确定:多个订阅者的执行顺序不保证 │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘4.2 服务定位器完整示例
// 服务注册中心
class ServiceRegistry {
public:
template<typename T>
void registerService(const char* name, T* service) {
services[name] = static_cast<void*>(service);
}
template<typename T>
T* getService(const char* name) {
auto it = services.find(name);
return (it != services.end()) ? static_cast<T*>(it->second) : nullptr;
}
private:
std::unordered_map<std::string, void*> services;
};
// 在插件初始化时:
class MathPlugin : public IPlugin {
bool initialize() override {
// 注册自己提供的服务
registry->registerService<ICalculator>("calculator", this);
// 查找自己需要的服务
auto logger = registry->getService<ILogger>("logger");
if (!logger) return false; // 依赖的服务不存在
return true;
}
};第5部分:真实案例分析
┌─────────────────────────────────────────────────────────────────────────────┐
│ 三种典型的插件架构 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ VS Code 扩展系统: │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 进程隔离:每个扩展在独立的 Extension Host 进程中运行 │ │
│ │ • JSON 声明:package.json 声明 activationEvents │ │
│ │ • 延迟激活:只在匹配的 activationEvents 触发时才加载扩展 │ │
│ │ • API 注入:宿主向扩展注入 vscode API 对象 │ │
│ │ • 安全:进程隔离 + 权限声明 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ QGIS 插件系统: │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • Python 主导:绝大多数插件是 Python(pyqgis) │ │
│ │ • C++ Provider:底层数据处理插件可以用 C++ │ │
│ │ • 约定目录:~/.qgis/python/plugins/ │ │
│ │ • metadata.txt:声明插件元信息 │ │
│ │ • 热加载:Plugin Reloader 插件支持不重启加载 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 游戏引擎 Mod 系统(以 Unreal/Unity 为参考): │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ • 资源 + 代码:Mod = .pak(资产) + .dll/.so(代码) │ │
│ │ • Mount/Unmount:运行时加载/卸载 Mod │ │
│ │ • 依赖声明:Mod 可以在 .json 中声明对其他 Mod 的依赖 │ │
│ │ • 补丁系统:先后加载的 Mod 覆盖前面的资源 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘核心总结
┌─────────────────────────────────────────────────────────────────────────────┐
│ 插件架构核心模式 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 发现:约定目录扫描(最简单)→ 配置文件(可控)→ 注册表(企业级) │
│ │
│ 生命周期:UNLOADED → LOADED → INITIALIZED → SHUTDOWN → UNLOADED │
│ │
│ 依赖:拓扑排序加载 + 循环依赖检测 │
│ │
│ 通信三模式: │
│ • 中介者(Host 中转)—— 简单但瓶颈 │
│ • 服务定位器 —— 灵活松耦合,推荐 │
│ • 事件总线 —— 最解耦,适合一对多 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘章节测试
测试1:发现机制
四种插件发现机制分别适用于什么场景?为什么"约定目录扫描"是最常用的?
测试2:生命周期
为什么插件需要 initialize() 和 shutdown() 两个独立步骤?只在 dlopen 时初始化和 dlclose 时清理不行吗?
测试3:依赖
A 依赖 B,B 依赖 C。加载这三个插件的正确顺序是什么?如果 C 也声明依赖 A 呢?
测试4:通信模式
服务定位器和事件总线有什么区别?什么场景下该用哪个?
参考答案
测试1答案
答案:(1) 约定目录扫描——最简单,用户只需把 .so/.dll 放到 plugins/ 目录;(2) 配置文件——适合需要控制启用/禁用和优先级的企业环境;(3) 注册表——Windows 桌面软件需要 COM 注册的场景;(4) 环境变量——适合开发和调试。约定目录最常用因为零配置,用户体验最好。
测试2答案
答案:分离 initialize 和 shutdown 是因为:(1) dlopen 只加载库文件,插件的初始化可能需要访问其他已加载的服务,应该等所有插件加载完再初始化;(2) dlclose 是释放库文件,插件的清理(如保存状态、注销服务)应该在 shutdown 中显式完成;(3) 生命周期分离让错误恢复更容易——init 失败可以只清理这个插件而不影响其他。
测试3答案
答案:加载顺序:C → B → A(拓扑排序)。如果 C 也依赖 A → 循环依赖 → 无法确定加载顺序 → 应该拒绝加载。循环依赖表示架构设计有问题,需要重构(比如提取公共接口到一个单独的模块)。
测试4答案
答案:服务定位器是"请求-响应"模式——PluginB 主动查询"谁提供 ILogger 服务"并获取实例。事件总线是"发布-订阅"模式——Publisher 不知道谁在听,Subscriber 不知道谁在发布。选服务定位器:需要同步的、请求-响应式的交互(如"帮我计算 3+5")。选事件总线:一对多的通知(如"文件已保存,所有关心的插件请更新")。
相关笔记
- plugin interface design - 插件接口设计
- cmake plugin project - CMake 构建插件项目
- hot reload versioning - 热加载需要依赖生命周期的支持
下一步学习
- [ ] 阅读 10 - 跨平台插件开发
学习状态:🟡 开始学习
工程深化:把本篇知识落到项目里
这一节不是为了凑篇幅,而是把《插件架构设计模式:发现、生命周期、依赖与通信 / Plugin Architecture Patterns for Discovery, Lifecycle, Dependencies, and Communication》从“知道概念”推进到“能在项目里稳定使用”。 阅读时可以把每个知识点都追问成四件事:它保护什么边界,失败时有什么现象,怎样最小复现,怎样写进团队流程。
concept -> boundary -> failure signal -> minimal proof -> project rule工程切片 1:最小可复现样例
**场景。**围绕 插件发现 建一个很小的案例,不要一开始就放进完整业务系统。把问题压缩到一个可以提交给同事的目录,保留源码、构建命令、版本输出和预期现象。
**边界。**先判断这里讨论的是 插件发现、加载器 还是 契约 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 插件发现 -> 加载器 -> 契约 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 2:接口边界
**场景。**围绕 加载器 建一个很小的案例,不要一开始就放进完整业务系统。写清调用方能依赖什么、不能依赖什么,并把隐式假设转换成命名函数、配置项或测试。
**边界。**先判断这里讨论的是 加载器、契约 还是 生命周期 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 加载器 -> 契约 -> 生命周期 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 3:失败注入
**场景。**围绕 契约 建一个很小的案例,不要一开始就放进完整业务系统。故意制造一个常见错误,让日志、断言或测试先失败,再用修复后的证据说明规则生效。
**边界。**先判断这里讨论的是 契约、生命周期 还是 隔离 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 契约 -> 生命周期 -> 隔离 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 4:跨平台差异
**场景。**围绕 生命周期 建一个很小的案例,不要一开始就放进完整业务系统。至少比较 Windows、Linux 或不同编译器下的一个差异,记录差异属于标准、实现还是平台约定。
**边界。**先判断这里讨论的是 生命周期、隔离 还是 诊断 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 生命周期 -> 隔离 -> 诊断 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 5:性能观察
**场景。**围绕 隔离 建一个很小的案例,不要一开始就放进完整业务系统。用小规模和压力规模各跑一次,区分算法成本、同步成本、I/O 成本和工具链配置成本。
**边界。**先判断这里讨论的是 隔离、诊断 还是 插件发现 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 隔离 -> 诊断 -> 插件发现 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
工程切片 6:生命周期
**场景。**围绕 诊断 建一个很小的案例,不要一开始就放进完整业务系统。标出资源创建、转移、共享、停止和销毁的顺序,特别关注错误返回和提前退出路径。
**边界。**先判断这里讨论的是 诊断、插件发现 还是 加载器 的责任;如果三者混在一起,先把输入、输出和所有权拆开。
input -> 诊断 -> 插件发现 -> 加载器 -> observable result**验证。**记录一条能重复运行的命令或测试,并写明成功输出和失败输出。只说“本机能跑”不够,至少要记录工具版本、构建类型和关键参数。
**常见错误。**把偶然通过当成规则、把私有实现当成公开契约、或者让错误路径绕开清理逻辑,都会让本篇主题在真实项目里变得不可维护。
**复盘问题。**如果明天换一个编译器、Qt 版本、插件版本、输入规模或发布目录,这个结论是否仍成立?不成立时,应该由文档、测试还是 CI 给出提示?
本篇收束
掌握《插件架构设计模式:发现、生命周期、依赖与通信 / Plugin Architecture Patterns for Discovery, Lifecycle, Dependencies, and Communication》的标志,不是记住所有名词,而是能把 插件发现、加载器、契约、生命周期、隔离、诊断 放进一条可验证的工程链路。 先用最小样例建立判断,再用测试和脚本固定判断,最后把失败证据留给未来的自己和团队。