插件工程:可演进的模块边界 / Plugin Engineering and Evolvable Module Boundaries
插件系统的核心不是 dlopen 或 LoadLibrary,而是宿主与扩展之间长期稳定的契约:接口、ABI、所有权、错误传播、卸载顺序和权限边界。
本模块不再承担 C++ 项目从源码到可执行文件、Windows/Linux 编译产物、链接器和构建系统的基础教学。
这些前置内容已经分层放在:
11-environment-and-toolchain
解释 object file、dynamic library、loader、CMake/Ninja、依赖和平台运行环境
08-build-tooling-and-abi
解释 ABI 策略、CI、发布证据和工程质量门禁
10-plugin-engineering
只聚焦插件系统:发现、加载、接口协商、生命周期、热更新、安全和工业案例一个普通动态库只解决“代码怎样被链接或装入进程”,插件系统还必须回答“宿主怎样发现能力、怎样判断兼容、谁创建和销毁对象、故障是否会拖垮主程序、升级后旧工程能否继续打开”。只要其中一个问题没有形成协议,系统就可能在演示环境正常,却在多版本并存、第三方扩展或热更新时失效。
插件从发现到卸载的生命周期
扫描目录或读取清单
│
v
校验来源、平台、版本与依赖
│ 不满足约束 -> 拒绝并记录诊断
v
加载动态库 -> 查找稳定入口 -> 协商接口版本
│
v
创建插件实例 -> 注册命令/格式/求解器能力
│
v
运行、监控、取消任务
│
v
撤销注册 -> 等待对象与线程退出 -> 销毁实例 -> 卸载模块加载只是生命周期中很短的一步。真正困难的是卸载:插件创建的对象、回调、线程、GPU 资源或函数指针只要还有一个存活,关闭动态库后就可能跳转到已经失效的代码页。因此生产系统通常维护模块状态和活动引用计数;无法证明已静止时,宁可延迟卸载或要求重启,也不冒险强制释放。
契约应稳定在哪一层
最脆弱的做法是直接跨模块暴露复杂 C++ 类。编译器版本、标准库、编译选项、异常模型和内存分配器任一不同,都可能改变对象布局或释放方式。更稳妥的边界通常是窄 C ABI、版本化函数表,或者在严格统一工具链的前提下使用纯抽象接口。
extern "C" PluginApiV1 const* plugin_query_v1();入口名称和调用约定保持稳定,返回结构包含版本、结构大小与函数指针。宿主只调用双方都认识的字段,新版本通过新增结构或能力位扩展。对象由哪一侧创建,就应由同一侧提供销毁函数;不要让宿主用自己的 delete 释放插件分配的对象。错误也应转换为状态码、错误对象或受控异常边界,不能让未知异常穿过 ABI。
架构选择与边界
- 静态链接适合能力固定、部署简单且不要求独立升级的组件;它不是插件。
- 运行时动态插件适合格式适配器、求解器、命令和后处理模块,但必须承担兼容与安全成本。
- 进程外插件通过 IPC 增加延迟和部署复杂度,却能隔离崩溃、权限和不可信依赖,适合高风险扩展。
- 脚本插件迭代快,但仍需限制文件、网络、进程和宿主 API 权限,不能把“脚本”误当成天然安全。
设计时先定义插件能做什么,再定义它不能做什么。权限默认最小化,文件格式解析等不可信输入应设置尺寸、时间和递归深度限制;插件加载失败必须产生可定位的日志,而不是只显示“初始化失败”。
判断项目是否真的需要插件
插件架构会增加独立构建产物、版本组合和运行时失败路径。 只有需求确实包含独立扩展或独立部署时,这些成本才值得承担。
适合使用插件的场景包括:
- 第三方需要在不修改宿主源码的情况下增加能力;
- 不同客户需要选择不同的格式、求解器或后处理模块;
- 扩展与宿主具有不同发布周期;
- 某些依赖因许可证或平台限制不能进入宿主核心;
- 高风险模块需要进程隔离和独立恢复;
- 宿主必须在运行时发现编译时未知的实现。
如果全部模块由同一团队、同一流水线和同一版本发布,普通库和内部组件通常更简单。 插件不是模块化的同义词,它只是把边界推进到了二进制和运行时。
用能力而不是具体类型组织扩展
宿主不应通过识别具体插件类来决定行为。 更稳定的方式是让插件声明“导入网格”“运行求解器”“注册命令”等能力。
enum class Capability : std::uint64_t {
ImportMesh = 1ull << 0,
ExportModel = 1ull << 1,
SolveLinear = 1ull << 2,
PostProcessField = 1ull << 3,
};
struct PluginDescriptorV1 {
std::uint32_t structSize;
std::uint32_t apiVersion;
std::uint64_t capabilities;
char const* id;
char const* displayName;
};structSize 允许宿主只读取双方都认识的结构前缀。 apiVersion 用于协议协商,稳定 id 用于配置和工程文件。 显示名称可以本地化,不能承担稳定身份职责。
能力位只说明插件声称提供什么。 真正调用前仍要检查函数指针、依赖和运行条件。
Manifest 负责发现,二进制入口负责确认
大型系统通常先读取轻量清单,再决定是否装载动态库。 这使宿主可以在执行第三方代码之前完成平台和权限检查。
{
"schemaVersion": 1,
"id": "com.example.mesh.bdf",
"version": "2.4.1",
"hostApi": ">=3 <5",
"platform": "windows-x86_64",
"library": "mesh_bdf.dll",
"capabilities": ["mesh.import.bdf"],
"permissions": ["filesystem.read"]
}Manifest 本身是不可信输入。 路径必须限制在插件包目录内,字符串长度、文件大小和依赖数量都需要上限。 重复 ID、非法版本和逃逸目录的相对路径必须在加载前拒绝。
清单声明不能替代动态库入口返回的 Descriptor。 加载后仍要核对 ID、版本和能力,防止清单与二进制不一致。
生命周期必须建模为状态机
一个 bool loaded 无法表达部分初始化、停止中或延迟卸载。 宿主至少需要区分发现、校验、装载、注册、活动和停止。
Discovered
|
v
Validated --失败--> Rejected
|
v
Loaded --入口失败--> Failed
|
v
Registered <-> Active Tasks
|
v
Stopping --仍有引用--> Deferred
|
v
Unregistered -> Unloaded加载管理器拥有动态库句柄。 注册中心拥有能力可见性。 任务系统跟踪活动调用。 插件实例只管理自身资源。
初始化失败时要记录已经完成的阶段,再按严格反序清理。 无条件调用全部 Finalize 逻辑,可能释放尚未创建或已经移交的对象。
所有权是 ABI 的一部分
| 对象 | 创建方 | 销毁方 | 能否跨卸载存活 |
|---|---|---|---|
| 动态库句柄 | 宿主 | 宿主 | 否 |
| 插件实例 | 插件工厂 | 插件销毁函数 | 否 |
| 宿主服务表 | 宿主 | 宿主 | 是 |
| 返回缓冲区 | 协议指定 | 同一分配域 | 取决于复制规则 |
| 后台任务 | 插件或任务服务 | 创建方协调回收 | 否 |
| 工程领域数据 | 宿主文档 | 宿主 | 是 |
跨模块传递 std::string、std::vector、异常和拥有资源的智能指针,会把标准库 ABI 与分配器带入协议。 窄 ABI 更适合传递长度明确的字节视图、普通结构和不透明句柄。
对象由插件创建时,插件必须提供对应销毁入口。 宿主不能假设自己的 delete、运行库和分配器与插件完全一致。
工程数据可以跨卸载保留,但其中不能保存插件对象地址、虚函数指针或回调。 需要保留插件私有数据时,应序列化成版本化字节块,并提供缺失插件时的降级策略。
错误协议不能只有 false
插件失败需要区分业务错误、插件内部错误和协议违规。 单一布尔值无法支持诊断、重试和用户提示。
struct PluginStatusV1 {
std::int32_t code;
std::uint32_t messageSize;
char const* messageUtf8;
};
PluginStatusV1 import_mesh(
PluginHandle plugin,
HostDocumentHandle document,
ByteView pathUtf8);稳定错误码供程序判断,UTF-8 消息用于日志和界面。 消息指针的生命周期必须写清,宿主通常应立即复制。
未知 C++ 异常不能穿过 ABI。 插件应在最外层捕获异常并转换为协议错误。 若进程内崩溃仍不可接受,就应把插件移动到独立进程,而不是继续增加 try/catch。
线程、回调和取消
协议必须说明函数在哪个线程调用、是否允许阻塞、回调能否重入,以及取消如何传递。 GUI 宿主不能允许插件在主线程执行长时间解析或求解。
GUI 发起命令
-> 宿主冻结输入快照
-> 任务线程调用插件
-> 插件通过受控回调报告进度
-> 宿主队列切回 GUI 提交结果进度回调应当轻量,不能反向等待任务完成。 否则任务等待 GUI、GUI 又等待任务时会形成死锁。
取消通常使用协作式 Token。 插件在安全点检查请求、结束当前事务、释放临时资源并返回取消状态。 超时只说明调用者停止等待,不代表插件线程已经终止。
宿主只有确认活动任务、回调和对象全部退出后,才能卸载动态库。
三类版本必须分开
Manifest Schema、宿主插件 API 和插件私有数据格式不应共用一个版本号。
- Manifest Schema 决定宿主怎样读取包信息;
- 插件 API 决定函数表、调用约定和所有权;
- 数据 Schema 决定工程文件怎样保存插件状态;
- 插件产品版本用于发布、诊断和依赖选择。
增加可选函数或结构尾字段,通常可以保持同一 API 主版本。 改变字段语义、调用约定或所有权时,必须提升不兼容版本。
宿主可以同时暴露多个版本的函数表,为旧插件提供迁移窗口。 兼容窗口应有明确期限和遥测,不能永久积累无人测试的协议。
热更新是一项状态迁移功能
热更新不是再次调用加载函数。 它需要停止旧模块、提取状态、撤销注册、销毁对象、装载新模块并迁移状态。
Quiesce -> Snapshot -> Unregister -> Unload old
-> Load new -> Migrate -> Resume任一步失败都要决定回滚旧版本还是禁用插件。 如果旧模块已经卸载而新状态迁移失败,宿主必须保留可恢复的快照。
桌面工业软件通常可以通过重启宿主换取一致性。 如果产品确实要求不停机升级,进程外 Worker 更容易实现滚动切换: 启动新 Worker,迁移新任务,等待旧 Worker 排空,再结束旧进程。
同进程加载不等于安全隔离
同进程插件拥有宿主进程的全部权限。 它可以读取内存、文件、环境变量,也可以破坏堆和线程状态。
签名与哈希验证来源和完整性,不能限制运行时行为。 面对不可信或高风险插件,应使用独立进程、低权限身份和显式 IPC 协议。
文件解析插件还要限制:
- 输入文件和解压后数据大小;
- 实体数量、递归深度和字符串长度;
- 处理时间、内存和并发任务数;
- 整数溢出、索引范围和循环引用;
- 临时目录、网络和子进程权限。
解析失败不能在当前 Document 留下半提交对象。 插件应先构造临时结果,通过校验后再由宿主事务提交。
可观测性是可维护性的前提
每次加载应记录插件 ID、版本、文件哈希、宿主 API、平台和结果。 每次调用携带任务 ID,记录持续时间、错误码、取消和资源峰值。
日志不能包含密钥、完整用户文件或敏感模型内容。 诊断信息要足以回答:
- 扫描到了哪些插件包;
- 某个插件为什么被拒绝;
- 当前注册了哪些能力;
- 哪些活动对象阻止卸载;
- 最近一次调用在哪个阶段失败;
- 宿主与插件实际协商了哪个 API 版本。
没有这些信息,加载问题很容易被误判为随机启动故障。
测试矩阵
插件自己的单元测试不能覆盖宿主边界。 完整测试至少包含:
- ABI 测试:入口名称、调用约定、结构大小与对齐;
- 契约测试:宿主对所有实现运行同一组行为断言;
- 生命周期测试:重复加载、部分初始化失败、取消和卸载;
- 兼容测试:支持范围内的宿主与插件版本组合;
- 恶意输入测试:坏 Manifest、截断文件和资源上限;
- 隔离测试:插件崩溃、超时和 IPC 中断;
- 端到端测试:工程保存、重开和缺失插件降级。
测试仓库应保留最小故障插件:
- 缺少入口符号;
- 返回不支持的 API 版本;
- 初始化到一半失败;
- 创建对象后拒绝停止;
- 后台任务忽略第一次取消;
- 返回非法长度或错误编码;
- 故意崩溃的进程外 Worker。
这些样例使失败路径能够持续回归,而不依赖真实大型插件。
设计评审清单
- 插件提供的是独立能力,还是普通内部模块?
- ABI 是否足够窄,并明确编码、长度、对齐和调用约定?
- 每个跨边界对象由谁创建、谁销毁?
- 部分初始化失败时如何反序清理?
- 活动调用、回调和线程如何阻止提前卸载?
- Manifest、API 和数据 Schema 是否独立版本化?
- 缺失、过期或崩溃插件如何降级?
- 第三方代码是否必须获得同进程权限?
- 哪些日志能够重建加载与调用过程?
- 兼容矩阵由哪些自动测试持续守护?
如果其中一项只能回答“由插件开发者自己注意”,说明契约还没有真正建立。
Windows 动态加载的诊断路径
Windows 宿主通常通过 LoadLibraryExW 装载插件,并用 GetProcAddress 查找入口。 生产代码应使用绝对规范路径,避免当前工作目录改变依赖解析结果。
HMODULE module = LoadLibraryExW(
absolutePath.c_str(),
nullptr,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR |
LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
if (!module) {
DWORD code = GetLastError();
reportWindowsLoadError(absolutePath, code);
}“找不到模块”不一定表示主 DLL 不存在。 它也可能表示某个传递依赖缺失、位数不匹配或初始化函数失败。
诊断时按以下顺序检查:
- 插件文件是否位于清单声明的绝对路径;
- PE 架构是否与宿主一致;
- 依赖 DLL 是否能在受控搜索目录中找到;
- 入口符号是否以协议要求的名称导出;
- 运行库和编译器 ABI 是否满足支持矩阵;
DllMain是否执行了不允许的阻塞或加载操作;- 安全软件或文件来源策略是否阻止装载。
不要通过把所有依赖复制到系统目录来“修复”搜索问题。 这会污染全局环境,使其他应用加载到错误版本。
宿主应为每个插件包建立私有依赖目录,或通过部署工具保证依赖闭包完整。
Linux 动态加载的诊断路径
Linux 通常使用 dlopen、dlsym 和 dlclose。
void* module = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!module) {
std::string message = dlerror();
reportLoadFailure(path, message);
}RTLD_NOW 在加载时解析符号,使问题尽早暴露。 RTLD_LOCAL 避免插件符号默认污染全局命名空间。
常见失败来源包括:
- ELF 架构或 ABI 不匹配;
RPATH、RUNPATH或系统搜索路径错误;- 依赖库的 SONAME 版本不可用;
- 宿主没有导出插件需要的符号;
- C++ 标准库或 GLIBC 版本高于部署环境;
- 符号可见性设置隐藏了稳定入口;
- 两个插件依赖同名但不兼容的库版本。
可以用只读工具检查 ELF 依赖和导出符号,但工具输出只是静态证据。 最终仍要在目标部署镜像中执行真实加载测试。
容器能固定用户态依赖,不能消除内核、驱动、GPU 和宿主挂载差异。
导出入口与符号可见性
稳定入口需要明确导出宏,不能依赖编译器默认可见性。
#if defined(_WIN32)
# define PLUGIN_EXPORT extern "C" __declspec(dllexport)
#else
# define PLUGIN_EXPORT extern "C" \
__attribute__((visibility("default")))
#endif
PLUGIN_EXPORT PluginApiV1 const* plugin_query_v1() noexcept;extern "C" 避免 C++ 名字改编影响入口查找。 它不自动形成稳定 ABI,结构布局和调用约定仍需协议约束。
入口应标记 noexcept,内部捕获所有异常并返回受控失败。 入口函数不应执行耗时初始化,只负责返回描述符或轻量工厂。
复杂初始化放在显式 initialize 阶段,便于报告进度和反序清理。
CMake 目标边界
插件应是独立目标,并只链接真正需要的依赖。
add_library(mesh_bdf_plugin MODULE
src/plugin_entry.cpp
src/bdf_importer.cpp)
target_compile_features(mesh_bdf_plugin PRIVATE cxx_std_20)
target_include_directories(mesh_bdf_plugin PRIVATE include)
target_link_libraries(mesh_bdf_plugin PRIVATE plugin_sdk)
set_target_properties(mesh_bdf_plugin PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES)宿主接口通过小型 SDK 目标提供。 SDK 不应把宿主内部头文件、私有模板和大型第三方库暴露给插件。
构建系统还要固定:
- 目标架构和运行库策略;
- Debug 与 Release 是否允许混用;
- 异常、RTTI 和字符集选项;
- 导出符号检查;
- 包目录布局与 Manifest 生成;
- 安装后的依赖扫描;
- 可复现构建信息与二进制哈希。
插件编译成功只证明源代码可以生成二进制。 安装测试必须从最终包位置启动干净宿主,验证搜索路径和依赖闭包。
宿主服务表
插件经常需要日志、内存、任务、配置和文档访问。 直接链接宿主内部单例会形成隐藏依赖。
struct HostServicesV1 {
std::uint32_t structSize;
void (*log)(LogLevel, Utf8View);
void* (*allocate)(std::size_t, std::size_t alignment);
void (*deallocate)(void*, std::size_t alignment);
TaskHandle (*submitTask)(TaskDescription const*);
DocumentHandle (*currentDocument)();
};服务表把依赖变成显式能力,也便于测试替换。 每个函数都需要说明线程安全、重入、生命周期和失败语义。
插件只能保存协议允许长期保存的服务句柄。 宿主升级服务表时使用结构大小和版本协商,不能就地改变旧字段语义。
测试环境可以提供记录型服务表,检查插件是否越权调用或泄漏资源。
进程外插件与 IPC
当崩溃隔离、依赖冲突或最小权限比调用延迟更重要时,应采用进程外插件。
Host Process
-> versioned request
-> length-delimited IPC channel
-> Plugin Worker
-> validated response
-> Host transaction commitIPC 协议需要消息长度上限、请求 ID、超时、取消和幂等语义。 宿主不能把来自 Worker 的偏移、数量和文件路径直接当作可信数据。
大网格不适合反复 JSON 序列化。 可以使用共享内存或临时对象存储传递大块数据,但控制消息仍要版本化。
共享内存描述符必须包含大小、布局、数据类型、校验和与所有权。 Worker 崩溃后,宿主负责识别并回收孤立资源。
进程外模式还需要心跳和健康状态。 没有响应时先停止派发新任务,再取消或终止 Worker,最后按策略重启。
重启不能自动重放有副作用的请求。 每个命令必须定义是否可安全重试,或使用幂等键阻止重复提交。
与 Document 和 Command 架构集成
插件不能直接任意修改宿主文档内部容器。 宿主应提供受控 Command 或事务接口,把修改、撤销和版本更新集中管理。
Plugin parses input
-> creates neutral change set
-> Host validates IDs and revision
-> Command applies atomically
-> Document revision increments
-> Views rebuild derived state中立 Change Set 只描述新增、修改和删除的领域实体。 它不能携带插件对象指针,也不能要求文档了解插件内部类型。
提交前检查输入 Document ID 和 revision。 用户已经编辑文档时,旧后台结果应拒绝、重新计算或显式合并。
Command 保存撤销所需的稳定 ID 和数据差异。 临时数组下标、视图选择索引和渲染句柄都不适合作为撤销依据。
大型修改可以分块计算,但最终领域提交仍要保持事务语义。 如果产品允许部分提交,协议必须明确每个分块的可见性和恢复方式。
插件私有数据的序列化
插件常需要把材料参数、算法配置或自定义对象写入工程文件。 宿主应把私有数据视为版本化扩展块,而不是理解其全部字段。
ExtensionRecord
├─ pluginId
├─ schemaVersion
├─ contentType
├─ payloadLength
├─ checksum
└─ payload读取工程时,即使插件缺失,宿主也应保留未知扩展块。 用户再次保存文件时不能静默删除无法理解的数据。
插件重新出现后,可以根据 schemaVersion 执行迁移。 迁移应从旧版本逐级转换,并在临时副本上完成。
迁移失败时保留原始 Payload 和错误诊断。 不要只留下部分转换后的不可恢复状态。
私有数据仍需受文件总大小和单块大小限制。 压缩 Payload 必须检查解压比例,避免压缩炸弹耗尽内存。
命令注册与 UI 扩展
UI 插件应注册命令语义,而不是直接把任意 QWidget 指针插入宿主内部布局。
命令描述可以包含:
- 稳定命令 ID;
- 本地化标题与说明键;
- 图标资源标识;
- 启用条件;
- 所需权限;
- 参数 Schema;
- 是否支持撤销;
- 是否为长时间任务。
宿主根据产品布局决定菜单、工具栏和快捷键。 这样同一插件能力可以在桌面、脚本和自动化接口中复用。
启用条件由当前 Selection 和 Document 状态计算。 插件不能缓存短生命周期的视图索引,再在用户切换文档后使用。
插件卸载前必须撤销命令、快捷键、Dock 和事件过滤器。 遗漏任何一个回调都可能在模块卸载后触发失效代码。
性能预算
插件边界会增加间接调用、数据转换、序列化和隔离成本。 是否可接受必须用端到端工作负载测量。
对同进程插件,应记录:
- 首次发现和加载时间;
- 初始化内存增量;
- 每次调用固定开销;
- 数据复制量;
- 任务队列与同步等待;
- 卸载和资源回收时间。
对进程外插件,还要记录 IPC 往返、序列化、共享内存建立和 Worker 启动。
不要为了减少一次函数调用而破坏 ABI 边界。 批量接口通常比暴露内部容器更安全:一次传递一批中立记录,插件内部再优化处理。
宿主应对插件设置资源预算。 超过内存、并发或时间限制时产生可诊断失败,而不是拖垮整个应用。
包格式与安装
插件包应包含 Manifest、主动态库、私有依赖、资源、许可证和可选调试符号索引。
com.example.mesh.bdf/
├─ plugin.json
├─ bin/
│ ├─ mesh_bdf.dll
│ └─ private_dependency.dll
├─ resources/
│ ├─ icons/
│ └─ translations/
├─ licenses/
└─ symbols.json安装过程先写入临时目录,验证完整性后再原子移动到版本目录。 不要覆盖正在运行版本的二进制文件。
同一插件可以并存多个版本,但一次工程或宿主会话需要明确选择规则。 选择结果应进入诊断信息,避免“机器上装了哪个就用哪个”的不可复现行为。
卸载包前检查是否仍有工程引用。 如果允许删除,也要保留项目中的扩展数据和缺失插件说明。
签名、哈希与供应链
发布者可以对包清单和文件哈希集合签名。 宿主验证签名链、文件哈希、插件 ID 和允许的发布者策略。
签名失败时不能提供“仍然加载”按钮给普通用户。 开发模式可以允许本地未签名插件,但必须与生产模式清晰区分。
构建流水线应生成软件物料清单,记录编译器、SDK 和第三方依赖版本。 发现高风险依赖后,可以定位受影响的插件包,而不必扫描用户机器猜测。
哈希用于识别具体二进制,不代替版本号。 相同语义版本的重新构建如果哈希不同,也应能在日志中区分。
发布与兼容矩阵
插件发布不能只测试最新宿主。 支持策略要明确列出宿主主版本、操作系统、架构和工具链组合。
Host 3.x Host 4.x Host 5.x
Plugin 1.x test test reject
Plugin 2.x test test test
Plugin 3.x reject test test矩阵中的 test 必须对应自动化契约测试,而不是文档声明。 未测试组合应默认拒绝或显示明确风险,不能静默尝试。
发布前执行干净安装、升级安装、降级回滚和缺失依赖测试。 升级成功后还要打开旧工程,验证私有数据迁移和能力注册。
弃用接口先产生诊断和迁移指南,再经过约定窗口删除。 宿主应记录仍在使用旧 API 的插件数量,为删除决策提供证据。
运维故障处理
启动阶段发现插件失败时,宿主应继续加载不依赖它的功能,并生成隔离报告。
报告至少包含:
- 插件包路径与哈希;
- Manifest 解析结果;
- 依赖检查结果;
- 动态加载系统错误;
- 入口与版本协商结果;
- 初始化完成阶段;
- 已执行的清理动作。
连续崩溃插件可以进入隔离区,下次启动默认禁用。 用户恢复时先在安全模式中验证,不要反复触发启动崩溃循环。
进程外 Worker 崩溃时,正在执行的任务进入明确终态。 宿主根据幂等协议决定重试,不能假装任务从未开始。
诊断包导出前必须脱敏路径、用户名、Token 和工程内容。
最小宿主加载器骨架
下面的伪代码强调顺序和失败清理,不绑定具体平台封装。
LoadResult PluginManager::load(PackagePath const& package) {
auto manifest = manifestReader_.read(package / "plugin.json");
if (!manifest) {
return fail(Stage::Manifest, manifest.error());
}
auto policy = policy_.validate(*manifest, package);
if (!policy) {
return fail(Stage::Policy, policy.error());
}
auto module = loader_.open(package / manifest->library);
if (!module) {
return fail(Stage::DynamicLoad, module.error());
}
auto query = module->symbol<QueryV1>("plugin_query_v1");
if (!query) {
return failAndClose(Stage::Entry, query.error(), *module);
}
PluginApiV1 const* api = query();
auto contract = validateApi(api, *manifest);
if (!contract) {
return failAndClose(Stage::Contract, contract.error(), *module);
}
PluginHandle instance{};
PluginStatusV1 status = api->create(&hostServices_, &instance);
if (!status.ok()) {
return failAndClose(Stage::Create, copyStatus(status), *module);
}
ScopeGuard rollback([&] {
api->destroy(instance);
module->close();
});
auto registrations = registry_.stage(*api, instance);
if (!registrations) {
return fail(Stage::Register, registrations.error());
}
registry_.commit(*registrations);
records_.insert(PluginRecord{
.manifest = *manifest,
.module = std::move(*module),
.api = api,
.instance = instance,
.state = PluginState::Registered,
});
rollback.dismiss();
return success(manifest->id);
}关键点不是具体类名,而是每一步都有阶段标签和可逆动作。 注册先进入暂存区,全部成功后一次提交,避免部分命令已经可见而后续注册失败。
动态库句柄必须比 API 指针和插件实例活得更久。 记录销毁时按“停止任务、撤销注册、销毁实例、关闭模块”的顺序执行。
卸载骨架
UnloadResult PluginManager::unload(PluginId id) {
PluginRecord& record = records_.require(id);
record.state = PluginState::Stopping;
registry_.disable(id);
taskService_.requestCancelForPlugin(id);
if (!taskService_.waitForQuiescence(id, unloadTimeout_)) {
record.state = PluginState::Deferred;
return deferred("active tasks still reference plugin code");
}
registry_.remove(id);
record.api->destroy(record.instance);
record.instance = {};
record.api = nullptr;
record.module.close();
record.state = PluginState::Unloaded;
records_.erase(id);
return success(id);
}先禁用新调用,再请求取消并等待静止。 等待失败时保留模块,不允许为了满足按钮操作而强制 dlclose 或 FreeLibrary。
真实系统还要处理宿主关闭期间的全局顺序。 插件依赖的日志、任务和文档服务必须晚于插件销毁。
端到端验收案例
选择一个小型格式导入插件作为验收样例:
- 从包目录发现 Manifest;
- 校验 ID、版本、平台与文件哈希;
- 加载入口并协商 API;
- 注册一个“导入示例网格”命令;
- 在 Worker 中解析有效文件;
- 生成中立 Change Set;
- 在文档事务中提交;
- 保存并重开工程;
- 禁用插件后验证未知扩展数据仍保留;
- 重新启用插件并恢复编辑;
- 注入截断文件验证事务不污染文档;
- 在任务运行时请求卸载,确认进入 Deferred;
- 取消完成后再次卸载,确认资源归零;
- 用旧插件版本打开新宿主,验证兼容策略;
- 删除依赖 DLL,确认诊断指出真实缺失项。
这个案例同时覆盖正常路径、持久化、兼容、取消和故障诊断。 只有加载成功的测试无法证明插件系统具备工程完整性。
推荐路线
链接格式、CMake 和 ABI 基础请先完成构建与 ABI 专题;Qt 集成与 CAE Document/Command 边界见 Qt 插件 ABI 与文档架构,SAM 插件则在其领域文章中讨论。
学完本模块应具备的能力
完成本模块后,应能从二进制产物判断插件为何无法加载,设计带版本协商的最小接口,明确字符串、容器、异常和对象所有权怎样过边界,并为注册、运行、取消和卸载建立可验证的状态机。面对“是否支持热更新”这类需求时,也应先检查活动对象和状态迁移成本,而不是直接调用第二次加载函数。