C/C++:构建你自己的插件框架
本文译自 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
#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;