[译]C/C++:构建你自己的插件框架(1)
本文译自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;
};
示例1每个插件可以注册多个实现了IActor接口的类型。当应用程序决定示例化一个由插件注册的对象,它将调用注册的,由插件实现的PF_CreateFunc函数。插件负责创建一个合适的对象并将其返回给应用程序。返回类型指定为void *是因为对象创建操作是通用插件框架的一部分,该部分不知道任何关于特定IActor接口的信息。应用程序随后将void *转换到IActor *,然后就可以在整个接口中使用它,好像它是一个正常的对象。当应用程序使用完IActor对象后,它执行注册的由插件实现的PF_DestroyFunc函数,然后插件销毁actor对象。目前不好考虑虚拟的析构函数,我会在后面的部分讨论它。编程语言支持在二进制兼容性部分我解释了你可以利用C++的vtable一级的兼容性,如果你的编译器满足的话。你也可以使用C一级的兼容性,这样你就可以使用不同的编译器来构建应用程序和插件,但你将被局限在C的交互上。你的应用程序对象模型必须是基于C的。你不能使用好的C++接口如IACTOR,但你必须设计一个相似的C接口。
纯C在纯C的编程模型中你只需要用C开发插件。当你实现PF_CreateFunc函数时你返回一个在你的应用程序C对象模型中与其它C对象交互的C对象。所有的话题都是关于C对象和C对象模型的。所有人都知道C是一个过程语言,没有对象的概念。然而C提供了足够的抽象机制来实现对象以及多态(在此处是必须的)并支持面向对象的编程泛型。实际上,最初的C++编译器是一个C编译器的事实上的一个前端(front-end)。它根据C++代码产生C代码,然后使用一个普通的C编译器来编译该C代码。它的名字Cfront说明了一切。使用包含函数指针的结构体(译注:就可以获得OO特性)。每个函数的签名应当接受它所属结构体作为第一个参数该结构体也可以包含其它的数据成员。这样提供了(与C++类有关的简单的土语)如:封装(状态和行为捆绑)、继承(通过将基结构体的对象作为第一个数据成员)以及多态(通过设置不同的函数指针。)(译注:没错,这就是用C来编写OO程序的基本要求和方法,我也用C写过OO程序)。C不支持析构函数、函数及操作符重载,名字空间,因此你定义接口时只有很少的选项。这也许是“塞翁失马,焉知非福”,因为接口应该被可能掌握C++另一个子集的其它人所使用。减少语言的范围可能会提升你的接口的简单性和可用性。我将在插件框架的后续文章中探究OO的C。列表2包含了陪伴该文章系列(仅仅是投你所好)的示例游戏的C对象模型。如果你快速浏览一下你会看见它甚至支持集合以及遍历。#ifndef C_OBJECT_MODEL
#define C_OBJECT_MODEL
#include <apr-1/apr.h>
#define MAX_STR 64 /* max string length of string fields */
typedef struct C_ActorInfo_
{
apr_uint32_t id;
apr_byte_t name;
apr_uint32_t location_x;
apr_uint32_t location_y;
apr_uint32_t health;
apr_uint32_t attack;
apr_uint32_t defense;
apr_uint32_t damage;
apr_uint32_t movement;
} C_ActorInfo;
typedef struct C_ActorInfoIteratorHandle_ { char c; } * C_ActorInfoIteratorHandle;
typedef struct C_ActorInfoIterator_
{
void (*reset)(C_ActorInfoIteratorHandle handle);
C_ActorInfo * (*next)(C_ActorInfoIteratorHandle handle);
C_ActorInfoIteratorHandle handle;
} C_ActorInfoIterator;
typedef struct C_TurnHandle_ { char c; } * C_TurnHandle;
typedef struct C_Turn_
{
C_ActorInfo * (*getSelfInfo)(C_TurnHandle handle);
C_ActorInfoIterator * (*getFriends)(C_TurnHandle handle);
C_ActorInfoIterator * (*getFoes)(C_TurnHandle handle);
void (*move)(C_TurnHandle handle, apr_uint32_t x, apr_uint32_t y);
void (*attack)(C_TurnHandle handle, apr_uint32_t id);
C_TurnHandle handle;
} C_Turn;
typedef struct C_ActorHandle_ { char c; } * C_ActorHandle;
typedef struct C_Actor_
{
void (*getInitialInfo)(C_ActorHandle handle, C_ActorInfo * info);
void (*play)(C_ActorHandle handle, C_Turn * turn);
C_ActorHandle handle;
} C_Actor;
#endif
列表2
纯C++在纯C++编程模型中你仅仅需要用C++开发你的插件。插件编程接口函数可以被实现为静态成员函数或者普通的静态/全局函数(毕竟C++主要是C的超集)。(这句不好翻啊:The object model can be your garden variety C++ object model.) 列表3包含示例游戏的C++对象模型。它基本上与列表2种的C对象模型相似。#ifndef OBJECT_MODEL
#define OBJECT_MODEL
#include "c_object_model.h"
typedef C_ActorInfo ActorInfo;
struct IActorInfoIterator
{
virtual void reset() = 0;
virtual ActorInfo * next() = 0;
};
struct ITurn
{
virtual ActorInfo * getSelfInfo() = 0;
virtual IActorInfoIterator * getFriends() = 0;
virtual IActorInfoIterator * getFoes() = 0;
virtual void move(apr_uint32_t x, apr_uint32_t y) = 0;
virtual void attack(apr_uint32_t id) = 0;
};
struct IActor
{
virtual ~IActor() {}
virtual void getInitialInfo(ActorInfo * info) = 0;
virtual void play( ITurn * turnInfo) = 0;
};
#endif
列表3
C/C++二重奏在这个编程模型中你可以使用C或者C++来开发插件。当你注册你的对象时要指定编程语言。如果你创建一个平台并且你想提供给第三方开发者最终的自由是他们可以选择自己的编程语言及模型,混合并匹配C和C++插件时,这个模型将非常有用。插件框架支持它,但实际的工作在于为你的应用设计一个既支持C又支持C++对象模型。每个对象类型需要同时实现C和C++的接口。这意味着你将有一个有着标准VTABLE布局的C++类以及一系列与虚拟方法相关的函数指针。这种结构非常复杂,我将不演示它。要注意的是从插件开发人员的角度来说这种方法不会带来额外的复杂性。他们永远可以使用C或C++接口来开发C或C++的插件。C/C++混合体在该模型中,你必须在C对象模型的盖子之下用C++开发插件。这就包括了C++包裹类的创建,该包裹类实现了C++对象模型并包裹(wrap)相应C对象的。插件开发人员在这层上编程,将每个调用、参数及返回值在C和C++之间翻译。当实现你的应用程序对象模型时这需要额外的工作,但通常很直接。好处是对于插件开发这来说提供了一个有着完整C级兼容性的好的C++编程模型。我不会在示例游戏的上下文中演示它。语言-链接矩阵图1显示了各种不同的部署模型组合的利与弊(静态库 vs. 动态库)以及编程语言的选择(C vs. C++)。图1为了本次讨论,如果使用C++插件,C/C++二重奏模型有C++限制和所需的先决条件,对于C插件,有C的限制和所需的先决条件。而且,C/C++混合模型不过是C模型,因为C++层被隐藏在插件实现后面。这些都让人迷惑,但底线是你有选项了,且插件框架允许你做出自己的决定,采用你自己认为合适的折中。它没有强迫你使用某个特定的模型,也没有瞄准最小公分母。Copyleft (C) 2007, 2008 raof01. 本文可以用于除商业用途外的所有用途。若用于非商业用途,请保留此权利声明,并以超链接形式标明文章原始出版、作者信息和本声明;若要用于商业用途,请与作者联系,否则作者将使用法律来保证权利。
页:
[1]