插件系统:让其他人也能给编辑器写节点

背景:为什么需要插件系统

CvEditor 刚开始只有十几个节点,全部写在主程序里,编译一次就完事。但随着节点越来越多(现在 60+),我遇到了几个问题:

  1. 编译时间越来越长:加一个节点就要重编整个项目
  2. 某些节点依赖很重:比如 YOLO 节点依赖 ONNX Runtime,不用的人也要编进来
  3. 无法分享节点:我写了一个好用的节点,想分享给朋友,只能发源码
  4. 第三方扩展困难:别人想加自己的算子,得 fork 整个项目

插件系统就是解决这些问题的。让节点变成可选的、可插拔的、可分发的独立模块。


设计目标

  1. 零耦合:插件不需要链接主程序,只需要头文件
  2. 简单:实现一个接口 + 导出两个函数,就是一个插件
  3. 安全:插件崩溃不应该拖垮主程序
  4. 易分发:一个 DLL/SO 文件就是一个插件,复制即用

插件接口设计

IPlugin 接口

每个插件都需要实现 IPlugin 接口(include/editor/sdk/plugin.h):

cpp 复制代码
class IPlugin {
public:
  virtual ~IPlugin() = default;
  
  // 返回插件信息
  virtual PluginInfo GetInfo() const = 0;
  
  // 初始化插件,注册节点
  virtual bool Initialize(Factory* factory) = 0;
  
  // 关闭插件,释放资源
  virtual void Shutdown() = 0;
};

PluginInfo 是一个简单的结构体:

cpp 复制代码
struct PluginInfo {
  std::string name;         // 插件名称
  std::string version;      // 版本号
  std::string description;  // 描述
  std::string author;       // 作者
};

Factory:节点工厂

Factory 是插件注册节点的入口,使用工厂模式将节点名称映射到创建函数:

cpp 复制代码
class Factory {
public:
  static Factory& Instance();
  
  // 注册节点创建函数
  void Register(const std::string& name, 
                std::function<NodePtr(Context*, const std::string&)> creator);
  
  // 创建节点
  NodePtr CreateNode(Context* context, const std::string& name);
  
  // 加载/卸载插件
  bool LoadPlugin(const std::string& path);
  void UnloadPlugin(const std::string& path);
  int LoadPluginsFromDirectory(const std::string& dir);
  void UnloadAllPlugins();
  
  // 查询已加载插件
  std::vector<PluginInfo> GetLoadedPlugins();
};

完整示例:写一个自定义插件

1. 创建插件类

cpp 复制代码
// my_plugin.h
#include "editor/sdk/plugin.h"

class MyPlugin : public editor::IPlugin {
public:
  MyPlugin() = default;
  ~MyPlugin() override = default;

  editor::PluginInfo GetInfo() const override {
    return {
      "My Plugin",       // 名称
      "1.0.0",           // 版本
      "My custom nodes", // 描述
      "Your Name"        // 作者
    };
  }

  bool Initialize(editor::Factory* factory) override;
  void Shutdown() override;
};

2. 实现自定义节点

插件的节点和内置节点写法完全一样:

cpp 复制代码
// my_plugin.cpp
#include "my_plugin.h"
#include "editor/sdk/node.h"
#include "editor/sdk/attribute.h"

class MyCustomNode : public editor::Node {
public:
  MyCustomNode(editor::Context* context, const std::string& name)
      : editor::Node(context, name) {
    SetWidth(160.0f);
    
    // 添加输入属性
    AddAttribute(std::make_shared<editor::IntInputAttribute>(
        context, "input_value", 0));
    
    // 添加输出属性
    AddAttribute(std::make_shared<editor::IntOutputAttribute>(
        context, "output_value", 0, true));
  }

  void Exec() override {
    auto input = GetAttribute(1);
    auto output = GetAttribute(2);
    
    if (input && output) {
      int value = input->Get<int>();
      output->Set(value * 2);  // 简单的翻倍逻辑
    }
  }
};

3. 注册节点

Initialize 方法中注册所有节点:

cpp 复制代码
bool MyPlugin::Initialize(editor::Factory* factory) {
  // 注册格式:"Category.nodeName"
  factory->Register("Custom.myNode", [](editor::Context* context, 
                                       const std::string& name) {
    return std::make_shared<MyCustomNode>(context, name);
  });
  
  // 可以注册多个节点
  factory->Register("Custom.anotherNode", [](editor::Context* context,
                                            const std::string& name) {
    return std::make_shared<AnotherNode>(context, name);
  });
  
  return true;
}

void MyPlugin::Shutdown() {
  // 清理插件分配的资源
}

4. 导出 C 接口

这是最关键的一步。插件必须导出两个 C 风格的函数,主程序通过它们来创建和销毁插件实例:

cpp 复制代码
extern "C" {

editor::IPlugin* CreatePlugin() {
  return new MyPlugin();
}

void DestroyPlugin(editor::IPlugin* plugin) {
  delete plugin;
}

}

为什么要 extern "C" 因为 C++ 的名称修饰(name mangling)会让导出的函数名变得不可预测。用 C 链接约定确保函数名不变,主程序才能通过 dlsym/GetProcAddress 找到它们。

5. 编译插件

Windows (Visual Studio):

bash 复制代码
cl /LD /EHsc /I"path/to/cveditor/include" ^
    /D"BUILD_EDITOR_LIB" /DNOMINMAX ^
    my_plugin.cpp ^
    /Fe:my_plugin.dll

Linux (GCC):

bash 复制代码
g++ -shared -fPIC -std=c++17 \
    -I"path/to/cveditor/include" \
    -DEDITOR_API= \
    my_plugin.cpp -o libmy_plugin.so

关键编译选项:

  • /LD-shared:生成动态链接库
  • /D"BUILD_EDITOR_LIB":正确导出符号
  • -fPIC:生成位置无关代码(Linux 必须)

加载和使用插件

加载单个插件

cpp 复制代码
#include "editor/sdk/factory.h"

auto& factory = editor::Factory::Instance();

if (!factory.LoadPlugin("plugins/my_plugin.dll")) {
  printf("Failed to load plugin\n");
  return 1;
}

// 创建插件注册的节点
auto node = factory.CreateNode(context.get(), "Custom.myNode");
if (node) {
  context->AddNode(node);
}

批量加载

cpp 复制代码
// 加载 plugins 目录下所有 .dll/.so 文件
int count = factory.LoadPluginsFromDirectory("plugins");
printf("Loaded %d plugins\n", count);

查询已加载插件

cpp 复制代码
auto plugins = factory.GetLoadedPlugins();
for (const auto& info : plugins) {
  printf("Plugin: %s v%s - %s\n", 
         info.name.c_str(),
         info.version.c_str(),
         info.description.c_str());
}

卸载插件

cpp 复制代码
factory.UnloadPlugin("plugins/my_plugin.dll");
// 或
factory.UnloadAllPlugins();

节点属性参考

插件中可用的属性类型(editor/sdk/attribute.h):

类型 说明
IntAttribute 整数输入
IntOutputAttribute 整数输出
FloatAttribute 浮点数输入
FloatOutputAttribute 浮点数输出
StringAttribute 字符串输入
StringInputAttribute 字符串输入(可编辑)
StringOutputAttribute 字符串输出
PathInputAttribute 路径输入
PathOutputAttribute 路径输出
BoolAttribute 布尔值
EnumAttribute 枚举选择
ImageAttribute 图像数据

注意:插件中的属性类型和内置节点略有不同,因为插件只能访问 editor/sdk/ 下的公共头文件。内置节点用的 CvMatOutputAttributeCvSizeInputAttribute 等是在 editor/node/ 下定义的,插件不可见。


实现细节:插件加载的底层逻辑

插件加载的核心是平台相关的动态库 API:

cpp 复制代码
// factory.cpp (简化版)
bool Factory::LoadPlugin(const std::string& path) {
#ifdef _WIN32
  HMODULE handle = LoadLibraryA(path.c_str());
#else
  void* handle = dlopen(path.c_str(), RTLD_LAZY);
#endif

  if (!handle) return false;

  // 查找导出函数
  using CreateFunc = IPlugin* (*)();
  using DestroyFunc = void (*)(IPlugin*);
  
#ifdef _WIN32
  auto create = (CreateFunc)GetProcAddress(handle, "CreatePlugin");
  auto destroy = (DestroyFunc)GetProcAddress(handle, "DestroyPlugin");
#else
  auto create = (CreateFunc)dlsym(handle, "CreatePlugin");
  auto destroy = (DestroyFunc)dlsym(handle, "DestroyPlugin");
#endif

  if (!create || !destroy) {
#ifdef _WIN32
    FreeLibrary(handle);
#else
    dlclose(handle);
#endif
    return false;
  }

  // 创建插件实例并初始化
  IPlugin* plugin = create();
  if (!plugin->Initialize(this)) {
    destroy(plugin);
    return false;
  }

  // 保存信息
  plugins_[path] = {handle, plugin, destroy, plugin->GetInfo()};
  return true;
}

跨平台的部分用 #ifdef 处理。虽然不够优雅,但在业余项目里足够了。


踩坑记录

1. ABI 兼容性

这是最头疼的问题。插件和主程序必须用相同的编译器版本和运行时库编译。Visual Studio 2019 编译的主程序,用 VS2022 编译的插件大概率崩。

目前没有好办法,只能在文档里写清楚编译器版本要求。如果将来要支持第三方插件,可能需要提供 SDK 包含编译好的头文件和 lib。

2. 内存管理

插件里 new 出来的对象,必须在插件的 Shutdown()delete。跨 DLL 边界的内存分配/释放会导致堆损坏。

cpp 复制代码
void MyPlugin::Shutdown() {
  // 释放插件分配的所有资源
  // 不要指望主程序来释放!
}

3. 异常不要跨 DLL

C++ 异常不能安全地跨越 DLL 边界。插件里的异常必须自己 catch 住:

cpp 复制代码
void MyCustomNode::Exec() override {
  try {
    // 可能抛异常的代码
  } catch (const std::exception& e) {
    // 记录日志,不要让异常逃逸
  }
}

4. 导出符号问题

Windows 上默认不导出符号,必须用 __declspec(dllexport)/D"BUILD_EDITOR_LIB" 宏。我在头文件里用了条件编译:

cpp 复制代码
#ifdef BUILD_EDITOR_LIB
  #define EDITOR_API __declspec(dllexport)
#else
  #define EDITOR_API __declspec(dllimport)
#endif

Linux 上用 -fvisibility=hidden + __attribute__((visibility("default"))) 可以更精确地控制导出,但我偷懒没做。

5. 路径问题

Windows 用 \,Linux 用 /;DLL 后缀是 .dll,SO 后缀是 .soLoadPluginsFromDirectory 需要处理这些差异。我用了一个简单的平台判断:

cpp 复制代码
#ifdef _WIN32
  const std::string ext = ".dll";
#else
  const std::string ext = ".so";
#endif

现状和改进方向

目前的插件系统已经能用了,但还有很多可以改进的地方:

  1. 热加载:运行时卸载重载插件,不用重启程序
  2. 依赖声明:插件声明自己依赖哪些库版本,加载时检查
  3. 沙箱机制:限制插件的 API 访问权限,防止恶意插件
  4. 插件市场:类似 VS Code 的扩展市场(做梦ing)
  5. C API 层:提供纯 C 接口,让其他语言也能写插件

相关推荐
诙_2 小时前
深入理解C++文件操作
开发语言·c++
ShoreKiten2 小时前
cpp考前急救
数据结构·c++·算法
Byron Loong2 小时前
【基础】c,c++编译过程
c语言·c++
Hesionberger2 小时前
LeetCode79:单词搜索DFS回溯详解
java·开发语言·c++·python·算法·leetcode·c#
MZ_ZXD0013 小时前
springboot音乐播放器系统-计算机毕业设计源码76317
java·c语言·c++·spring boot·python·flask·php
Emberone3 小时前
C++ list 详解:从入门到模拟实现,彻底搞懂双向链表
c++·list
Cando学算法4 小时前
欧拉回路(一笔画)
数据结构·c++·图论
我不是懒洋洋4 小时前
手写一个并查集:从原理到最小生成树实战
c语言·c++·经验分享·算法
叼烟扛炮4 小时前
C++ 知识点06 inline
开发语言·c++·inline