当前位置: 首页 > 工具软件 > pluma > 使用案例 >

pluma Tutorial

费子濯
2023-12-01

介绍

在开始之前需要先了解插件的概念以及pluma是如何工作的。

插件架构

插件架构允许应用程序根据需要在运行时加载/卸载功能模块。 这些模块(插件)可以由任何人开发并连接到主应用程序,而无需将所有内容编译或链接在一起。 您可能有兴趣开发一个核心应用程序并在以后添加功能。 编写新功能的人不必了解核心应用程序的工作原理,只需遵守连接协议,即插件接口。

  • 宿主应用程序可以使用满足相同连接接口的任何插件。

  • 任何具有合适接口的主机应用程序都可以使用插件程序来连接它。

主要概念

  • 宿主应用程序:在运行时加载插件并使它们工作。 为插件提供运行它们所必需的环境。 例如:鲨鱼(插件)只能在水中游泳(由主机应用程序提供)。

  • 插件:独立模块,实现公共共享接口。 例如:键盘是一种输入设备。

  • 连接接口:将插件连接到宿主应用程序的方式。 两者共享兼容的接口(通常是完全相同的接口)。 接口通常由宿主应用程序定义并分发给插件开发者。 但也可以由一个独立的插件定义并分发给有兴趣使用它的开发者。

pluma是如何工作的

C++ 中的插件都是面向对象的。 因此,Pluma 使用独特的类 C 函数将插件连接到主机。 该函数为主机提供对象工厂,其类型遵循协议接口。 这样我们就可以简单地忘记语言限制并使用面向对象的代码。 注意:我们称它为提供者而不是工厂(听起来不那么工业化,不是吗?)。

如果您还没有理解它,请不要担心。 我们将看到它与教程示例一起使用。

教程例子

让我们考虑一个关于阿兹特克战争的游戏。 游戏引擎(主机应用程序)将有一个名为 SimpleWarrior 的内置战士类。 让新的战士类型稍后添加并由玩家在运行时选择(插件)。 我们定义了一个 Warrior 接口,它将被宿主(uses)和插件(implements)共享。

然后我们将构建一个名为 elite-warriors 的插件,它将引入 Eagle 和 Jaguar 战士类型。 当插件连接到主机应用程序时,它为主机提供了一种创建此类战士的方法。 我们称之为供应商。 在这种情况下,插件会向主机添加一个 EagleProvider 和一个 JaguarProvider。

宿主现在可以从各自的提供者处创建美洲豹和鹰级战士。 忘记插件架构,随意使用勇士。

正如我们将看到的,提供者类可以用一行代码(宏)自动生成,所以我们只需要担心我们游戏的类,像往常一样编码,没有用于插件支持的交错代码。


共享接口

Warrior 接口是一个常见的 C++ 抽象类。 我们需要一个头文件和一个源文件,让它成为 Warrior.hpp 和 Warrior.cpp。 你可以把它放在任何你想要的地方,只要确保两个项目都可以访问它。

我为战士描述声明了一个纯虚函数,但请随意添加您想要的属性和功能(能量、护甲、攻击等)。

class Warrior{
public:
    virtual std::string getDescription() const = 0;
};

正如介绍中所述,宿主将能够通过战士提供者创建战士。 所以我们需要一个WarriorProvider 接口,允许宿主创建勇士。 幸运的是,Pluma 使用两个宏为我们生成了一个。 一个宏生成类头部分,另一个为它生成代码。 这就是为什么我们也需要一个 cpp 文件。

在 Warrior.hpp 文件中,我们在类声明后写入:

PLUMA_PROVIDER_HEADER(Warrior);

这会生成 WarriorProvider 类声明。

注意:宏末尾的分号是可选的。

不要忘记包含 Pluma 标头,否则宏将无法被识别。

Warrior.cpp 将只包含提供程序源:

#include "Warrior.hpp"
PLUMA_PROVIDER_SOURCE(Warrior, 1, 1);

宏 PLUMA_PROVIDER_SOURCE 的第一个参数是我们要提供的对象类型。 第二个参数是接口的当前版本,最后一个参数是最低兼容版本。 版本参数是无符号整数值。

每当我们修改共享接口时,我们应该增加它的版本。 但它可能不会破坏以前的版本。 例如,如果我们只添加一个具有默认行为的新函数(例如“virtual int attack(){ retunr 0; }”),旧插件仍然兼容并且可以加载。 但是如果我们将“getDescription”函数修改为“getName”,旧插件将无法兼容,所以我们必须修改最低兼容版本,以防止旧版本加载不正常。

小结

您已经创建了一个接口,该接口将由插件继承并由宿主使用。 您还生成了相应的提供程序类。 从现在开始,我们可以假设类 WarriorProvider 的存在。 在下一节我们将定义插件,这将介绍两种战士类型和相应的提供者。


插件

我们将从创建 Eagle 和 Jaguar 类开始。 然后我们编写一个连接函数,将相应的提供程序添加到主机。

实现一个共享接口

让我们从老鹰开始吧。 它继承自Warrior,所以首先包含它。

#include "Warrior.hpp"

不要忘记告诉编译器 Warrior 标头的位置,以便它可以找到它。

Eagle 类只是另一个常见的 C++ 类。

class Eagle: public Warrior{
public:
    std::string getDescription(){
        return "Eagle Warrior: soldier of the sun";
    }
};

插件会给宿主添加一个鹰战士的提供者,所以我们需要声明一个EagleProvider类,它本身就是一个WarriorProvider。 Pluma 为我们做了一个宏:

PLUMA_INHERIT_PROVIDER(Type, BaseType)

其中Type继承自BaseType,存在一个类BaseTypeProvider(即BaseType是共享接口)。 在我们的示例中,在 Eagle 声明之后我们放置:

PLUMA_INHERIT_PROVIDER(Eagle, Warrior);

要使用此宏,我们需要包含 Pluma 头文件:

#include <Pluma/Pluma.hpp>

此时我们有一个 Eagle 类和一个 EagleProvider 类。

对 Jaguar 类进行相同的处理。

连接函数

插件通过连接函数连接到宿主应用程序。 在 Windows 中,connect 函数是“特殊的”(C 导出函数),我们将为它使用一个优雅的宏。 连接器宏在 Connector.hpp 头文件中定义。

#include <Pluma/Connector.hpp>
 
PLUMA_CONNECTOR
bool connect(pluma::Host& host){
    ...
}

其他系统并不真正需要该宏,但最好使用它以获得更好的可移植性(您可能希望稍后将您的应用程序移植到 Windows)。 注意:不要在这个宏后面放分号,它是连接函数签名的一部分。

host 参数有这个有用的功能:

bool Host::add(Provider* provider);

传递的提供者指针将由主机管理,包括它的删除,因此在通过添加函数调用传递提供者后永远不要删除它。

让我们添加一些提供者:

host.add( new EagleProvider() );
host.add( new JaguarProvider() );

最后我们返回 true 告诉我们一切顺利。

不要忘记包含 Eagle 和 Jaguar 标头。

构建你的插件。

测试兼容性和取消连接(高级)

您可能会问那些 bool 回报是什么? 如果主机不接受您尝试添加的提供程序类型,或者插件和主机使用的共享接口版本不兼容,则 add 函数返回 false。 通常插件不必担心这一点,但是您可能想要例如测试 add 函数的结果以添加您在插件上拥有的其他内容。 注意:即使 add 返回 false,传递的提供者指针也总是被删除。

最后,如果您在 connect 函数上返回 false,它会告诉主机取消您可能添加的所有内容。 在以下示例中,如果提供程序失败,则所有成功添加的提供程序都将被取消。

小结

因此,一个插件包含一个或多个共享接口的实现,以及一个连接函数,为宿主提供类的提供者。 在下一节中,我们将在主机应用程序中使用我们的插件。


主机端

现在让我们在主机端做一个简单的主要功能。 我们需要包含 Pluma 头文件。

让我们首先声明一个 Pluma 类型的对象(不要忘记它在 pluma 命名空间中):

pluma::Pluma manager;

我们只想接受 WarriorProvider 类型的提供者。 插件尝试注册的其他类型的提供者将被拒绝。 我们这样做:

manager.acceptProviderType< WarriorProvider >();

可能看起来很奇怪,但你不能只将类的类型作为函数参数传递。 这就是我们改用模板参数的原因。

要加载插件,我们使用 load 函数:

manager.load("plugins", "elite-warriors");

第一个参数是搜索我们的插件的文件夹。 它可以是全局路径或相对于程序工作目录的路径。 第二个参数是不带扩展名的插件文件名。

或者,您可以从文件夹加载所有插件:

pluma.loadFromFolder("plugins", true);

第二个参数是可选的。 如果为真,它将递归地从子文件夹加载库。

现在我们可以访问加载的提供程序。 我们将把它们放在 WarriorProvider 指针的向量中。

std::vector<WarriorProvider*> providers;
manager.getProviders(providers);

如果我们有其他类型的提供者,我们可以使用相应类型的向量获取它们,getProviders 总是用给定向量类型的所有提供者填充向量。 此外,此函数从不清理向量,它会将提供程序附加到它的末尾(例如,对于向量中已经有一些本地提供程序的情况)。

最后使用提供者来创建战士。 在我们的示例中,让我们创建每种类型的战士并打印它的描述:

std::vector<WarriorProvider*>::iterator it;
for (it = providers.begin() ; it != providers.end() ; ++it){
    Warrior* warrior = (*it)->create();
    std::cout << warrior->getDescription() << std::endl;
    delete warrior;
}

由于多态性,你不能在这里去掉指针。

现在应该可以编译了。 如果插件路径正确,程序应该会显示 Eagle 和 Jaguar 的描述。

小结

就这样。 我们最后没有卸载插件,因为它会在管理器作用域结束时自动卸载。 当您使用从它的提供者创建的对象时,请确保您的管理器永远不会被销毁。 当管理器被销毁时,应用程序将失去对插件代码的访问权限。

最后请注意,创建函数没有参数。 插件类必须只提供默认构造函数,它在创建函数时使用。 不能使用带参数的构造函数。 如果你需要用一些数据初始化你的对象,推荐的方法是在共享接口上定义一些初始化函数,并带有所需的参数。 在主机端,对象创建后立即调用该函数。 例如:

Warrior* warrior = providers[0].create();
warrior->init(team, weapons);

但是,如果您真的想使用其他构造函数怎么办? 如果您将 Pluma 与现有项目集成,是否必须转换 init 函数中的每个构造函数? 答案是不需要。 在下一节你将了解如何操作。


最后的笔记

您已经通过一个虚拟示例了解了 Pluma 的基本用法。 本节为开发更有趣的程序提供了一些额外的注释和提示。

内置模块

在介绍中我们说我们的程序将有一个内置的战士。 这真的很简单,我们创建 SimpleWarrior 类就像我们创建 Eagle 和 Jaguar 一样,但是这个类包含在主机应用程序中。 在我们的主文件中,我们包含它的标头并向我们的 pluma 对象添加一个新的 SimpleWarriorProvider:

pluma::Pluma manager;
manager.acceptProviderType<WarriorProvider>();
 
manager.addProvider( new SimpleWarriorProvider() );
 
manager.load("plugins", "elite-warriors");

代码集成和组织提示

如果您想将现有项目拆分为核心系统和可插入模块,那可能是因为您已经在编写模块化 C++ 代码。 您可能正在使用接口(抽象类)来不依赖于特定的实现,您肯定正在使用多态性的优势。 如果不是,那么您真的必须在继续之前进行一些修改。

所以在这一点上,我假设您在项目中的某处有几个接口,您希望将它们转换为共享插件接口。 正如您之前看到的那样,它是由两个简单的宏组成的,但您不必在每个类上都编写它们(特别是如果由于某种原因您甚至没有访问或权限修改这些文件,例如,如果这些类是来自一些外部图书馆)。您可以保留所有接口不变,只需创建一个新的头文件和源文件,其中包含您想要共享的所有接口并生成相应的提供程序:

MyPublicInterface.hpp:

#ifndef MY_PUBLIC_INTERFACE
#define MY_PUBLIC_INTERFACE
 
#include "Interface1.hpp"
#include "Interface2.hpp"
#include "Interface3.hpp"
(...)
 
PLUMA_PROVIDER_HEADER(Interface1);
PLUMA_PROVIDER_HEADER(Interface2);
PLUMA_PROVIDER_HEADER(Interface3);
(...)
#endif

MyPublicInterface.cpp:

#include MyPublicInterface.hpp
PLUMA_PROVIDER_SOURCE(Interface1, 3, 1);
PLUMA_PROVIDER_SOURCE(Interface2, 4, 1);
PLUMA_PROVIDER_SOURCE(Interface3, 3, 2);
(...)

可以对插件端执行类似的过程。 您有几个将用作插件的类。 这些类实现了已经共享的接口。 不要碰那些文件,只需在放置宏的地方创建一个头文件。 为连接器函数创建一个 cpp 文件并包含该头文件。

很简单,不是吗? 您将插件相关代码与您的应用程序代码完美分离。

至于主机应用程序,简单的应用程序可以直接使用提供者向量,但您可能对更复杂的东西感兴趣。 创建自己的管理器类是个好主意。 它有一个 Pluma 对象来加载/卸载库,以及根据应用程序环境/需求选择使用哪个提供者的某种方式。 通过这种方式,您可以将库管理和对象创建封装在一个黑盒子中。

自定义供应者(高级)

默认提供者有一个函数“create”,它调用类的默认构造函数。 如果您不喜欢这种行为,您可以定义自己的行为。 除了宏 PLUMA_PROVIDER_HEADER 之外,还有另外两个用于相同目的的宏,但它们没有定义创建函数:

PLUMA_PROVIDER_HEADER_BEGIN(TYPE)
    // your interface body here
PLUMA_PROVIDER_HEADER_END

在这两个宏之间,您定义了一个普通类的主体。 这是一个例子:

// class WarriorProvider
PLUMA_PROVIDER_HEADER_BEGIN(Warrior)
    public:
        virtual Warrior* create(int energy) = 0;
        virtual void populate(std::vector<Warrior*>& army, int numSoldiers) = 0;
PLUMA_PROVIDER_HEADER_END

包含对 PLUMA_PROVIDER_SOURCE 的调用的 cpp 文件保持不变。

由于新行为,在插件端(或内置)您不能使用 PLUMA_INHERIT_PROVIDER 宏。 这不是问题,只需手动继承提供者,如本例所示:

class EagleProvider: public WarriorProvider{
public:
    Warrior* create(int energy){
        return new Eagle(energy);
    }
 
    void populate(std::vector<Warrior*>& army, int numSoldiers){
        (...)
    }
 
};

在主机端,您现在可以使用这些功能:

pluma::Pluma pluma;
pluma.acceptProviderType<WarriorProvider>();
pluma.load("plugins", "elite-warriors");
std::vector<WarriorProvider*> providers;
pluma.getProviders(providers);
(...)
 
providers[0].populate( myArmy, 15 );

小结

在学习了 Pluma 框架的基本用法之后,您对实际程序有了 Pluma 的想法。

例如,当插件不存在时,内置模块可以用作默认提供程序,因此您不必在使用插件时和不使用插件时进行不同的编码。

与插件架构相关的代码可以隔离,您的应用程序代码不必依赖插件特定的限制。

本教程到此结束,希望您觉得它有用;)

原文链接

 类似资料:

相关阅读

相关文章

相关问答