插件系统 - GetProcAddress 和接口的替代方案






4.85/5 (60投票s)
一种强大且可扩展的创建基于插件的应用程序的方法。
引言
插件是扩展应用程序的常用方法。它们通常实现为DLL。宿主应用程序通过查找(通过查找预定义文件夹,或通过某种注册表设置或配置文件)来定位插件,然后逐个使用LoadLibrary
加载它们。然后,插件被集成到宿主应用程序中,从而用新功能扩展它。
本文将展示如何创建一个带有多个插件DLL的宿主EXE。我们将看到如何将宿主中的任何类、函数和数据无缝地作为API暴露给插件。在此过程中,我们将解决一些技术挑战。
我们将使用一个简单的例子。宿主应用程序host.exe是一个图像查看器。它实现了一个插件框架,用于添加对不同图像文件格式的支持(在此示例中为24位BMP和24位TGA)。插件将是DLL,并且扩展名为.IMP(**IM**age **P**arser),以将它们与常规DLL区分开。但请注意,本文是关于插件的,而不是关于图像解析的。提供的解析器非常基础,仅用于演示目的。
有许多文章描述了如何实现简单的插件框架。例如,请参阅[1]、[2]。它们通常侧重于两种方法:
- 插件实现一组标准(通常很小)的函数。宿主知道函数的名称,并可以使用
GetProcAddress
找到地址。这扩展性不好。随着函数数量的增长,维护变得越来越困难。如果您必须手动按名称绑定每个函数,则能做的非常有限。 - 使用
GetProcAddress
返回的函数,用于将接口指针传递给插件或从插件获取接口指针。其余的宿主和插件之间的通信通过该接口完成。操作方法如下:
接口方式
接口是基类,其中所有成员函数都是公共的纯虚函数,并且没有数据成员。例如:
// IImageParser is the interface that all image parsers
// must implement
class IImageParser
{
public:
// parses the image file and reads it into a HBITMAP
virtual HBITMAP ParseFile( const char *fname )=0;
// returns true if the file type is supported
virtual bool SupportsType( const char *type ) const=0;
};
实际的图像解析器继承自接口类并实现纯虚函数。BMP插件可能如下所示:
// CBMPParser implements the IImageParser interface
class CBMPParser: public IImageParser
{
public:
virtual HBITMAP ParseFile( const char *fname );
virtual bool SupportsType( const char *type ) const;
private:
HBITMAP CreateBitmap( int width, int height, void **data );
};
static CBMPParser g_BMPParser;
// The host calls this function to get access to the
// image parser
extern "C" __declspec(dllexport) IImageParser *GetParser( void )
{
return &g_BMPParser;
}
宿主将使用LoadLibrary
加载BmpParser.imp,然后使用GetProcAddress("GetParser")
找到GetParser
函数的地址,然后调用它以获取IImageParser
指针。
宿主维护所有已注册解析器的列表。它将GetParser
返回的指针添加到该列表中。
当宿主需要解析BMP文件时,它将为每个解析器调用SupportsType(".BMP")
。如果SupportsType
返回true
,则宿主将使用完整的文件名调用ParseFile
并绘制HBITMAP
。
有关完整源代码,请参阅下载文件中的Interface文件夹。
基类不必严格是纯接口。技术上,这里的约束是所有成员都必须通过对象的指针可访问。因此,您可以拥有:
- 纯虚成员函数(它们通过虚函数表间接访问)
- 数据成员(它们直接通过对象的指针访问)
- 内联成员函数(它们在技术上不是通过指针访问的,但其代码会在插件中被二次实例化)
- 这就剩下非内联成员函数和静态成员函数了。插件无法从宿主访问此类函数,宿主也无法从插件访问此类函数。不幸的是,在一个大型应用程序中,此类函数可能占代码的大部分。
例如,所有图像解析器都需要CreateBitmap
函数。将其声明在基类中并在宿主端实现是有意义的。否则,每个解析器DLL都会有一个该函数的副本。
这种方法的另一个限制是,您无法将任何全局数据或全局函数从宿主公开给插件。
那么,我们如何改进这一点呢?
将宿主分成DLL和EXE
看看USER32模块。它有两个部分——user32.dll和user32.lib。实际的代码和数据在DLL中,而LIB仅提供调用DLL的占位符函数。最好的是,这一切都是自动发生的。您链接user32.lib,即可自动获得user32.dll中所有功能的访问权限。
MFC更进一步——它公开了您可以直接使用或继承的整个类。它们没有我们上面讨论过的纯接口类的限制。
我们可以做同样的事情。您想提供给插件的任何基本功能都可以放在一个DLL中。使用/IMPLIB
链接器选项来创建相应的LIB文件。然后,插件可以链接该库,并且所有导出的功能都将可用于它们。您可以根据需要任意拆分DLL和EXE之间的代码。在源代码中显示的极端情况下,EXE仅包含一个单行的WinMain函数,其唯一作用是启动DLL。
您希望导出的任何全局数据、函数、类或成员函数,在编译DLL时都必须标记为__declspec(dllexport)
,在编译插件时标记为__declspec(dllimport)
。一个常见的技巧是使用宏:
#ifdef COMPILE_HOST
// when the host is compiling
#define HOSTAPI __declspec(dllexport)
#else
// when the plugins are compiling
#define HOSTAPI __declspec(dllimport)
#endif
将COMPILE_HOST
添加到DLL项目的定义中,但不要添加到插件项目中。
在宿主DLL方面:
// CImageParser is the base class that all image parsers
// must inherit
class CImageParser
{
public:
// adds the parser to the parsers list
HOSTAPI CImageParser( void );
// parses the image file and reads it into a HBITMAP
virtual HBITMAP ParseFile( const char *fname )=0;
// returns true if the file type is supported
virtual bool SupportsType( const char *type ) const=0;
protected:
HOSTAPI HBITMAP CreateBitmap( int width, int height,
void **data );
};
现在,基类不再受限于仅作为接口。我们可以在其中添加更多的基本功能。CreateBitmap
将由所有解析器共享。
这次,而不是宿主调用一个函数来获取解析器并将其添加到列表中,这部分由CImageParser
的构造函数接管。当创建解析器对象时,其构造函数将自动更新列表。宿主不再需要使用GetProcAddress
来查看每个DLL中包含哪个解析器。
在插件方面:
// CBMPParser inherits from CImageParser
class CBMPParser: public CImageParser
{
public:
virtual HBITMAP ParseFile( const char *fname );
virtual bool SupportsType( const char *type ) const;
};
static CBMPParser g_BMPParser;
当创建g_BMPParser
时,将调用其构造函数CBMPParser()
。该构造函数(在插件端实现)将调用基类CImageParser()
的构造函数(在宿主端实现)。这是可能的,因为基类构造函数已标记为HOSTAPI
。
有关完整源代码,请参阅下载文件中的DLL+EXE文件夹。
等等,还有更好的
合并宿主DLL和宿主EXE
通常,仅在创建DLL时才会创建导入库。这是一个鲜为人知的技巧,即使对于EXE也可以创建导入库。在Visual C++ 6中,/IMPLIB
选项不能直接用于EXE,就像用于DLL一样。您必须手动将其添加到链接器属性底部的编辑框中。在Visual Studio 2003中,它可以在Linker\Advanced部分找到,您只需将其值设置为$(IntDir)/Host.lib
。
这样一来,您就拥有了一个宿主EXE,多个插件DLL,并且可以与所有插件共享宿主中的任何函数、类或全局数据。完全不需要使用GetProcAddress
,因为插件可以自行注册到宿主的数据结构中。
有关完整源代码,请参阅下载文件中的EXE文件夹。
DEF文件
随着宿主应用程序越来越大,您将希望将其拆分成单独的静态库。然后您就会遇到问题。
假设CImageParser
的构造函数位于其中一个库中,而不是主项目中。主项目中没有代码引用该函数(显然,只有插件才需要在它们自己的构造函数中调用它)。链接器很智能,它会认为这种函数没有用处,并将其从EXE中移除。
那么,您如何欺骗链接器将构造函数添加到EXE中呢?这是DEF文件的绝佳用途。DEF文件是一个文本文件,列出了DLL或EXE将导出的所有符号。链接器将被强制将它们包含在输出中,即使没有代码引用它们。DEF文件可能如下所示:
EXPORTS
// the C++ decorated name for the CImageParser constructor
??0CImageParser@@QAE@XZ
// the C++ decorated name for CImageParser::CreateBitmap
?CreateBitmap@CImageParser@@IAEPAUHBITMAP__@@HHPAPAX@Z
要在VC6中将DEF文件提供给链接器,您必须手动将选项/DEF:<filename>
添加到命令行。在VS2003中,您可以在Linker\Input部分执行此操作。
如何创建DEF文件?您可以手动创建,列出要导出的所有符号,也可以自动创建:
defmaker – 自动创建DEF文件
defmaker是一个简单的工具,它扫描LIB文件,查找库导出的所有符号,并将它们添加到DEF文件中。
// defmaker - creates a DEF file from a list of libraries.
// The output DEF file will contain all _declspec(dllexport)
// symbols from the libraries.
// /def:<def file> must be added to the linker options
// for the DLL/EXE.
//
// Parameters:
// defmaker <output.def> <library1.lib> <library2.lib> ...
//
// Part of the Plugin System tutorial
//
/////////////////////////////////////////////////////////////
#pragma warning( disable: 4786 ) // Identifier was truncated
// to 255 characters in the debug info
#include <stdio.h>
#include <windows.h>
#include <string>
#include <set>
#include <Dbghelp.h>
struct StrNCmp
{
bool operator()(const std::string &s1,
const std::string &s2) const
{
return stricmp(s1.c_str(),s2.c_str())<0;
}
};
std::set<std::string,StrNCmp> g_Names;
static const char *EXPORT_TAG[]=
{
"/EXPORT:", // VC6 SP5, VC7.1, VC8.0
"-export:", // VC6 SP6
};
static bool CmpTag( const char *data )
{
for (int i=0;i<sizeof(EXPORT_TAG)/
sizeof(EXPORT_TAG[0]);i++)
if (strnicmp(EXPORT_TAG[i],data,
strlen(EXPORT_TAG[i]))==0)
return true;
return false;
}
static bool ParseLIB( const char *fname )
{
int len=strlen(EXPORT_TAG[0]);
bool err=true;
// create a memory mapping of the LIB file
HANDLE hFile=CreateFile(fname,GENERIC_READ,
FILE_SHARE_READ,NULL,OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL|FILE_FLAG_RANDOM_ACCESS,0);
if (hFile!=INVALID_HANDLE_VALUE) {
HANDLE hFileMap=CreateFileMapping(hFile,NULL,
PAGE_READONLY,0,0,0);
if (hFileMap!=INVALID_HANDLE_VALUE) {
const char *data=
(const char *)MapViewOfFile(hFileMap,
FILE_MAP_READ,0,0,0);
if (data) {
err=false;
// search for the EXPORT_TAG and
// extract the symbols
int size=GetFileSize(hFile,NULL);
for (int i=0;i<size-len;i++)
if (CmpTag(data+i)) {
i+=len;
const char *text=data+i;
while (data[i]!=0 && data[i]!=' '
&& data[i]!='/' && i<size)
i++;
std::string name(text,data+i);
// add the symbols to a sorted set
g_Names.insert(name);
}
UnmapViewOfFile(data);
}
CloseHandle(hFileMap);
}
CloseHandle(hFile);
}
return !err;
}
int main( int argc, char *argv[] )
{
if (argc<3) {
printf("defmaker: Not enough command line parameters.\n");
printf("Usage: defmaker <def file> <libfiles>\n");
return 1;
}
for (int i=2;i<argc;i++) {
printf("!defmaker: Parsing library %s.\n",argv[i]);
if (!ParseLIB(argv[i])) {
printf("defmaker: Failed to parse library %s.\n",
argv[i]);
return 1;
}
}
FILE *def=fopen(argv[1],"wt");
if (!def) {
printf("defmaker: Failed to open %s for writing.\n",
argv[1]);
return 1;
}
fprintf(def,"EXPORTS\n");
for (std::set<std::string,StrNCmp>::iterator it=
g_Names.begin();it!=g_Names.end();++it) {
std::string name=*it;
int len=name.size();
if (len>5 && name[len-5]==',')
name[len-5]=' '; // converts ",DATA" to " DATA"
fprintf(def,"\t%s\n",name.c_str());
}
fclose(def);
printf("defmaker: File %s was created successfully.\n",
argv[1]);
return 0;
}
使用方法如下:
defmaker <output.def> <library1.lib> <library2.lib> ...
对于我们的示例,命令行是:
defmaker "$(IntDir)\host.def" "ImageParser\$(IntDir)\ImageParser.lib"
在VC6中,您将其添加到链接器选项的Pre-link step选项卡中。在VS2003中,您可以在项目选项的Build Events\Pre-Link Event中执行此操作。它将在链接步骤之前执行。Defmaker将生成host.def文件,然后由链接器使用。
Defmaker通过搜索LIB文件中的"/EXPORT:"
标签来定位符号。(注意:出于某种未知原因,仅在VC6 service pack 6中,标签更改为"-export:"
,因此defmaker会同时搜索两者)。紧跟在标签后面的是符号的修饰C++名称。如果符号引用的是数据而不是代码,则后面会跟着文本",DATA"。DEF文件格式要求数据符号标记为"<space>DATA"。Defmaker会进行转换。也许最好是解析LIB文件,遵循官方文件格式规范,但我发现搜索标签是100%成功的。
defmaker的另一个用途与插件或DLL无关。有时您需要强制链接器包含一个全局对象,即使没有对其进行引用。一个常见的例子是工厂系统,其中每个工厂都是一个全局对象,它将自己注册到一个列表中(就像上面的CImageParser
那样)。但是,如果您的工厂对象位于静态库中而不是主项目中,链接器可能会决定将其删除。使用defmaker,您可以将该对象标记为__declspec(dllexport)
,它将被添加到EXE文件中。
提示:将defmaker.exe的路径添加到Visual Studio的Executable files设置中。您可以在任何项目中对其进行使用。
结论
我们在这里看到了如何创建一个不依赖于GetProcAddress
或试图通过接口挤压所有功能的插件系统。要将宿主中的任何符号暴露给插件,只需将其标记为HOSTAPI
。其余的都是自动的。您可以直接控制要导出哪些符号,哪些不导出。
您编写插件中的代码将和编写单体应用程序或静态库中的代码一样容易。无论您是编写插件还是简单的应用程序,都可以访问基类、全局函数和全局数据。仍然建议在宿主功能和插件功能之间进行清晰的划分,但这应该基于您自己的架构,而不是由技术限制决定。
需要提醒的是——能力越大,责任越大。您有权与插件共享宿主内部的任意数量的组件。一个设计良好的插件框架的关键,正如任何设计一样,是找到平衡——在这种情况下,提供一个简单而强大的API。您需要导出足够的功能来帮助插件开发人员,但也要隐藏那些可能在未来版本中更改或不必要地损害宿主稳定性的特性。
源代码
源zip文件包含四个文件夹:
- Interface - 使用
GetProcAddress
和接口的插件系统。 - DLL+EXE - 使用单独的EXE和DLL作为宿主的插件系统。
- EXE - 使用单个EXE作为宿主的最终插件系统。
- defmaker - defmaker工具的源代码。
defmaker.exe
的可执行版本包含在根文件夹中。编译宿主EXE版本需要它。
源代码包含Visual C++ 6和Visual Studio 2003的项目文件。对于Visual Studio 2005,您可以打开任一项目并将其转换为最新格式。
链接
[1] Mohit Khanna 的 使用DLL的插件框架
[2] thomas_tom99 的 基于ATL COM的Addin/插件框架,具有动态工具栏和菜单