首页 插件 自己动手写插件框架(7)

自己动手写插件框架(7)

0 1.6K

本章节将开始讨论如何开发跨平台的 C++ 插件。

跨平台开发

C/C++ 的跨平台开发相当复杂:数据类型不同,编译器不同,操作系统 API 也不同。跨平台开发关键是封装平台差异性,让你的应用程序仅关注于业务逻辑。如果应用程序使用了与平台相关的代码,我们就需要添加很多#ifdef OS_THIS或者#ifdef OS_THAT这样的宏。这是你必须注意的。解决跨平台问题的一个最佳实践是,将平台相关代码分隔到不同的库中去。这么做的好处是,如果你需要支持全新的平台,你只需修改这个平台的支持库即可。

了解目标平台

开发多平台系统,首先主要了解和注意平台之间的差异。如果你的目标平台分别是 32 位和 64 位系统,那么就应当明白其中的限制。如果你的目标平台是 Windows,你需要了解 ANSI/MBCS 和 Unicode/Wide 字符串的区别。如果你的目标平台是移植了操作系统的移动设备,你需要了解该系统的哪些子集可用。

选择良好的跨平台库

下一步,你需要选择一个良好的跨平台库。选择有很多优秀的库。大多数关注于 UI。这里,我们为我们的插件框架选择了 Apache Portable Runtime (APR)。APR 是 Apache 的 web 服务器,subversion 服务器以及其他项目的基础类库。

但是,或许 APR 并不适合你。尽管它有完善的文档,但是它的文档并不清晰;而且它不是一个著名的大型社区;也没有教程;只有相对较少的项目使用。另外,这是一个 C 库,你或许不喜欢它的命名风格。但是,它易于移植并且健壮(至少它是 Apache 和 Subversion 的一部分),并且可以用于实现高性能系统。

考虑编写一个包装类,实现自己的跨平台库(只封装需要的部分)。这么做有很多好处:

  • 你可以修改接口以满足自己的需要
  • 符合程序中其它代码的命名
  • 方便修改到其它的库,或者是升级底层库的版本

当然,这种做法也有许多不足:

  • 需要花费时间和资源编写、维护包装器
  • 调试会有些困难,因为你需要浏览另外的底层(更不要说你的包装类也会有 bug)

这里,我们选择 APR,因为它是一个 C 库,需要你显式释放资源,为优化内存分配处理内存池,并且我也不喜欢名称变换。尽管只使用一个相对较小的子集(只使用目录和文件 API),但是依然值得我们花费时间去做这个工作,并且测试。我们可以在插件框架的子目录中找到 Path 和 Directory 类。注意,我们使用了基本类型的 APR 定义的 typedef,而没有自己定义。这只是因为我懒,并不值得推荐。你可以从结构以及接口中找到 APR 的类型信息。

数据类型的差异

C++ 类型继承自 C 是一个跨平台的潜在威胁。int、long 和 friend 在不同的平台上(现在的 32 位和 64 位系统,以及以后的 128 位系统)会有不同的大小。对于一些应用程序而言,这些都不是问题,因为它们不会涉及到 32 位的限制(如果是使用无符号整数,则是 31 位),但是如果你需要在 64 位系统序列化你的对象,又要在 32 位系统上反序列化,那么你就得注意这个问题了。并且,没有什么简单办法避免这个问题。你必须了解这个问题,才能在发送(保存)以及接收(加载)的时候提防每个值的字节数,以便做出正确的处理。文件格式或者网络协议都得注意这个问题。

APR 为在不同平台上可能会有不同的基本类型提供了一系列 typedef。这些 typedef 提供了恰当的大小,避免了类似的问题。然而,对于有些系统(大多数数值相关的应用),的确有必要使用原生的机器字长(典型的就是int的字长)来获得最大化的性能。

在跨平台包装类中包装平台相关的组件

有时,你必须编写一大堆平台相关的代码。例如,APR 所支持的动态库并不能满足插件框架的需要。我们简单地实现了DynamicLibrary 类,使用统一的接口抽象跨平台的动态库的概念。我们的实现时常会有使用体验或者性能的损失。你必须自己权衡,找出自己需要什么。在我们的例子中,DynamicLibrary 是一个最小接口,不允许应用程序给 Unix 上的 dlopen()指定任何标记位;在 Windows 平台上,也只是使用了LoadLibrary() 函数,而不是更加灵活的LoadLibraryEx()

组织第三方依赖库

现在,我们经常使用第三方代码,而且也已经有很多优秀的第三方库可以使用。你的项目很可能要使用一些。在跨平台的环境中,选择合适的第三方库也是相当重要的。选择第三方库一般需要注意健壮性、性能、使用方便、文档支持良好等,还应当注意这个库的开发以及维护方式。有些库在一开始就没有注意跨平台的问题。很多时候,我们会看到有些库在一开始时为单一平台开发,后来才被移植到其他平台。如果基础代码并不统一,这就是一个红灯信号。如果在特定平台只有很少用户在使用,那么这也算一个红灯信号。如果核心开发者或维护者没有为所有平台提供安装包,也是一个红灯信号。如果新版本推出,但是某些平台有滞后,也是亮起红灯。“红灯”并不意味着你不能使用这个库,而是如果存在相同功能的没有红灯信号的库的时候,你应当优先选择那些库。

深刻理解构建系统

一个良好的自动构建系统对于开发严肃的软件系统是很重要的。如果你的程序支持多种版本(标准版、专业版、企业版),多个平台(Windows、Linux、OS X),多种编译选项(debug、release),你就得注意这个问题。构建系统必须足够自动化,并且支持整个构建生命周期——从源代码控制系统获取源代码、做预处理、编译、链接、运行单元测试、集成测试、发布,可能还会有完整的系统测试、用户反馈报告等。

由于构建系统多而杂,你必须对你自己的构建系统有深刻的理解。

平台服务

平台服务是由你的系统提供给插件的服务。之所以叫做“平台服务”,是因为一般的插件框架都会为基于插件的系统提供一个服务平台。PF_PlatformServices 结构包含了版本号、registerObject 和invokeService 函数指针。代码如下所示:

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;

版本号让插件知道PluginManager 的版本。这就允许插件能够根据版本不同而注册不同的对象。

registerObject() 函数是PluginManager 的一个服务,用于插件注册其对象(没有它也就没有插件系统)。

invokeService是应用程序提供的特定服务。应用程序和插件(插件对象创建之后)的交互由应用程序调用插件的对象模型接口(例如IActor::play())完成。但是,插件有时也需要调用应用程序的服务,比如应用程序提供的对象日志执行环境的管理,错误报告以及程序级别的内存分配以便其它对象使用等。这些服务通常是单例,或者 static 函数。动态插件不能直接访问。不同于 registerObject 服务,应用程序相关的服务不能在PF_PlatformServices 结构中定义,因为它们不应该是通用插件框架所应该知道的,不同的应用程序提供的这类服务也不相同。应用程序可以将这些服务的访问点封装到一个很大的结构中,然后通过插件的对象模型的接口函数(initObject())将其传递给各个插件。这种实现对于 C 插件有些不便。这些服务对象更适合使用 C++ 实现,将返回的 C++ 对象当做参数,也可以将其作为模板,也可以抛除异常。当然,我们也可以为每一个插件提供 C 兼容的包装器。但是,使用单一的类来处理插件的交互更为合适。

这就是invokeService()的作用。它的签名很简单——一个服务名的字符串和指向任意结构的 void 指针。这是一个弱类型的接口,但是足够灵活。插件和应用程序必须约定有哪些服务,需要怎样的参数。下面的代码展示了日志服务,其参数是文件名、行号和日志内容。

LogServiceParams.h
==================
typedef struct LogServiceParams
{
    const apr_byte_t * filename;
    apr_uint32_t       line;
    const apr_byte_t * message;
} LogServiceParams;

Some Application File...
========================

#include "LogServiceParams.h"

apr_int32_t InvokeService(const apr_byte_t * serviceName,
                          void * serviceParams)
{
    if (::strcmp(serviceName, "log") == 0)
    {
        LogServiceParams * lsp = (LogServiceParams *)serviceParams;
        Logger::log(lsp->filename, lsp->line, lsp->message);
    }
}

LogServiceParams 结构在插件和应用程序都需要 include 的头文件中定义。该结构提供了插件和应用程序之间的日志服务的协议。插件将当前文件名、行号和日志信息包装进一个结构,将其指针传递给 invokeService() 函数,“log” 作为服务名。应用程序端 invokeService() 函数的实现是,获取作为 void 指针传来的LogServiceParams结构,然后调用 Logger::log()函数。如果插件没有发送正确的LogServiceParams 结构,则其行为是未定义的(当然,这是很坏的结果)。invokeService() 可以用于处理多次请求,并且失败的话则返回 -1。如果应用程序需要返回给插件执行结果,则需要在服务的 params结构添加输出参数。每个服务都应当有自己的 params结构。

例如,如果应用程序需要控制内存分配(它提供了自定义的内存分配函数),就应当提供“allocate”和“deallocate”服务。当插件需要分配内存时,应当调用“allocate”服务,使用包含了请求分配内存空间的大小和用于输出的分配结果的void *指针的 AllocateServiceParams结构。

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2