Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← C++ 编程 / C++ Programming

插件系统 / Plugin Systems

1. 插件工程:可演进的模块边界 / Plugin Engineering and Evolvable Module Boundaries

2. 运行时动态加载:LoadLibrary 与 dlopen —— 打开插件的大门 / Runtime Dynamic Loading with LoadLibrary and Dlopen

3. 插件接口设计:C ABI、函数表与受控 C++ 接口 / Plugin Interface Design with C ABI, Function Tables, and Controlled C++ Interfaces

4. CMake 构建完整插件项目 / Building a Complete Plugin Project with CMake

5. 插件架构设计模式:发现、生命周期、依赖与通信 / Plugin Architecture Patterns for Discovery, Lifecycle, Dependencies, and Communication

6. 跨平台插件开发:Windows / Linux / macOS 统一策略

7. 插件热加载与版本管理 / Plugin Hot Reloading and Version Management

8. 插件安全:沙箱、进程隔离与权限控制 / Plugin Security with Sandboxing, Process Isolation, and Permissions

9. 工业软件插件系统案例分析 / Industrial Software Plugin System Case Study

10. 综合实战:版本化 C ABI 插件系统 / Full Project: Versioned C ABI Plugin System

本页目录

插件工程:可演进的模块边界 / Plugin Engineering and Evolvable Module Boundaries ​

插件系统的核心不是 dlopen 或 LoadLibrary,而是宿主与扩展之间长期稳定的契约:接口、ABI、所有权、错误传播、卸载顺序和权限边界。

本模块不再承担 C++ 项目从源码到可执行文件、Windows/Linux 编译产物、链接器和构建系统的基础教学。

这些前置内容已经分层放在:

text
11-environment-and-toolchain
  解释 object file、dynamic library、loader、CMake/Ninja、依赖和平台运行环境

08-build-tooling-and-abi
  解释 ABI 策略、CI、发布证据和工程质量门禁

10-plugin-engineering
  只聚焦插件系统:发现、加载、接口协商、生命周期、热更新、安全和工业案例
1
2
3
4
5
6
7
8

一个普通动态库只解决“代码怎样被链接或装入进程”,插件系统还必须回答“宿主怎样发现能力、怎样判断兼容、谁创建和销毁对象、故障是否会拖垮主程序、升级后旧工程能否继续打开”。只要其中一个问题没有形成协议,系统就可能在演示环境正常,却在多版本并存、第三方扩展或热更新时失效。

插件从发现到卸载的生命周期 ​

text
扫描目录或读取清单
        │
        v
校验来源、平台、版本与依赖
        │ 不满足约束 -> 拒绝并记录诊断
        v
加载动态库 -> 查找稳定入口 -> 协商接口版本
        │
        v
创建插件实例 -> 注册命令/格式/求解器能力
        │
        v
运行、监控、取消任务
        │
        v
撤销注册 -> 等待对象与线程退出 -> 销毁实例 -> 卸载模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

加载只是生命周期中很短的一步。真正困难的是卸载:插件创建的对象、回调、线程、GPU 资源或函数指针只要还有一个存活,关闭动态库后就可能跳转到已经失效的代码页。因此生产系统通常维护模块状态和活动引用计数;无法证明已静止时,宁可延迟卸载或要求重启,也不冒险强制释放。

契约应稳定在哪一层 ​

最脆弱的做法是直接跨模块暴露复杂 C++ 类。编译器版本、标准库、编译选项、异常模型和内存分配器任一不同,都可能改变对象布局或释放方式。更稳妥的边界通常是窄 C ABI、版本化函数表,或者在严格统一工具链的前提下使用纯抽象接口。

cpp
extern "C" PluginApiV1 const* plugin_query_v1();
1

入口名称和调用约定保持稳定,返回结构包含版本、结构大小与函数指针。宿主只调用双方都认识的字段,新版本通过新增结构或能力位扩展。对象由哪一侧创建,就应由同一侧提供销毁函数;不要让宿主用自己的 delete 释放插件分配的对象。错误也应转换为状态码、错误对象或受控异常边界,不能让未知异常穿过 ABI。

架构选择与边界 ​

  • 静态链接适合能力固定、部署简单且不要求独立升级的组件;它不是插件。
  • 运行时动态插件适合格式适配器、求解器、命令和后处理模块,但必须承担兼容与安全成本。
  • 进程外插件通过 IPC 增加延迟和部署复杂度,却能隔离崩溃、权限和不可信依赖,适合高风险扩展。
  • 脚本插件迭代快,但仍需限制文件、网络、进程和宿主 API 权限,不能把“脚本”误当成天然安全。

设计时先定义插件能做什么,再定义它不能做什么。权限默认最小化,文件格式解析等不可信输入应设置尺寸、时间和递归深度限制;插件加载失败必须产生可定位的日志,而不是只显示“初始化失败”。

判断项目是否真的需要插件 ​

插件架构会增加独立构建产物、版本组合和运行时失败路径。 只有需求确实包含独立扩展或独立部署时,这些成本才值得承担。

适合使用插件的场景包括:

  • 第三方需要在不修改宿主源码的情况下增加能力;
  • 不同客户需要选择不同的格式、求解器或后处理模块;
  • 扩展与宿主具有不同发布周期;
  • 某些依赖因许可证或平台限制不能进入宿主核心;
  • 高风险模块需要进程隔离和独立恢复;
  • 宿主必须在运行时发现编译时未知的实现。

如果全部模块由同一团队、同一流水线和同一版本发布,普通库和内部组件通常更简单。 插件不是模块化的同义词,它只是把边界推进到了二进制和运行时。

用能力而不是具体类型组织扩展 ​

宿主不应通过识别具体插件类来决定行为。 更稳定的方式是让插件声明“导入网格”“运行求解器”“注册命令”等能力。

cpp
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;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

structSize 允许宿主只读取双方都认识的结构前缀。 apiVersion 用于协议协商,稳定 id 用于配置和工程文件。 显示名称可以本地化,不能承担稳定身份职责。

能力位只说明插件声称提供什么。 真正调用前仍要检查函数指针、依赖和运行条件。

Manifest 负责发现,二进制入口负责确认 ​

大型系统通常先读取轻量清单,再决定是否装载动态库。 这使宿主可以在执行第三方代码之前完成平台和权限检查。

json
{
  "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"]
}
1
2
3
4
5
6
7
8
9
10

Manifest 本身是不可信输入。 路径必须限制在插件包目录内,字符串长度、文件大小和依赖数量都需要上限。 重复 ID、非法版本和逃逸目录的相对路径必须在加载前拒绝。

清单声明不能替代动态库入口返回的 Descriptor。 加载后仍要核对 ID、版本和能力,防止清单与二进制不一致。

生命周期必须建模为状态机 ​

一个 bool loaded 无法表达部分初始化、停止中或延迟卸载。 宿主至少需要区分发现、校验、装载、注册、活动和停止。

text
Discovered
    |
    v
Validated --失败--> Rejected
    |
    v
Loaded --入口失败--> Failed
    |
    v
Registered <-> Active Tasks
    |
    v
Stopping --仍有引用--> Deferred
    |
    v
Unregistered -> Unloaded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

加载管理器拥有动态库句柄。 注册中心拥有能力可见性。 任务系统跟踪活动调用。 插件实例只管理自身资源。

初始化失败时要记录已经完成的阶段,再按严格反序清理。 无条件调用全部 Finalize 逻辑,可能释放尚未创建或已经移交的对象。

所有权是 ABI 的一部分 ​

对象创建方销毁方能否跨卸载存活
动态库句柄宿主宿主否
插件实例插件工厂插件销毁函数否
宿主服务表宿主宿主是
返回缓冲区协议指定同一分配域取决于复制规则
后台任务插件或任务服务创建方协调回收否
工程领域数据宿主文档宿主是

跨模块传递 std::string、std::vector、异常和拥有资源的智能指针,会把标准库 ABI 与分配器带入协议。 窄 ABI 更适合传递长度明确的字节视图、普通结构和不透明句柄。

对象由插件创建时,插件必须提供对应销毁入口。 宿主不能假设自己的 delete、运行库和分配器与插件完全一致。

工程数据可以跨卸载保留,但其中不能保存插件对象地址、虚函数指针或回调。 需要保留插件私有数据时,应序列化成版本化字节块,并提供缺失插件时的降级策略。

错误协议不能只有 false ​

插件失败需要区分业务错误、插件内部错误和协议违规。 单一布尔值无法支持诊断、重试和用户提示。

cpp
struct PluginStatusV1 {
    std::int32_t code;
    std::uint32_t messageSize;
    char const* messageUtf8;
};

PluginStatusV1 import_mesh(
    PluginHandle plugin,
    HostDocumentHandle document,
    ByteView pathUtf8);
1
2
3
4
5
6
7
8
9
10

稳定错误码供程序判断,UTF-8 消息用于日志和界面。 消息指针的生命周期必须写清,宿主通常应立即复制。

未知 C++ 异常不能穿过 ABI。 插件应在最外层捕获异常并转换为协议错误。 若进程内崩溃仍不可接受,就应把插件移动到独立进程,而不是继续增加 try/catch。

线程、回调和取消 ​

协议必须说明函数在哪个线程调用、是否允许阻塞、回调能否重入,以及取消如何传递。 GUI 宿主不能允许插件在主线程执行长时间解析或求解。

text
GUI 发起命令
  -> 宿主冻结输入快照
  -> 任务线程调用插件
  -> 插件通过受控回调报告进度
  -> 宿主队列切回 GUI 提交结果
1
2
3
4
5

进度回调应当轻量,不能反向等待任务完成。 否则任务等待 GUI、GUI 又等待任务时会形成死锁。

取消通常使用协作式 Token。 插件在安全点检查请求、结束当前事务、释放临时资源并返回取消状态。 超时只说明调用者停止等待,不代表插件线程已经终止。

宿主只有确认活动任务、回调和对象全部退出后,才能卸载动态库。

三类版本必须分开 ​

Manifest Schema、宿主插件 API 和插件私有数据格式不应共用一个版本号。

  • Manifest Schema 决定宿主怎样读取包信息;
  • 插件 API 决定函数表、调用约定和所有权;
  • 数据 Schema 决定工程文件怎样保存插件状态;
  • 插件产品版本用于发布、诊断和依赖选择。

增加可选函数或结构尾字段,通常可以保持同一 API 主版本。 改变字段语义、调用约定或所有权时,必须提升不兼容版本。

宿主可以同时暴露多个版本的函数表,为旧插件提供迁移窗口。 兼容窗口应有明确期限和遥测,不能永久积累无人测试的协议。

热更新是一项状态迁移功能 ​

热更新不是再次调用加载函数。 它需要停止旧模块、提取状态、撤销注册、销毁对象、装载新模块并迁移状态。

text
Quiesce -> Snapshot -> Unregister -> Unload old
                                  -> Load new -> Migrate -> Resume
1
2

任一步失败都要决定回滚旧版本还是禁用插件。 如果旧模块已经卸载而新状态迁移失败,宿主必须保留可恢复的快照。

桌面工业软件通常可以通过重启宿主换取一致性。 如果产品确实要求不停机升级,进程外 Worker 更容易实现滚动切换: 启动新 Worker,迁移新任务,等待旧 Worker 排空,再结束旧进程。

同进程加载不等于安全隔离 ​

同进程插件拥有宿主进程的全部权限。 它可以读取内存、文件、环境变量,也可以破坏堆和线程状态。

签名与哈希验证来源和完整性,不能限制运行时行为。 面对不可信或高风险插件,应使用独立进程、低权限身份和显式 IPC 协议。

文件解析插件还要限制:

  • 输入文件和解压后数据大小;
  • 实体数量、递归深度和字符串长度;
  • 处理时间、内存和并发任务数;
  • 整数溢出、索引范围和循环引用;
  • 临时目录、网络和子进程权限。

解析失败不能在当前 Document 留下半提交对象。 插件应先构造临时结果,通过校验后再由宿主事务提交。

可观测性是可维护性的前提 ​

每次加载应记录插件 ID、版本、文件哈希、宿主 API、平台和结果。 每次调用携带任务 ID,记录持续时间、错误码、取消和资源峰值。

日志不能包含密钥、完整用户文件或敏感模型内容。 诊断信息要足以回答:

  • 扫描到了哪些插件包;
  • 某个插件为什么被拒绝;
  • 当前注册了哪些能力;
  • 哪些活动对象阻止卸载;
  • 最近一次调用在哪个阶段失败;
  • 宿主与插件实际协商了哪个 API 版本。

没有这些信息,加载问题很容易被误判为随机启动故障。

测试矩阵 ​

插件自己的单元测试不能覆盖宿主边界。 完整测试至少包含:

  1. ABI 测试:入口名称、调用约定、结构大小与对齐;
  2. 契约测试:宿主对所有实现运行同一组行为断言;
  3. 生命周期测试:重复加载、部分初始化失败、取消和卸载;
  4. 兼容测试:支持范围内的宿主与插件版本组合;
  5. 恶意输入测试:坏 Manifest、截断文件和资源上限;
  6. 隔离测试:插件崩溃、超时和 IPC 中断;
  7. 端到端测试:工程保存、重开和缺失插件降级。

测试仓库应保留最小故障插件:

  • 缺少入口符号;
  • 返回不支持的 API 版本;
  • 初始化到一半失败;
  • 创建对象后拒绝停止;
  • 后台任务忽略第一次取消;
  • 返回非法长度或错误编码;
  • 故意崩溃的进程外 Worker。

这些样例使失败路径能够持续回归,而不依赖真实大型插件。

设计评审清单 ​

  • 插件提供的是独立能力,还是普通内部模块?
  • ABI 是否足够窄,并明确编码、长度、对齐和调用约定?
  • 每个跨边界对象由谁创建、谁销毁?
  • 部分初始化失败时如何反序清理?
  • 活动调用、回调和线程如何阻止提前卸载?
  • Manifest、API 和数据 Schema 是否独立版本化?
  • 缺失、过期或崩溃插件如何降级?
  • 第三方代码是否必须获得同进程权限?
  • 哪些日志能够重建加载与调用过程?
  • 兼容矩阵由哪些自动测试持续守护?

如果其中一项只能回答“由插件开发者自己注意”,说明契约还没有真正建立。

Windows 动态加载的诊断路径 ​

Windows 宿主通常通过 LoadLibraryExW 装载插件,并用 GetProcAddress 查找入口。 生产代码应使用绝对规范路径,避免当前工作目录改变依赖解析结果。

cpp
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);
}
1
2
3
4
5
6
7
8
9
10

“找不到模块”不一定表示主 DLL 不存在。 它也可能表示某个传递依赖缺失、位数不匹配或初始化函数失败。

诊断时按以下顺序检查:

  1. 插件文件是否位于清单声明的绝对路径;
  2. PE 架构是否与宿主一致;
  3. 依赖 DLL 是否能在受控搜索目录中找到;
  4. 入口符号是否以协议要求的名称导出;
  5. 运行库和编译器 ABI 是否满足支持矩阵;
  6. DllMain 是否执行了不允许的阻塞或加载操作;
  7. 安全软件或文件来源策略是否阻止装载。

不要通过把所有依赖复制到系统目录来“修复”搜索问题。 这会污染全局环境,使其他应用加载到错误版本。

宿主应为每个插件包建立私有依赖目录,或通过部署工具保证依赖闭包完整。

Linux 动态加载的诊断路径 ​

Linux 通常使用 dlopen、dlsym 和 dlclose。

cpp
void* module = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!module) {
    std::string message = dlerror();
    reportLoadFailure(path, message);
}
1
2
3
4
5

RTLD_NOW 在加载时解析符号,使问题尽早暴露。 RTLD_LOCAL 避免插件符号默认污染全局命名空间。

常见失败来源包括:

  • ELF 架构或 ABI 不匹配;
  • RPATH、RUNPATH 或系统搜索路径错误;
  • 依赖库的 SONAME 版本不可用;
  • 宿主没有导出插件需要的符号;
  • C++ 标准库或 GLIBC 版本高于部署环境;
  • 符号可见性设置隐藏了稳定入口;
  • 两个插件依赖同名但不兼容的库版本。

可以用只读工具检查 ELF 依赖和导出符号,但工具输出只是静态证据。 最终仍要在目标部署镜像中执行真实加载测试。

容器能固定用户态依赖,不能消除内核、驱动、GPU 和宿主挂载差异。

导出入口与符号可见性 ​

稳定入口需要明确导出宏,不能依赖编译器默认可见性。

cpp
#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;
1
2
3
4
5
6
7
8

extern "C" 避免 C++ 名字改编影响入口查找。 它不自动形成稳定 ABI,结构布局和调用约定仍需协议约束。

入口应标记 noexcept,内部捕获所有异常并返回受控失败。 入口函数不应执行耗时初始化,只负责返回描述符或轻量工厂。

复杂初始化放在显式 initialize 阶段,便于报告进度和反序清理。

CMake 目标边界 ​

插件应是独立目标,并只链接真正需要的依赖。

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)
1
2
3
4
5
6
7
8
9
10
11

宿主接口通过小型 SDK 目标提供。 SDK 不应把宿主内部头文件、私有模板和大型第三方库暴露给插件。

构建系统还要固定:

  • 目标架构和运行库策略;
  • Debug 与 Release 是否允许混用;
  • 异常、RTTI 和字符集选项;
  • 导出符号检查;
  • 包目录布局与 Manifest 生成;
  • 安装后的依赖扫描;
  • 可复现构建信息与二进制哈希。

插件编译成功只证明源代码可以生成二进制。 安装测试必须从最终包位置启动干净宿主,验证搜索路径和依赖闭包。

宿主服务表 ​

插件经常需要日志、内存、任务、配置和文档访问。 直接链接宿主内部单例会形成隐藏依赖。

cpp
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)();
};
1
2
3
4
5
6
7
8

服务表把依赖变成显式能力,也便于测试替换。 每个函数都需要说明线程安全、重入、生命周期和失败语义。

插件只能保存协议允许长期保存的服务句柄。 宿主升级服务表时使用结构大小和版本协商,不能就地改变旧字段语义。

测试环境可以提供记录型服务表,检查插件是否越权调用或泄漏资源。

进程外插件与 IPC ​

当崩溃隔离、依赖冲突或最小权限比调用延迟更重要时,应采用进程外插件。

text
Host Process
  -> versioned request
  -> length-delimited IPC channel
  -> Plugin Worker
  -> validated response
  -> Host transaction commit
1
2
3
4
5
6

IPC 协议需要消息长度上限、请求 ID、超时、取消和幂等语义。 宿主不能把来自 Worker 的偏移、数量和文件路径直接当作可信数据。

大网格不适合反复 JSON 序列化。 可以使用共享内存或临时对象存储传递大块数据,但控制消息仍要版本化。

共享内存描述符必须包含大小、布局、数据类型、校验和与所有权。 Worker 崩溃后,宿主负责识别并回收孤立资源。

进程外模式还需要心跳和健康状态。 没有响应时先停止派发新任务,再取消或终止 Worker,最后按策略重启。

重启不能自动重放有副作用的请求。 每个命令必须定义是否可安全重试,或使用幂等键阻止重复提交。

与 Document 和 Command 架构集成 ​

插件不能直接任意修改宿主文档内部容器。 宿主应提供受控 Command 或事务接口,把修改、撤销和版本更新集中管理。

text
Plugin parses input
  -> creates neutral change set
  -> Host validates IDs and revision
  -> Command applies atomically
  -> Document revision increments
  -> Views rebuild derived state
1
2
3
4
5
6

中立 Change Set 只描述新增、修改和删除的领域实体。 它不能携带插件对象指针,也不能要求文档了解插件内部类型。

提交前检查输入 Document ID 和 revision。 用户已经编辑文档时,旧后台结果应拒绝、重新计算或显式合并。

Command 保存撤销所需的稳定 ID 和数据差异。 临时数组下标、视图选择索引和渲染句柄都不适合作为撤销依据。

大型修改可以分块计算,但最终领域提交仍要保持事务语义。 如果产品允许部分提交,协议必须明确每个分块的可见性和恢复方式。

插件私有数据的序列化 ​

插件常需要把材料参数、算法配置或自定义对象写入工程文件。 宿主应把私有数据视为版本化扩展块,而不是理解其全部字段。

text
ExtensionRecord
├─ pluginId
├─ schemaVersion
├─ contentType
├─ payloadLength
├─ checksum
└─ payload
1
2
3
4
5
6
7

读取工程时,即使插件缺失,宿主也应保留未知扩展块。 用户再次保存文件时不能静默删除无法理解的数据。

插件重新出现后,可以根据 schemaVersion 执行迁移。 迁移应从旧版本逐级转换,并在临时副本上完成。

迁移失败时保留原始 Payload 和错误诊断。 不要只留下部分转换后的不可恢复状态。

私有数据仍需受文件总大小和单块大小限制。 压缩 Payload 必须检查解压比例,避免压缩炸弹耗尽内存。

命令注册与 UI 扩展 ​

UI 插件应注册命令语义,而不是直接把任意 QWidget 指针插入宿主内部布局。

命令描述可以包含:

  • 稳定命令 ID;
  • 本地化标题与说明键;
  • 图标资源标识;
  • 启用条件;
  • 所需权限;
  • 参数 Schema;
  • 是否支持撤销;
  • 是否为长时间任务。

宿主根据产品布局决定菜单、工具栏和快捷键。 这样同一插件能力可以在桌面、脚本和自动化接口中复用。

启用条件由当前 Selection 和 Document 状态计算。 插件不能缓存短生命周期的视图索引,再在用户切换文档后使用。

插件卸载前必须撤销命令、快捷键、Dock 和事件过滤器。 遗漏任何一个回调都可能在模块卸载后触发失效代码。

性能预算 ​

插件边界会增加间接调用、数据转换、序列化和隔离成本。 是否可接受必须用端到端工作负载测量。

对同进程插件,应记录:

  • 首次发现和加载时间;
  • 初始化内存增量;
  • 每次调用固定开销;
  • 数据复制量;
  • 任务队列与同步等待;
  • 卸载和资源回收时间。

对进程外插件,还要记录 IPC 往返、序列化、共享内存建立和 Worker 启动。

不要为了减少一次函数调用而破坏 ABI 边界。 批量接口通常比暴露内部容器更安全:一次传递一批中立记录,插件内部再优化处理。

宿主应对插件设置资源预算。 超过内存、并发或时间限制时产生可诊断失败,而不是拖垮整个应用。

包格式与安装 ​

插件包应包含 Manifest、主动态库、私有依赖、资源、许可证和可选调试符号索引。

text
com.example.mesh.bdf/
├─ plugin.json
├─ bin/
│  ├─ mesh_bdf.dll
│  └─ private_dependency.dll
├─ resources/
│  ├─ icons/
│  └─ translations/
├─ licenses/
└─ symbols.json
1
2
3
4
5
6
7
8
9
10

安装过程先写入临时目录,验证完整性后再原子移动到版本目录。 不要覆盖正在运行版本的二进制文件。

同一插件可以并存多个版本,但一次工程或宿主会话需要明确选择规则。 选择结果应进入诊断信息,避免“机器上装了哪个就用哪个”的不可复现行为。

卸载包前检查是否仍有工程引用。 如果允许删除,也要保留项目中的扩展数据和缺失插件说明。

签名、哈希与供应链 ​

发布者可以对包清单和文件哈希集合签名。 宿主验证签名链、文件哈希、插件 ID 和允许的发布者策略。

签名失败时不能提供“仍然加载”按钮给普通用户。 开发模式可以允许本地未签名插件,但必须与生产模式清晰区分。

构建流水线应生成软件物料清单,记录编译器、SDK 和第三方依赖版本。 发现高风险依赖后,可以定位受影响的插件包,而不必扫描用户机器猜测。

哈希用于识别具体二进制,不代替版本号。 相同语义版本的重新构建如果哈希不同,也应能在日志中区分。

发布与兼容矩阵 ​

插件发布不能只测试最新宿主。 支持策略要明确列出宿主主版本、操作系统、架构和工具链组合。

text
              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
1
2
3
4

矩阵中的 test 必须对应自动化契约测试,而不是文档声明。 未测试组合应默认拒绝或显示明确风险,不能静默尝试。

发布前执行干净安装、升级安装、降级回滚和缺失依赖测试。 升级成功后还要打开旧工程,验证私有数据迁移和能力注册。

弃用接口先产生诊断和迁移指南,再经过约定窗口删除。 宿主应记录仍在使用旧 API 的插件数量,为删除决策提供证据。

运维故障处理 ​

启动阶段发现插件失败时,宿主应继续加载不依赖它的功能,并生成隔离报告。

报告至少包含:

  • 插件包路径与哈希;
  • Manifest 解析结果;
  • 依赖检查结果;
  • 动态加载系统错误;
  • 入口与版本协商结果;
  • 初始化完成阶段;
  • 已执行的清理动作。

连续崩溃插件可以进入隔离区,下次启动默认禁用。 用户恢复时先在安全模式中验证,不要反复触发启动崩溃循环。

进程外 Worker 崩溃时,正在执行的任务进入明确终态。 宿主根据幂等协议决定重试,不能假装任务从未开始。

诊断包导出前必须脱敏路径、用户名、Token 和工程内容。

最小宿主加载器骨架 ​

下面的伪代码强调顺序和失败清理,不绑定具体平台封装。

cpp
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

关键点不是具体类名,而是每一步都有阶段标签和可逆动作。 注册先进入暂存区,全部成功后一次提交,避免部分命令已经可见而后续注册失败。

动态库句柄必须比 API 指针和插件实例活得更久。 记录销毁时按“停止任务、撤销注册、销毁实例、关闭模块”的顺序执行。

卸载骨架 ​

cpp
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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

先禁用新调用,再请求取消并等待静止。 等待失败时保留模块,不允许为了满足按钮操作而强制 dlclose 或 FreeLibrary。

真实系统还要处理宿主关闭期间的全局顺序。 插件依赖的日志、任务和文档服务必须晚于插件销毁。

端到端验收案例 ​

选择一个小型格式导入插件作为验收样例:

  1. 从包目录发现 Manifest;
  2. 校验 ID、版本、平台与文件哈希;
  3. 加载入口并协商 API;
  4. 注册一个“导入示例网格”命令;
  5. 在 Worker 中解析有效文件;
  6. 生成中立 Change Set;
  7. 在文档事务中提交;
  8. 保存并重开工程;
  9. 禁用插件后验证未知扩展数据仍保留;
  10. 重新启用插件并恢复编辑;
  11. 注入截断文件验证事务不污染文档;
  12. 在任务运行时请求卸载,确认进入 Deferred;
  13. 取消完成后再次卸载,确认资源归零;
  14. 用旧插件版本打开新宿主,验证兼容策略;
  15. 删除依赖 DLL,确认诊断指出真实缺失项。

这个案例同时覆盖正常路径、持久化、兼容、取消和故障诊断。 只有加载成功的测试无法证明插件系统具备工程完整性。

推荐路线 ​

  1. 运行时动态加载
  2. 插件接口设计
  3. 插件架构设计模式
  4. 热加载与版本管理
  5. 插件安全
  6. 完整插件项目

链接格式、CMake 和 ABI 基础请先完成构建与 ABI 专题;Qt 集成与 CAE Document/Command 边界见 Qt 插件 ABI 与文档架构,SAM 插件则在其领域文章中讨论。

学完本模块应具备的能力 ​

完成本模块后,应能从二进制产物判断插件为何无法加载,设计带版本协商的最小接口,明确字符串、容器、异常和对象所有权怎样过边界,并为注册、运行、取消和卸载建立可验证的状态机。面对“是否支持热更新”这类需求时,也应先检查活动对象和状态迁移成本,而不是直接调用第二次加载函数。

最后更新于:

Pager
上一篇← C++ 编程 / C++ Programming
下一篇2. 运行时动态加载:LoadLibrary 与 dlopen —— 打开插件的大门 / Runtime Dynamic Loading with LoadLibrary and Dlopen

持续记录,持续成长

Copyright © Tidenflow