本文译自Gigi Sayfan在DDJ上的专栏文章。Gigi Sayfan是北加州的一个程序员,email: gigi@gmail.com.
本文是一系列讨论架构、开发和部署C/C++跨平台插件框架的文章的第一篇。第一部分探索了一下现状,调查了许多现有的插件/组件库,深入研究了二进制兼容问题,并展现了一些该方案必要的一些属性。
后续的文章用一个例子展示了可用于Window、Linux、Mac OS X并易于移植到其他系统的,具有工业级强度的插件框架。与其他类似框架相比,该框架有一些独一无二的属性,并且被设计为灵活、高效、易于编程、易于创建新插件,且同时支持C和C++。同时还提供了多种部署选项(静态或动态库)。
我将开发一个简单的角色扮演游戏,可以自己增加非玩家角色的插件。游戏的引擎加载插件并无逢地集成他们。游戏展示了这些概念并且展示能够实际运行的代码。
谁需要插件?插件是你想开发一个成功的动态系统所需要的一种方式。基于插件的扩展性是当前扩展&进化一个系统的最具有实践意义的安全方式。插件使得第三方开发人员可以为系统做增值工作,也可以使其他开发人员增加新的功能而不破坏现有的核心功能。插件能够促进将关注点分开,保证隐藏实现细节,将测试独立开来,并最具有实践意义。
类似Eclipse的平台实际上就是一个所有功能都由插件提供的骨架。Eclipse IDE自身(包括UI和Java开发环境)仅仅是一系列挂在核心框架上的插件。
为什么选择C++众所周知,当用于插件时,C++不是一个容易适应新环境的东西。它非常依赖于编译器和平台。C++标准没有指定任何应用程序二进制接口,这说明由不同的编译器编译出的库甚至不同版本的库是不兼容的。加上C++没有动态加载的概念,且每个平台提供了自己的与其他平台不兼容的解决方案,你就能够了解。有少许重量级的解决方案试图说明不仅仅是插件和对一些额外的运行时的支持的依赖。但当要求高性能系统时, C/C++依然是仅有的实际可行的选项。
那里有什么?在着手一个全新的框架之前,检查现有的库或者框架是值得的。我发现既有重量级的解决方案,如M$的COM和Mozilla的XPCOM(Cross-platform COM),或者只提供相当基础功能的如QT的插件以及少许关于创建C++插件的文章。一个有趣的库,DynObj,声称能解决二进制兼容的问题(在相同的约束下)。也有一个由Daveed Vandervoorde提出,作为一个原生的概念给C++添加插件的提案。那是一个有趣的读物,但感觉怪怪的。
没有一个基础的解决方案阐述了与创建工业级强度的基于插件的系统相关的大量的问题,如错误处理,数据类型,版本控制,与框架代码以及应用代码的分离。在进入解决方案前,让我们理解这个问题。
二进制兼容问题再次强调,没有标准的C++ ABI。不同的编译器(甚至同一编译器的不同版本)产生不同的目标文件和库。最明显的表现是,不同编译器实现不同的name mangling(译注:这个术语我没有找到合适的翻译,意思是编译时给函数名字加上一些标识,功能之一就是区分重载函数)。这表明,通常情况下,你只能链接完全由同一个编译器(版本号也要相同)编译出来的目标文件。甚至有很多编译器没有完全实现C++98标准中的功能。
对于这个问题有一些局部的解决方案。例如,如果你访问一个C++对象时仅仅是通过虚拟指针(译注:不知道说的是什么意思,很费解,原文如下:if you access a C++ object only through a virtual pointer and call only its virtual methods you sidestep the name mangling issue)并只调用其虚函数九可以回避name mangling问题。然而,不能保证所有编译器编译出来的代码运行时在内存中有相同的VTABLE布局,尽管它更稳定(译注:应该指的是VTABLE在内存中的布局各编译器的实现更倾向于一致)。
如果你试图动态加载C++代码将面对另一个问题——没有直接的方法从Linux或者Mac OS X的动态库来加载并实例化C++的类(在Windows上,VC++支持)。
解决方案是使用一个具有C linkage的函数(因此编译器不会对其进行name mangling操作)作为一个工厂方法,来返回一个透明的handle给调用方。然后调用方将其转换成正确的类(通常是一个纯抽象基类)。当然,这需要一些协作,而且仅当应用和库所用编译器的VTABLE内存布局一致时才能工作。
解决兼容性的终极方法就是忘记C++,并使用纯C的API。在实际中,C对于所有的编译器实现都是兼容的。后面我会战士如何在C的兼容性基础上达成C++编程模型。
基于插件的系统的体系结构一个基于插件的系统可以分成三个部分:
- 领域相关系统(译注:应用程序的逻辑部分)
- 插件管理器
- 插件
领域相关系统通过插件管理器加载插件并创建其对象。一旦创建了插件对象且系统有某种指针或引用指向它,它就可以像其他对象一样使用。我们将看到,这通常会需要一些特殊的析构/清除工作。
插件管理器是相当通用的一段代码。它管理插件的生命期并且将他们暴露给主系统。它能找到并加载插件,初始化它们,注册工厂函数并能够卸载插件。它还应当能够让主系统遍历已加载的插件或注册的插件对象。
插件自身需要顺应插件管理器的协议并提供适应主系统期望的对象。
实际上,你很少会看见如此清晰的分解(总之,在基于C++的插件系统上如此)。插件管理器经常与领域相关系统紧密耦合。这是有很好的原因的。插件管理器需要提供某种类型的插件对象的最终实例。而且,插件的初始化经常需要传递一些领域相关的信息和/或回调函数/服务。这可以由通用插件管理器轻松地做到。
插件部署模型插件通常以动态库的形式部署。动态库允许插件的很多优势如热交换(重新加载一个插件的新实现而无需关闭系统),而且需要更少的链接时间。然而,在某些情况下静态库是插件的最好选择。例如,仅仅因为某些系统不支持动态库(很多嵌入式系统)。在其他的情况下,出于安全考虑,不允许加载陌生的代码。有时,核心系统会与一些预先加载好插件一起部署,而且静态加载到主系统中使得它们更健壮(因此用户不会无意中删除它们)。
底线是,好的插件系统应当同时支持静态和动态插件。这可以让你在不同的环境下,不同的约束下部署同一个基于插件的系统。
插件编程接口所以关于插件的问题都是关于接口(译注:要注意这里说的接口,不是C#和JAVA的接口概念,理解为signature更合适)的。基于插件的系统的基本观念是:有某个中央系统,通过定义良好的接口和协议,其在加载插件时不知道任何关于与插件通信的问题。
定义一系列函数作为插件导出的接口(动态库及静态库)是幼稚的方法。这种方法在技术上是可行的,但在概念上是有瑕疵的(译注:作者说话分量还是轻些)。原因是,插件应当支持两种接口且只能有一套从插件导出的函数。这表明这两种接口会被混合在一起。
第一层接口(及协议)是通用的插件接口。它使得中央系统可以初始化插件,并使插件可以在中央系统中注册一系列的用于创建和销毁对象以及全局的清理函数。通用插件接口不是与领域相关的,且可以被指定和实现为可服用的库。第二层接口是由插件对象实现的功能性的接口。该接口是领域相关的,且世纪的插件必须非常谨慎的对其进行设计和实现。中央系统应当知道该接口并能通过其与插件对象进行交互。
列表1是一个指定了通用插件接口的头文件。没有深入细节并解释所有事情之前,让我们看看它提供了什么。
- #ifndef PF_PLUGIN_H
- #define PF_PLUGIN_H
-
- #include <apr-1/apr_general.h>
-
- #ifdef __cplusplus
- extern "C" {
- #endif
-
- typedef enum PF_ProgrammingLanguage
- {
- PF_ProgrammingLanguage_C,
- PF_ProgrammingLanguage_CPP
- } PF_ProgrammingLanguage;
-
- struct PF_PlatformServices_;
-
- typedef struct PF_ObjectParams
- {
- const apr_byte_t * objectType;
- const struct PF_PlatformServices_ * platformServices;
- } PF_ObjectParams;
-
- typedef struct PF_PluginAPI_Version
- {
- apr_int32_t major;
- apr_int32_t minor;
- } PF_PluginAPI_Version;
-
- typedef void * (*PF_CreateFunc)(PF_ObjectParams *);
- typedef apr_int32_t (*PF_DestroyFunc)(void *);
-
- typedef struct PF_RegisterParams
- {
- PF_PluginAPI_Version version;
- PF_CreateFunc createFunc;
- PF_DestroyFunc destroyFunc;
- PF_ProgrammingLanguage programmingLanguage;
- } PF_RegisterParams;
-
- typedef apr_int32_t (*PF_RegisterFunc)(const apr_byte_t * nodeType, const PF_RegisterParams * params);
- typedef apr_int32_t (*PF_InvokeServiceFunc)(const apr_byte_t * serviceName, void * serviceParams);
-
- typedef struct PF_PlatformServices
- {
- PF_PluginAPI_Version version;
- PF_RegisterFunc registerObject;
- PF_InvokeServiceFunc invokeService;
- } PF_PlatformServices;
-
- typedef apr_int32_t (*PF_ExitFunc)();
-
- typedef PF_ExitFunc (*PF_InitFunc)(const PF_PlatformServices *);
-
- #ifndef PLUGIN_API
- #ifdef WIN32
- #define PLUGIN_API __declspec(dllimport)
- #else
- #define PLUGIN_API
- #endif
- #endif
-
- extern
- #ifdef __cplusplus
- "C"
- #endif
- PLUGIN_API PF_ExitFunc PF_initPlugin(const PF_PlatformServices * params);
-
- #ifdef __cplusplus
- }
- #endif
- #endif /* PF_PLUGIN_H */
复制代码
列表1
首先你应当注意到这是一个C文件。这允许插件框架可以由纯C系统编译使用并可用来写纯C插件。但是,它不仅仅局限在C上,且实际上大多数情况下用在C++中。
枚举类型PF_ProgrammingLanguage允许插件声明到用C++实现的插件管理器中。
PF_ObjectParams是一个抽象的结构体,创建插件时用于传递参数给插件对象。
PF_PluginAPI_Version被用于商讨版本问题,并保证插件管理器只加载合适版本的插件。
函数指针PF_CreateFunc和PF_DestroyFunc(由插件来实现)允许插件管理器来创建和销毁插件对象(每个插件注册这样的函数到插件管理器中。)
PF_RegisterParams结构体包含初始化时插件必须提供给插件管理器的所有信息。(版本信息,创建/销毁函数,编程语言)
PF_RegisterFunc(由插件管理器实现)允许每个插件为每种它所支持的对象类型注册一个PF_RegisterParams结构体。注意这个方案允许一个插件注册一个对象的不同版本和多个对象类型。
PF_InvokeService函数指针是一个通用的函数,查检可以用其来调用主系统提供的服务如日志、事件通知及错误报告。其签名(signature)包括服务名称和指向一个参数结构体不透明的指针。插件应当知道可用的服务以及如何调用它们(或者,如果你希望使用PF_InvokeService,你可以自己实现服务。)
PF_PlatformServices结构体聚集了我刚刚提到所有的由平台提供给插件的服务(版本,注册对象,执行服务函数)。该结构体在初始化时传递给每个插件。
PF_ExitFunc定义了插件退出函数,由插件来实现。
PF_InitFunc定义了插件初始化函数指针。
PF_initPlugin是动态插件(由动态链接库/共享库来部署的插件)实际的初始化函数的签名(signature)。从动态插件中导出它的名字,因此插件管理器可以在加载插件时调用它。它接受一个指向PF_PlatformServices结构体的指针,因此所有的服务在初始化时立刻可用(这是注册对象的正确时机)并返回一个指向退出函数的指针。
注意静态插件(实现在静态库中且直接连接到主执行体中)应当实现一个有C linkage的init函数,但禁止将其命名为PF_initPlugin。原因是如果有多个静态插件,他们都将有一个同样的初始化函数名字,你的编译器痛恨这个。
静态插件的初始化有所不同。他们必须显式地由主执行体初始化。主执行体将通过PF_InitFunc的签名调用它们的初始化函数。很不幸,这意味着每当一个新的静态插件加入/移出系统时,主执行体需要被修改,并且各种各样的init函数的名字必须是对等的(coordinated)。
有一种试图解决该问题的技术叫做“自动注册”。自动注册通过静态库中一个全局的对象来达到目的。该对象在main()事件启动之前被构建。该全局对象可以请求插件管理器来初始化静态插件(通过传递插件的init()函数指针)。不幸的是,这种方案在VC++中不能工作。
撰写插件撰写插件意味着什么?插件框架是非常generic,并且不提供任何可以与你的应用交互的切实的对象。你必须在插件框架上构建你自己的应用程序模型。这意味着你的应用程序(加载插件)以及插件自身必须同意并通过某种模型来协作。通常这表明应用程序期待插件提供暴露某种特定API的某种类型的对象。插件框架将提供注册、枚举及加载这些对象的基础设施。示例1是一个叫做IActor的C++接口的定义。它有两个操作——getInitialInfo()和play()。注意该接口不是充分的,因为getInitialInfo()期望一个指向名为ActorInfo的结构体的指针,且play()期望一个指向另一个叫做ITurn接口的指针。这是实际的一个案例,你必须设计并指定整个对象模型。
- struct IActor
- {
- virtual ~IActor() {}
- virtual void getInitialInfo(ActorInfo * info) = 0;
- virtual void play( ITurn * turnInfo) = 0;
- };
复制代码