CTK框架实际应用比较可靠,但网上资料很少。本教程围绕 CTK,探索 C++ 中的模块化技术,并能够基于 CTK 快速搭建 C++ 组件化框架,避免后来的人走弯路。本教程的源码下载地址:项目源代码。
CTK介绍
CTK 为支持生物医学图像计算的公共开发包,其全称为 Common Toolkit。CTK插件框架的设计有很大的灵感来自OSGi并且使得应用程序由许多不同的组件组合成一个可扩展模型。这个模型允许通过那些组件间共享对象的服务通信。
当前,CTK 工作的主要范围包括:
1.DICOM:提供了从 PACS 和本地数据库中查询和检索的高级类。包含 Qt 部件,可以轻松地设置服务器连接,并发送查询和查看结果。
2.DICOM Application Hosting:目标是创建 DICOM Part 19 Application Hosting specifications 的 C++ 参考实现。它提供了用于创建主机和托管应用程序的基础设。
3.Widgets:用于生物医学成像应用的 Qt Widgets 集合。
4.Plugin Framework:用于 C++ 的动态组件系统,以 OSGi 规范为模型。它支持一个开发模型,在这个模型中,应用程序(动态地)由许多不同(可重用的)组件组成,遵循面向服务的方法。
5.Command Line Interfaces:一种允许将算法编写为自包含可执行程序的技术,可以在多个终端用户应用程序环境中使用,而无需修改。
使用 CTK Plugin Framework 的好处
由于 CTK Plugin Framework 基于 OSGi,因此它继承了一种非常成熟且完全设计的组件系统,这在 Java 中用于构建高度复杂的应用程序,它将这些好处带给了本地(基于 Qt 的)C++ 应用程序。以下内容摘自 Benefits of Using OSGi,并适应于 CTK Plugin Framework:
降低复杂性
使用 CTK Plugin Framework 开发意味着开发插件,它们隐藏了内部实现,并通过定义良好的服务来和其它插件通信。隐藏内部机制意味着以后可以自由地更改实现,这不仅有助于 Bug 数量的减少,还使得插件的开发变得更加简单,因为只需要实现已经定义好的一定数量的功能接口即可。
复用
标准化的组件模型,使得在应用程序中使用第三方组件变得非常容易。
现实情况
CTK Plugin Framework 是一个动态框架,它可以动态地更新插件和服务。在现实世界中,有很多场景都和动态服务模型相匹配。因此,应用程序可以在其所属的领域中重用 Service Registry 的强大基元(注册、获取、用富有表现力的过滤语言列表、等待服务的出现和消失)。这不仅节省了编写代码,还提供了全局可见性、调试工具以及比为专用解决方案实现的更多的功能。在这样的动态环境下编写代码听起来似乎是个噩梦,但幸运的是,有支持类和框架可以消除大部分(如果不是全部的话)痛苦。
开发简单
CTK Plugin Framework 不仅仅是组件的标准,它还指定了如何安装和管理组件。这个 API 可以被插件用来提供一个管理代理,这个管理代理可以非常简单,如命令 shell、图形桌面应用程序、Amazon EC2 的云计算接口、或 IBM Tivoli 管理系统。标准化的管理 API 使得在现有和未来的系统中集成 CTK Plugin Framework 变得非常容易。
动态更新
OSGi 组件模型是一个动态模型,插件可以在不关闭整个系统的情况下被安装、启动、停止、更新和卸载。
自适应
OSGi 组件模型是从头设计的,以允许组件的混合和匹配。这就要求必须指定组件的依赖关系,并且需要组件在其可选依赖性并不总是可用的环境中生存。Service Registry 是一个动态注册表,其中插件可以注册、获取和监听服务。这种动态服务模型允许插件找出系统中可用的功能,并调整它们所能提供的功能。这使得代码更加灵活,并且能够更好地适应变化。
透明性
插件和服务是 CTK 插件环境中的一等公民。管理 API 提供了对插件的内部状态的访问,以及插件之间的连接方式。可以停止部分应用程序来调试某个问题,或者可以引入诊断插件。
版本控制
在 CTK Plugin Framework 中,所有的插件都经过严格的版本控制,只有能够协作的插件才会被连接在一起。
简单
CTK 插件相关的 API 非常简单,核心 API 不到 25 个类。这个核心 API 足以编写插件、安装、启动、停止、更新和卸载它们,并且还包含了所有的监听类。
懒加载
懒加载是软件中一个很好的点,OSGi 技术有很多的机制来保证只有当类真正需要的时候才开始加载它们。例如,插件可以用饿汉式启动,但是也可以被配置为仅当其它插件使用它们时才启动。服务可以被注册,但只有在使用时才创建。这些懒加载场景,可以节省大量的运行时成本。
非独占性
CTK Plugin Framework 不会接管整个应用程序,你可以选择性地将所提供的功能暴露给应用程序的某些部分,或者甚至可以在同一个进程中运行该框架的多个实例。
非侵入
在一个 CTK 插件环境中,不同插件均有自己的环境。它们可以使用任何设施,框架对此并无限制。CTK 服务没有特殊的接口需求,每个 QObject 都可以作为一个服务,每个类(也包括非 QObject)都可以作为一个接口。
CTK编译
使用cmake编译出与系统版本相应的动态库。参见CTK编译教程(64位环境 Windows + Qt + MinGW或MSVC + CMake)。
项目结构
由于每一个插件都要建一个子项目,本项目刚开始创建时在QtCreator中选择新建-其他项目-子目录项目,新建项目名称为ctkExample,然后建立主程序入口项目,这里建立一个控制台项目,取名叫console。
更改项目输出路径:console.pro
DESTDIR = $$PWD/../bin |
主函数中加载插件,启动框架:main.cpp
#include <QCoreApplication> #include "ctkPluginFrameworkFactory.h" #include "ctkPluginFramework.h" #include "ctkPluginException.h" #include <QDebug> int main(int argc, char *argv[]) { QCoreApplication a(argc, argv); a.setApplicationName("ctkExample"); //给框架创建名称,Linux下没有这步会报错 ctkPluginFrameworkFactory factory;//插件工厂类 QSharedPointer<ctkPluginFramework> framework; framework = factory.getFramework(); try{ framework->init(); framework->start(); } catch(const ctkPluginException& e){ qDebug() << "framework init fail"; qDebug() << e.message() << e.getType(); } return a.exec(); } |
如果想把CTK初始化、插件安装启动、获取等操作封装成一个类,那么要注意:需要把CTK相关的变量定义成类属性,不能是局部变量,否则会出现各种问题如获取不了服务、服务引用为空等。
没有报错的话及表示插件加载成功!
其中QSharedPointer framework这个对象比较有意思,既可以作为对象也可以作为对象指针,但要作为插件框架使用必须要用指针方法调用,所以代码里使用“->”。
项目加载CTK框架插件
复制CTK安装目录到项目根目录,编译出的动态库就可以当普通动态库使用加载了,pro里面加载代码为:
LIBS += -L$$PWD/../libs/ctk-0.1 -lCTKCore LIBS += -L$$PWD/../libs/ctk-0.1/ -lCTKPluginFramework INCLUDEPATH += $$PWD/../includes/ctk-0.1 DEPENDPATH += $$PWD/../libs/ctk-0.1 |
CTK插件的接口处理
CTK框架由一个一个可分离的插件组成,框架对插件识别有一定要求,目前网上很多一整块扔出来对新人不太友好,博主这里讲解是尽量拆。单个插件最基本的格式要求分成Activator,qrc文件,以及MANIFEST.MF,我们以生成一个主界面模块MainWindow为例。
Activator注册器
每个插件都有自己的注册器Activator。
右键项目选择新建子项目-其他项目-Empty qmake Project,项目名称为MainWindow,pro文件中添加代码:
TEMPLATE = lib TARGET = MainWindow DESTDIR = $$PWD/../bin/plugins/$$TARGET QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets #加载ctk库 LIBS += -L$$PWD/../libs/ctk-0.1 -lCTKCore LIBS += -L$$PWD/../libs/ctk-0.1/ -lCTKPluginFramework INCLUDEPATH += $$PWD/../includes/ctk-0.1 #INCLUDEPATH += $$PWD/../libs/ctk-0.1 DEPENDPATH += $$PWD/../libs/ctk-0.1 |
生成的插件名(TARGET)不要有下划线,因为CTK会默认将插件名中的下划线替换成点号,最后后就导致找不到插件。
项目中添加C++类MainWindowActivator,代码如下:
mainwindowactivator.h
#ifndef MAINWINDOWACTIVATOR_H #define MAINWINDOWACTIVATOR_H #include <QObject> #include "ctkPluginActivator.h" class MainWindowActivator : public QObject, public ctkPluginActivator { public: Q_OBJECT Q_PLUGIN_METADATA(IID "MainWindow") //向Qt的插件框架声明,希望将xxx插件放入到框架中。 Q_INTERFACES(ctkPluginActivator) public: MainWindowActivator(); void start(ctkPluginContext *context); void stop(ctkPluginContext *context); }; #endif // MAINWINDOWACTIVATOR_H |
mainwindowactivator.cpp
#include "mainwindowactivator.h" #include <QDebug> MainWindowActivator::MainWindowActivator() { } void MainWindowActivator::start(ctkPluginContext *context) { qDebug() << "mainwindow start"; } void MainWindowActivator::stop(ctkPluginContext *context) { qDebug() << "my plugin stop"; Q_UNUSED(context) /* Q_UNUSED,如果一个函数的有些参数没有用到、某些变量 只声明不使用,但是又不想编译器、 编辑器报警报,其他没有什么实际性作用 */ } |
activator是标准的Qt插件类,它实现ctkPluginActivator的start、stop函数并对外提供接口。我这里是Qt5的版本,所以使用Q_PLUGIN_METADATA申明插件,Qt4需要用自己的方法实现插件。
qrc文件
创建插件的资源文件,格式如下:
<RCC> <qresource prefix="/MainWindow/META-INF"> <file>MANIFEST.MF</file> </qresource> </RCC> |
插件加载后会寻找同名前缀/META-INF,所以前缀格式固定,将MANIFEST.MF文件添加进来
MENIFEST.MF文件内容如下:
可直接在MF文件里添加自己特有的元数据
Plugin-SymbolicName:MainWindow Plugin-Version:1.0.0 Plugin-Number:100 #元数据 |
注意:Plugin-SymbolicName要满足这里的前缀是:TARGET/META-INF格式。TARGET的名字最好和工程名一致,不然可能出现device not open错误。
获取MANIFEST.MF中的数据
QHash<QString, QString> headers = plugin->getHeaders(); ctkVersion version = ctkVersion::parseVersion(headers.value (ctkPluginConstants::PLUGIN_VERSION)); QString name = headers.value(ctkPluginConstants::PLUGIN_NAME); |
文件包含ctk插件的基本信息,只要ctk框架正常识别到文件中Plugin-SymbolicName等信息,则判定它是一个ctk插件,能够正常调用activator中的start、stop函数。这个文件需要拷到插件生成路径下,pro文件中添加代码:
file.path = $$DESTDIR file.files = MANIFEST.MF INSTALLS += file |
CTK插件启用
根据以上步骤,一个CTK插件接口定义基本完成,我们在console项目下调用观察插件是否能够正常加载。main函数中框架启动成功后添加以下代码:
QString dir = QCoreApplication::applicationDirPath(); dir += "/plugins/MainWindow/libMainWindow.dll"; qDebug() << dir; QUrl url = QUrl::fromLocalFile(dir); QSharedPointer<ctkPlugin> plugin; try { plugin = framework->getPluginContext()->installPlugin(url); }catch(ctkPluginException e){ qDebug() << e.message() << e.getType(); } try{ plugin->start(ctkPlugin::START_TRANSIENT); }catch(ctkPluginException e){ qDebug() << e.message() << e.getType(); } |
控制台打印输出:
"C:/d/mmm/qt/ctk/ctkExample/bin/plugins/ MainWindow / MainWindow.dll" mainwindow start |
成功调用MainWindow中start内打印输出,则表明ctk插件接口正常定义并能成功加载。其中start(ctkPlugin::START_TRANSIENT)表示立即启用插件,不设置参数的话加载后也不会立即打印输出。
CTK插件间通信
CTK框架插件化开发实现功能的隔离,插件通信需要参照固定标准,这里介绍两种插件间通信的方法。以上面的MainWindow为例,主程序中以接口调用的方法弹出插件中的界面。由于涉及到Qt的Widget界面,请先将main函数中的QCoreApplication改为QApplication。
通信方法1-注册接口调用
函数接口
接口就是纯虚函数类,也就是最终的服务的前身。
上面我们已经编译出需要的动态库,首先确定我们需要插件向外部暴露的功能有什么,比如这里我们需要弹出窗口界面的操作,定义头文件如下:imainwindow.h
#ifndef IMAINWINDOW_H #define IMAINWINDOW_H #include <QObject> class iMainWindow { public: virtual void popMainWindow() = 0; }; Q_DECLARE_INTERFACE(iMainWindow, "interface_mainwindow") //此宏将当前这个接口类声明为接口, 后面的一长串就是这个接口的唯一标识。 #endif // IMAINWINDOW_H |
Q_DECLARE_INTERFACE将接口类向Qt系统申明,然后添加它的实现对象:
接口的实现
插件就是实现这个接口类的实现类,所以理论上有多少个实现类就有多少个插件。
#ifndef MAINWINDOWPLUGIN_H #define MAINWINDOWPLUGIN_H #include <QObject> #include "../includes/imainwindow.h" #include "ctkPluginContext.h" #include "mainwindowdlg.h" class MainWindowPlugin : public QObject, public iMainWindow { Q_OBJECT Q_INTERFACES(iMainWindow) /* 此宏与Q_DECLARE_INTERFACE宏配合使用。 Q_DECLARE_INTERFACE:声明一个接口类 Q_INTERFACES:当一个类继承这个接口类, 表明需要实现这个接口类 */ public: MainWindowPlugin(ctkPluginContext *context); virtual void popMainWindow(); private: ctkPluginContext *m_context; MainWindowDlg* m_windowDlg; }; #endif // MAINWINDOWPLUGIN_H |
mainwindowplugin.cpp
#include "mainwindowplugin.h" MainWindowPlugin::MainWindowPlugin(ctkPluginContext *context) :m_context(context) { m_windowDlg = new MainWindowDlg; } void MainWindowPlugin::popMainWindow() { m_windowDlg->show(); } |
这仍是Qt的插件定义格式,但是不会作为插件导出,外部功能接口可以自定义。
服务注册(Activator注册服务)
激活类里有一个独占智能指针,指向接口类【使用多态,指针都指向父类】,然后在start里new一个实现类,注册这个实现类为服务,功能是实现接口类的接口,然后将智能指针指向这个实现类。可以理解为以后向框架索取这个服务的时候,实际获取的就是这个new出来的实现类。如果不用智能指针,就需要在stop里手动delete这个实现类。
每个插件都有自己的注册器Activator,功能节接口完成后,在插件启动时注册到ctk框架的服务中,代码如下:mainwindowactivator.cpp
void MainWindowActivator::start(ctkPluginContext *context) { qDebug() << "mainwindow start"; m_plugin = new MainWindowPlugin(context); ctkDictionary dic; context->registerService<iMainWindow>(m_plugin, dic); } |
接口调用
CTK插件启用后,就可以调用接口。
主函数框架及插件加载完成后,即可调用插件接口,代码如下:main.cpp
#include "../includes/imainwindow.h" …… ctkPluginContext* context = framework-> getPluginContext (); ctkServiceReference ref =context->getServiceReference <iMainWindow>(); iMainWindow* mainWindow; if(ref) mainWindow = context->getService<iMainWindow>(ref); if(mainWindow) mainWindow->popMainWindow(); |
在获取服务的时候,有两个重载方式【可直接使用的】
1、iMainWindow* ret = context->getService <iMainWindow > (reference); 2、iMainWindow* ret = qobject_cast <iMainWindow*> (context->getService(reference)); |
服务就是根据接口的实例,每生成一个服务就会调用一次注册器的start。把接口当做类,服务是根据类new出的对象,插件就是动态库dll。
小结:接口、插件、服务的关系
1、1对1
1个接口类由1个类实现,输出1个服务和1个插件。
2、多对1
1个类实现了2个接口类,输出2个服务和1个插件,无论想使用哪个服务最终都通过这同一个插件来实现。
3、1对多
1接口由2个类实现,也就是某一个问题提供了2种解决思路,输出1个服务和2个插件,通过ctkPluginConstants::SERVICE_RANKING和ctkPluginConstants::SERVICE_ID来调用不同的插件。这里虽然有两个插件,但都是被编译到同一个dll中的。
某插件每次调用另一个插件的时候,只会生成一个实例,然后把实例存到内存当中,不会因为多次调用而生成多个服务实例。
在使用1接口2插件的时候,虽然是两个插件,也会有两个激活类【从原理上来讲1个激活类就行了,但是在start里注册两次】,其中的IID只能有一个。从Qt插件基础上来说,一个dll只能有一个IID。
通信方法2-事件监听
CTK框架中的事件监听,即观察者模式流程上是这样:接收者注册监听事件->发送者发送事件->接收者接收到事件并响应;相比调用插件接口,监听事件插件间依赖关系更弱,不用指定事件的接收方和发送方是谁。
要使用CTK框架的事件服务,准备工作应该从cmake开始,编译出支持事件监听的动态库,名称为liborg_commontk_eventadmin.dll。现在要完成的内容是,从上面生成的主窗体中,以事件监听的方式调用一个子窗体。
1、通信主要用到了ctkEventAdmin结构体,主要定义了如下接口:
postEvent:类通信形式异步发送事件
sendEvent:类通信形式同步发送事件
publishSignal:信号与槽通信形式发送事件
unpublishSignal:取消发送事件
subscribeSlot:信号与槽通信形式订阅时间,返回订阅的ID
unsubscribeSlot:取消订阅事件
updateProperties:更新某个订阅ID的主题
2、通信的数据是:ctkDictionary
其实就是个hash表:typedef QHash<QString,QVariant> ctkDictionary
加载EventAdmin动态库
添加动态库可以使用ctkPluginFrameworkLauncher,代码如下:main.cpp
ctkPluginFrameworkLauncher::addSearchPath ("../libs/ctk-0.1/plugins"); ctkPluginFrameworkLauncher::start("org.commontk .eventadmin"); …… // 停止插件 ctkPluginFrameworkLauncher::stop(); |
需要在框架加载前调用。
事件注册监听(接收插件)
首先编写我们需要的接收者模块,并注册监听事件,这里我们新建一个模块Client1,模块的接口处理参见上面“CTK插件的接口处理”。插件部分代码如下:
client1plugin.h
#ifndef CLIENT1PLUGIN_H #define CLIENT1PLUGIN_H #include <QObject> #include "ctkPluginContext.h" #include "service/event/ctkEventAdmin.h" #include "service/event/ctkEventHandler.h" #include "client1dlg.h" class Client1Plugin : public QObject, public ctkEventHandler { Q_OBJECT Q_INTERFACES(ctkEventHandler) public: Client1Plugin(ctkPluginContext *context); protected: void handleEvent(const ctkEvent& event); signals: void openDlg(); public slots: void onOpenDlg(); private: void registToMainWindow(); ctkPluginContext *m_context; Client1Dlg* m_clientDlg; }; #endif |
#include "client1plugin.h" #include <service/event/ctkEventConstants.h> Client1Plugin::Client1Plugin(ctkPluginContext *context) :m_context(context) { m_clientDlg = new Client1Dlg; connect(this, SIGNAL(openDlg()), this, SLOT(onOpenDlg()), Qt::QueuedConnection); //注册监听信号"zhimakaimen" ctkDictionary dic; dic.insert(ctkEventConstants::EVENT_TOPIC, "zhimakaimen"); context->registerService<ctkEventHandler>(this, dic); } void Client1Plugin::handleEvent(const ctkEvent& event) { //接收监听事件接口 if(event.getTopic() == "zhimakaimen") { emit openDlg(); //这里用了信号槽异步,因为线程中不能调用界面元素 } } |
与上面自定义接口不同,这里我们实例化ctkEventHandler对象,并实现handleEvent接口。构造函数中注册的服务对象是ctkEventHandler,在注册时指定触发的事件,当事件触发时调用该对象的handleEvent实现指定操作。
事件发送(发送插件)
监听对象完成后调用比较简单,比如我们直接在窗体的菜单栏中,点击actoin调用,代码如下:mainwindowdlg.cpp
#include "mainwindowdlg.h" #include "ui_mainwindowdlg.h" #include "service/event/ctkEvent.h" #include "service/event/ctkEventAdmin.h" #include "service/event/ctkEventHandler.h" MainWindowDlg::MainWindowDlg(ctkPluginContext * context , QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindowDlg), m_context(context) { ui->setupUi(this); QAction* action = new QAction("用户1"); ui->menubar->addAction(action); connect(action, SIGNAL(triggered(bool)), this , SLOT (action_clicked())); } MainWindowDlg::~MainWindowDlg() { delete ui; } void MainWindowDlg::action_clicked() { //获取事件服务接口 ctkServiceReference ref; ctkEventAdmin* eventAdmin; ref = m_context->getServiceReference<ctkEventAdmin>(); if(ref) { eventAdmin = m_context->getService<ctkEventAdmin>(ref); m_context->ungetService(ref); } //发送事件 ctkDictionary message; if(eventAdmin) eventAdmin->postEvent(ctkEvent("zhimakaimen", message)); } |
事件发送二种方式:类通信、信号槽通信
1、类通信
原理就是直接将信息使用CTK的eventAdmin接口send/post出去。
2、信号槽通信
原理是将Qt自己的信号与CTK的发送事件绑定、槽与事件订阅绑定。
二种方式的区别:
1、通过event事件通信,是直接调用CTK的接口,把数据发送到CTK框架;通过信号槽方式,会先在Qt的信号槽机制中转一次,再发送到CTK框架。故效率上来讲,event方式性能高于信号槽方式。
2、两种方式发送数据到CTK框架,这个数据包含:主题+属性。主题就是topic,属性就是ctkDictionary。 一定要注意signal方式的信号定义,参数不能是自定义的,一定要是ctkDictionary,不然会报信号槽参数异常错误。
3、两种方式可以混用,如发送event事件,再通过槽去接收;发送signal事件,再通过event是接收。
4、同步:sendEvent、Qt::DirectConnection;异步:postEvent、Qt::QueuedConnection
这里的同步是指:发送事件之后,订阅了这个主题的数据便会处理数据【handleEvent、slot】,处理的过程是在发送者的线程完成的。可以理解为在发送了某个事件之后,会立即执行所有订阅此事件的回调函数。
异步:发送事件之后,发送者便会返回不管,订阅了此事件的所有插件会根据自己的消息循环,轮到了处理事件后才会去处理。不过如果长时间没处理,CTK也有自己的超时机制。如果事件处理程序花费的时间比配置的超时时间长,那么就会被列入黑名单。一旦处理程序被列入黑名单,它就不会再被发送任何事件。
插件依赖
插件加载时一般根据首字母大小自动加载,所以在Client1启用时,MainWindow还没有被调用,所以发送的”event/registAction”事件没有接收方,这样就要考虑到插件依赖关系,在MANIFEST.MF中添加依赖:
Plugin-SymbolicName:Client1 Plugin-Version:1.0.0 Require-Plugin:MainWindow; plugin-version="[1.0,2.0)" ; resolution ="mandatory" |
MainWindow:为需要依赖的插件名【就是另一个插件在MANIFEST.MF里的Plugin-SymbolicName】;
[1.0,2.0):为MainWindow的版本,这里是左闭右开区间,默认是1.0,;
resolution:有两个选择,optional、mandatory。前者是弱依赖,就算依赖的插件没有,当前插件也能正常使用,后者是强依赖,如果没有依赖的插件,就当前插件就不能被start。
这样就向框架申明了,该插件加载时需要先加载MainWindow插件,所有用户插件都应该有这样一份申明