【实战指南】4步实现C++插件化编程,轻松实现功能定制与扩展
引言
在项目开发中,我们经常面临为适应不同市场或产品层级而需调整功能的需求。从软件工程的角度来看,这意味着使用同一套代码,通过配置来实现产品的功能差异化。实现这一目标的方法多种多样,本文将探讨如何通过 插件化编程 优雅地满足这一需求。
概述
插件化编程 是一种通过动态加载功能模块(即插件)来增强主程序功能的软件设计策略。通过制定标准化接口,确保插件与主程序之间的兼容性与独立性。此方法能显著提高软件的灵活性、可扩展性和易维护性,同时支持快速定制及对市场变化的迅速响应。
需求分析
通过上述描述,可以将功能需求概括为:使用同一套代码基础,实现不同产品的功能差异化。
从软件设计的角度来看,主要功能需求包括:
- 实现不同产品客制化配置
- 通过配置文件来启用或禁用特定功能。通过配置文件灵活控制功能的开启与关闭,以满足不同市场或客户的具体需求。
- 系统支持查阅配置版本信息。动态集成配置文件的版本信息,方便现场快速了解当前使用的配置状态。
- 配置文件易于管控和维护。客制化配置应与具体产品绑定,避免不同产品的配置混淆,确保易于管理和维护;同时,配置文件应设计得易于编辑。
- 实现依据配置集成指定模块
- 系统能够准确识别差异化配置内容。
- 系统支持的功能与配置一致。
设计方案
基于上述分析,以下是设计方案的大致流程:
- 配置文件构建
- ① 初步以
modules_configs.cmake
作为模块配置文件。在CMake
编译期间识别配置选项,编译指定模块。 - ② 增加配置版本号。在配置文件中增加版本号字段,并在编译期间将该版本号传递至软件中,由软件写入实时环境。
- ③ 增加配置文件版本管理。每次新增客制化产品时,都需要在工程中添加该产品唯一的客制化配置文件。
- 依据配置加载指定模块
- ① 差异化模块以动态库形式呈现。
根据modules_configs.cmake
配置,在编译期间编译指定需加载的功能模块动态库。 - ② 统一动态库命名前缀、入口函数命名和入口函数形式。
- 动态库以
libplug
前缀命名; - 统一入口函数名为
PluginEntry
; - 函数形式为
void(*PluginEntryFunc)(std::map<int, SprObserver*>& modules, SprContext& ctx)
。
- ③ 各模块按上述格式完成动态库的命名和入口函数实现。
在PluginEntryFunc
函数实现中,完成该模块的入口设计。 - ④ 在主程序中调用各模块入口:
- 首先,主程序通过
dlopen
加载libplug
前缀的客制化模块动态库; - 其次,通过
dlsym
获取动态库的入口函数PluginEntry
; - 最后,通过函数指针调用动态库的入口函数。
详细设计
主要是通过CMake配置化编译和插件化编程实现动态加载,详细实现如下:
- 配置文件 modules_configs.cmake
# 业务模块 Components/Business set(MODULE_CONFIG_VERSION "DEFAULT_MCONFIG_1001") set(BUSINESS_MODULES "") list(APPEND BUSINESS_MODULES OneNetMqtt)
MODULE_CONFIG_VERSION
作为配置版本号变量:其值遵循[产品]_MCONFIG_[版本号]
的命名规则,每次配置修改时,版本号应递增。BUSINESS_MODULES
作为模块编译列表:用于存储需要编译的模块名称。
- 编译
BUSINESS_MODULES
指定模块
## Business # 动态加载, 配置文件modules_configs.cmake foreach(module IN LISTS BUSINESS_MODULES) message(STATUS "Add Business Module: ${module}") add_subdirectory(${module}) endforeach()
- 通过循环遍历
BUSINESS_MODULES
, 包含指定模块的编译路径,确保指定的模块都能被正确编译。
- 动态库入口实现
// The entry of OneNet business plugin extern "C" void PluginEntry(std::map<int, SprObserver*>& observers, SprContext& ctx) { auto pOneDrv = OneNetDriver::GetInstance(MODULE_ONENET_DRIVER, "OneDrv"); auto pOneMgr = OneNetManager::GetInstance(MODULE_ONENET_MANAGER, "OneMgr"); observers[MODULE_ONENET_DRIVER] = pOneDrv; observers[MODULE_ONENET_MANAGER] = pOneMgr; SPR_LOGD("Load plug-in OneNet modules\n"); }
- 实现动态库入口函数:
PluginEntry
作为动态库的入口函数,其内部主要负责调用当前模块的初始化函数。 - 初始化模块实例:通过
OneNetDriver::GetInstance
和OneNetManager::GetInstance
获取模块的单例实例。 - 注册模块实例:将模块实例注册到
observers
映射中,以便主程序能够访问和使用这些模块。
- 主程序加载指定动态库
- 插件化编程实现流程
void SprSystem::LoadPlugins() { std::string path = DEFAULT_PLUGIN_LIBRARY_PATH; if (access(DEFAULT_PLUGIN_LIBRARY_PATH, F_OK) == -1) { GetDefaultLibraryPath(path); SPR_LOGW("%s not exist, changed path %s\n", DEFAULT_PLUGIN_LIBRARY_PATH, path.c_str()); } DIR* dir = opendir(path.c_str()); if (dir == nullptr) { SPR_LOGE("Open %s fail! (%s)\n", path, strerror(errno)); return; } // loop: find all plugins library files in path struct dirent* entry; while ((entry = readdir(dir)) != NULL) { if (strncmp(entry->d_name, DEFAULT_PLUGIN_LIBRARY_FILE_PREFIX, strlen(DEFAULT_PLUGIN_LIBRARY_FILE_PREFIX)) != 0) { continue; } void* pDlHandler = dlopen(entry->d_name, RTLD_NOW); if (!pDlHandler) { SPR_LOGE("Load plugin %s fail! (%s)\n", entry->d_name, dlerror() ? dlerror() : "unknown error"); continue; } auto pEntry = (PluginEntryFunc)dlsym(pDlHandler, DEFAULT_PLUGIN_LIBRARY_ENTRY_FUNC); if (!pEntry) { SPR_LOGE("Find %s fail in %s! (%s)\n", DEFAULT_PLUGIN_LIBRARY_ENTRY_FUNC, entry->d_name, dlerror() ? dlerror() : "unknown error"); dlclose(pDlHandler); continue; } mPluginHandles.push_back(pDlHandler); mPluginEntries.push_back(pEntry); SPR_LOGD("Load plugin %s success!\n", entry->d_name); } closedir(dir); } void SprSystem::Init() { ... LoadPlugins(); // load plugin libraries // excute plugin entry function SprContext ctx; for (auto& mPluginEntry : mPluginEntries) { mPluginEntry(mModules, ctx); } // excute plug module initialize function for (auto& module : mModules) { module.second->Initialize(); } ... }
- 加载动态库
LoadPlugins()
:
加载位于DEFAULT_PLUGIN_LIBRARY_PATH
路径下,前缀为DEFAULT_PLUGIN_LIBRARY_FILE_PREFIX
的动态库。
获取并存储函数DEFAULT_PLUGIN_LIBRARY_ENTRY_FUNC
的地址。 - 主函数程序入口
Init()
:
调用LoadPlugins()
加载动态库。
执行获取到的函数DEFAULT_PLUGIN_LIBRARY_ENTRY_FUNC
。
验证
- 从日志上看
OneNetMqtt
模块是否正常
09-28 17:02:23.049 146938 SprSystem D: 173 Load plugin libpluginonenet.so success! 09-28 17:02:23.052 146938 EntryOneNet D: 41 Load plug-in OneNet modules
日志上看,动态库已经加载成功,动态库入口日志正常打印,OneNetMqtt
模块启动正常。
- 查阅系统加载的模块配置信息
$ cat /tmp/sparrow_version System Version : Sparrow 1.0.1 C++ Standard : 11 G++ Version : 11.4.0 Gcc Version : 11.4.0 Running Env : Default Build Time : 2024-09-28 16:50:58 Build Type : Release Build Host : Beckett Build Platform : Linux 5.15.153.1-microsoft-standard-WSL2 Module Config : DEFAULT_MCONFIG_1001
系统环境中模块配置版本号为DEFAULT_MCONFIG_1001
与配置文件中一致
总结
- 插件化编程通过动态加载功能模块,实现了软件的高度灵活性和可扩展性。其主要思路在于加载动态库,并调用动态库中预定义的入口函数,从而实现主程序与插件之间的解耦。
- 除了实现产品的功能差异化外,插件化编程还可以应用于性能优化、安全性增强、用户体验提升等多个方面。例如,通过动态加载最新的安全补丁或功能更新,无需重新启动整个应用程序。
- 在项目中实现差异化的配置时,建议采用单一配置文件或配置管理系统来集中管理所有配置项,减少因配置错误导致的问题。此外,配置文件应具备良好的可读性和易维护性,避免复杂的多重开关设计,以免造成新开发人员的理解困难。