转自:http://www.devbean.net/2012/03/building-your-own-plugin-framework-1/
本系列文章来自 Building Your Own Plugin Framework,主要内容是讨论使用 C/C++ 语言开发跨平台的插件框架所需要的架构、开发方法以及部署。我们将从分析现有插件/组件系统开始,一步步深入了解如何开发插件框架,以及很多需要注意的问题,比如二进制兼容性等,在文章的最后,我们将给出一个比较合理的解决方案。
在本系列文章中,我们将开发一套具有工业强度的插件框架,可以运行在 Windows、Linux、OS X 等主流操作系统之上,并且可以很容易地移植到其他操作系统平台。这个插件框架相对于其他已有的系统具有一些独特的属性,并且灵活易用,兼顾 C 和 C++,提供多种部署方式(动态库和静态库)。
我们将以一个简单的角色扮演游戏为例,来说明我们的插件框架。在该游戏中,我们利用插件来添加 NPC。游戏引擎加载插件,集成其内容。
谁需要插件?
要回答“谁需要插件”这个问题,我们需要首先理解,什么是插件。
如果你要开发成功的、动态的系统,插件是最有效的方法之一。基于插件的系统具有很好的可扩展性,可以说是当前技术条件下最有效的一种安全扩展现有系统的解决方案。插件允许第三方开发者为系统添加有价值的东西,允许本系统开发者在不改变核心功能的条件下增加新的功能。插件提供了一种机制,可以分离相互独立的概念、隐藏实现细节、易于测试,还有很多其他的好处。
平台,比如 Eclipse,就是典型的插件系统。它的功能全部由插件提供,其核心功能可以看做一个插件加载器。 Eclipse IDE 本身(包括 UI 和 Java 开发环境)都是以插件挂载到核心框架的形式实现。
为什么选择 C++?
在插件开发方面,C++ 可谓臭名昭著。C++ 极大地依赖于平台特性和编译器特性。C++ 标准没有指定应用程序二进制接口(Application Binary Interface, ABI),这意味着,使用不同编译器,甚至同一编译器的不同版本来编译 C++ 库,都有可能是部件通的。基于以上事实,C++ 本身根本没有动态加载的概念。那些所谓的“动态加载”,都是各个平台提供的自己的解决方案(显然是互不兼容的)。现在,你就需要建立起这么一个概念。不过,现在有很多重量级的解决方案解决了这一问题,不仅仅是插件机制,而且增加了很多运行时支持。
说了这么多 C++ 的不好,有一点我们不可否认,C/C++ 至今仍然是开发高效系统的首选语言。因此,我们需要使用 C++ 实现插件机制。很多时候,这是我们无法绕过的。
已经有什么解决方案?
在开发我们的新插件框架之前,我们最好看一下现在已有的库或者框架。
现在既有重量级的解决方案,比如 Microsoft 的 COM 和 Mozilla 的 XPCOM (Cross-platform COM),也有仅提供了基本功能的,比如 Qt 的插件系统和其他一些轻量级 C++ 库。其中一个库是DynObj,它的目标是消除二进制兼容性问题(基于一些限制)。还有一个类库由 Daveed Vandervoorde 开发,试图向 C++ 提供原生的插件概念。这篇文章读起来很有趣,但是感觉很奇怪。
不过,上面所说的这些轻量级解决方案都没有解决很多创建工业强度的插件系统所必须面对的问题,比如错误处理、数据类型、版本以及框架代码和应用程序代码的分离等。在试图解决问题之前,我们必须首先理解问题。
二进制兼容性问题
再次强调一句,现在没有标准的 C++ ABI。这意味着,不同编译器(甚至同一编译器的不同版本)会编译出不同的目标文件和库。这个问题导致的最显而易见的问题就是,不同编译器会使用不同的名称改写算法。所谓名称改写(name mangling),意思是,在目标文件符号表中和连接过程中使用的名字,通常和编译目标文件的源程序中使用的名字不一样,为了进行匹配,编译器需要将目标源文件中的名字进行调整。名称改写并不是 C++ 所特有的,例如,我们在汇编 C 语言时经常看到的以下划线 _ 开头的函数名,其实就是 C 编译器将函数名进行了名称改写。但是在 C++ 中,名称改写要复杂得多,因为 C++ 中支持 overload 和 override。名称改写的存在意味着,通常来说,你只能使用完全一致的编译器(同一编译器的同一版本)来链接 C++ 目标文件和库。许多编译器甚至从 C++ 98 标准起就没有完整实现。
不过,我们也有很多办法来解决这个问题。例如,如果你仅仅通过虚指针去访问 C++ 对象,仅仅调用虚函数,那么就不存在这个问题。但是,这种方法并不值得推荐,因为即使是虚表,不同编译器生成的内存中的格式也是不一致的,虽然这比名称改写的区别要小得多。
如果你试图动态加载 C++ 代码,你就要面对另外一个问题:在 Linux 或者 OS X 平台,没有直接的方法加载和实例化 C++ 类(Windows 平台下 Visual C++ 支持)!这个问题的解决方案是,使用 C 风格的函数(避免 C++ 编译器的名称改写)作为工厂函数,返回一个不透明的句柄。调用者获取该句柄后,将其转换成适合的类(通常是纯虚基类)。当然,这要求一些额外的操作,同时,也要求编译器在编译库和应用程序时,需要在内存中建立一致的虚表。
终极解决方案是,忘记 C++,完全使用 C API。所有的 C 编译器都有一致的实现,也就是全部兼容。在后面的内容中,我们将讨论,如何在底层 C++ 代码之上建立 C 的兼容性。
基于插件的系统架构
一个基于插件的系统可以分成三部分:
- 特定领域系统,也就是业务相关的部分
- 插件管理器
- 插件
特定领域系统(主系统)通过插件管理器加载和创建插件对象。插件对象创建完成后,主系统就持有该对象的指针或者引用,就可以像其它对象一样使用该对象。通常,我们还需要执行一些特殊的销魂、清理工作。
插件管理器通常是一段通用代码。它用于管理插件的生命周期,将插件暴露给主系统。它能够发现、加载插件,执行初始化操作,注册工厂函数,也能够卸载插件。另外,它还应该能够允许主系统遍历已加载或者已注册的插件。
插件需要符合插件管理器的协议,为主系统提供所需要的对象。
在实际系统中,很少见到相对独立的清理工作(当然是在基于 C++ 的插件系统中)。插件管理器通常与特定领域系统绑定在一起。理由是,插件管理器需要提供特定类型的插件的实例。这些类型需要定义在主系统中。另外,插件的初始化操作一般需要主系统的特定信息,也可能需要回调某些函数或服务。这些操作都很难由完全独立的插件管理器去完成。
插件部署模型
插件通常以动态链接库的形式部署。动态链接库有很多好处,例如热切换(无需停止系统即可重新加载新的实现),由第三方开发者提供安全扩展(无需修改系统即可增加功能)和更短的链接时间。但是,也有一些情景是静态库更适合的。例如,有些系统根本不支持动态链接库(许多嵌入式系统都是这样的)。另外,基于安全原因,有些系统不允许加载外部代码。有时,核心系统需要预加载一些额外的插件,那么,使用静态链接的形式无疑更加健壮(这样的话,用户就不能随便删除这些文件了)。
最后,一个好的插件系统应当同时支持动态链接和静态链接的插件。这可以让你在不同的环境、不同的要求下使用同一套插件系统。