编写、加载和存取插件程序(Plug-Ins)

2016-07-05

在 2005 年一月刊的 MSDN 杂志文章中,你有一个例子程序的代码是用混合模式编写的。有没有可能动态加载 .NET 类或 DLL 并调用那些函数呢?假设我有一个本机 C++ 应用程序,我想允许用户在 .NET 中为该 C++ 程序编写插件。就像在 .NET 中使用 LoadLibrary 加载 DLLs 一样。

Ravi Singh

我正在用 Visual C++ 6.0 编写一个插件应用,它是一个 DLL,输出和接收纯虚拟接口指针。加载 DLL 后,EXE 便调用 DLL 中输出的 C 函数,该函数返回一个纯虚拟接口指针。然后 EXE 调用该接口上的方法,有时会传回另一个接口指针给 DLL 处理。

目前有人要求必须用 C#,Visual Basic .NET 和其它语言编写插件。我没有什么基于 .NET 的编程经验,不懂托管和非托管代码之间的通讯问题,我找到许多有关这方面的信息,但是越看越糊涂。我如何才能让用户编写基于.NET 语言的插件? 

Daniel Godson

在 MSDN 杂志 2003 年 10 月刊中,有一篇 Jason Clark 写的一篇关于插件的文章,但我并不介意在此复习一下这个主题,尤其是因为插件本身就是 .NET 框架中举足轻重的部分(参见:Plug-Ins: Let Users Add Functionality to Your .NET Applications with Macros and Plug-Ins)。毕竟,微软 .NET 框架的主要目的之一就是为编写可重用的软件组件提供一种语言无关的系统。从第一个 “Hello,world”程序到现在,这已经成为软件 开发至高无上的准则。可重用性从拷贝/粘贴到子例程,再到静态链接库,再到 DLLs 以及更专业的 VBX,OCX 和 COM。虽然最后三个东西属于不同的主题(它们都是 本机 DLLs),.NET 框架标志着一个真正的开端,因为所有代码都被编译成微软中间语言(MSIL)。互用性成为一种不可或缺的成分,因为在公共语言运行时层面,所有代码都一样。这就使得编写支持语言中立的插件体系结构 的程序变得尤其容易。

那么在你的 C++ 程序中如何利用这个优势呢?Daniel 的虚拟函数指针系统就是一个手工自制的 COM。它就是 COM 对象本质之所在:纯虚拟函数指针。你可以为插件模型使用 COM ,开发人员可以用任何面向 .NET 的语言编写插件,因为这个框架让你创建和使用 COM 对象。但众所周知, COM 编码非常繁杂,因为它需要考虑的细节颇多,例如注册、引用计数,类型库等等――这些东西足以使你认为 COM 简直就是“Cumbersome Object Model”(麻烦对象模型)。如果你正在编写新代码并试图简化你的日常工作,那么就用 .NET 直接实现一个插件模型吧,我现在就是在讨论这个话题。

首先让我回答 Ray 的问题,即:在 .NET 中有没有类似 LoadLibrary 的东西,答案是:有,你可以用静态方法 System::Assembly::Load 加载任何框架程序集(就是一个包含 .NET 类的 DLL)。此外,.NET 支持反射机制。每个程序集都提供所有你需要的信息,如:该程序集有什么类,什么方法以及何种接口。不需要关心 GUIDs,注册,引用计数等诸如此类的事 情。

在我展示更一般的插件系统之前,我将从一个简单的例子开始,Figure 1 是一个 C# 类,它提供一个静态函数 SayHello。注意与 C/C++ 不同,在 .NET 中函数不单独输出;每个函数必须属于某个类,虽然这个类可以为静态的,也就是说它不需要实例化。为了将 MyLib.cs 编译成一个库,可以这样做:

				Figure 1 MyLib.cs

using System;
using System.Reflection;
using System.Runtime.CompilerServices;

namespace MyLib
{
   // Class that exports a single static function
   public class MyClass
   {
      public MyClass(){}

      static public void SayHello(String who)
      {
         Console.WriteLine("Hello, world, from {0}", who);
      }
   }
}

csc /target:library MyLib.cs

编译器将产生一个名为 MyLib.dll 的 .NET 程序集。为了通过托管扩展从 C++ 中调用 SayHello,你得这样写:

#using < mscorlib.dll >
#using < MyLib.dll >
using namespace MyLib;
void main ()
{
    MyClass::SayHello("test1");
}

编译器链接到 MyLib.dll 并调用正确的入口点。这一切都简单明了,它属于 .NET 的基础。现在假设你不想在编译时链接 MyLib,而是想进行动态链接,就像在 C/C++ 用 LoadLibrary 那样。毕竟,插件无非是要在运行时链接,在你已经生成并交付的应用程序之后。Figure 2 所做的事情和前述代码段一样,只不过它是动态加载 MyLib 的。关键函数是 Assembly::Load。一旦你加载了该程序集,你便可以调用 Assembly::GetType 来获得有关类的 Type 信息(注意你必须提供全限定名字空间和类名),进而调用 Type::GetMethod 来获取有关方法的信息,甚至是调用它,就像这样:

				Figure 2 Loading MyLib Dynamically

using < mscorlib.dll >
#using < System.dll >
#include < vcclr.h >
#include < stdio.h >
#include < tchar.h >

using namespace System;
using namespace System::Reflection;

void main ()
{
   try {
      // Load library dynamically:
      Assembly* a = Assembly::Load("MyLib");
      if (a) {
         Console::WriteLine("Assembly = {0}", a);
         Type* t = a->GetType("MyLib.MyClass");
         if (t) {
            Console::WriteLine("Type     = {0}", t);
            MethodInfo* m = t->GetMethod("SayHello");
            if (m) {
               Console::WriteLine("Method   = {0}\n", m);
               String* args[] = {"Test2"};
               m->Invoke(NULL, args);
            } else {
               printf("Can't find SayHello!\n");
            }
         } else {
            printf("Can't find MyLib.MyClass!\n");
         }
      } else {
         printf("Can't load MyLib!\n");
      }
   } catch (Exception* e) {
      Console::WriteLine("*** Oops: Exception occurred: {0}", e);
   }
}

MethodInfo* m = ...; // get it
String* args[] = {"Test2"};
m->Invoke(NULL, args);

第一个参数是对象实例(此例中为 NULL,因为 SayHello 是静态的),第二个参数是 Object (对象)数组,明白了吗?

在继续往下讨论之前,我必须指出 Load 函数有几个,正是这一点很容易把我们搞糊涂。.NET 被设计用来解决的一个问题就是所谓的 DLL 地狱(DLL Hell)问题,当几个应用程序共享某个共公 DLL 并想要更新该 DLL 时常常会发生这个问题――它能使某些应用程序崩溃。而在 .NET 中,不同的应用程序可以加载同一个程序集/DLL的不同版本。不幸的是,DLL 地狱现在变成了 Load 地狱(Load Hell),因为加载程序集的规则是如此复杂,我都可以专门写一个专栏来描述它。

加载并将程序集邦定到你的程序的过程称为熔接(fusion),甚至框架带有专门的程序,fuslogyw.exe (Fusion Log Viewer)来做这件事情,你可以用它确定加载了那个程序集的哪个版本。正像我说过的,要完整地描述框架是如何加载并邦定程序集,以及它是如何定义“身份”(identity)的需要几页篇幅 才能说清楚。但对于插件来说,只需考虑两个函数:Assembly::Load 和 Assembly::LoadFrom。

Assembly::Load 的参数可以是完整的或部分的名称(例如,“MyLib”或者“MyLib Version=xxx”Culture=xxx”)。Figure 2 中的测试程序加载“MyLib”,然后显示完整的程序集名称,如 Figure 3 所示:

Figure 3 测试程序

Assembly::Load 使用框架的发现规则来决定实际加载了哪个文件。它在 GAC(全局程序集缓冲:Global Assembly Cache)里,你的程序给出的路径以及应用程序所在的目录以及诸如此类的路径中查找。

另一个函数 Assembly::LoadFrom 使你能从外部路径加载程序集。这里有一点模糊的是如果相同的程序集(由同一性规则确定)已经被从不同的路径加载,框架将会使用之。所以 LoadFrom 并不总是正确地使用通过该路径指定的程序集,尽管大多数时候能正确使用。晕了吧?还有另外一个方法是 Assembly::LoadFile,它总是加载请求的路径――但你几乎从来用不上 LoadFile,因为它解决不了依赖性问题,并且无法将程序集加载到正确的环境中(LoadFrom)。不用去了解所有的细节,我将对 LoadFrom 进行简单地讨论,以此说明对于简单的插件模型,它是一个很好用的函数。

这样一个模型的基本思路是定义一个接口,然后让其他人编写实现此接口的类。你的应用程序可以调用 Assembly::LoadFrom 来加载插件,并用反射来查找实现你所定义之接口的类。不过在你动手之前,有两个重要的问题要问:你的应用程序需要在运行中卸载或重新加载插件吗?你的程序需要 考虑对插件必须使用的文件或其它资源进行安全存取吗?如果你对两个问题的答案都为 YES,那么你将需要 AppDomain。

在框架中,没有办法直接卸载某个程序集。唯一途径是将程序集加载到单独的 AppDomain,然后卸载整个 AppDomain。每个 AppDomain 还可以有其自己的安全许可。 AppDomains 带有一个隔离的处理单元,通常由单独的进程操控,一般都用于服务器程序中,服务器基本上都是昼夜运行(24x7),并需要动态加载和卸载组件而不用重新启动。AppDomains 还被用于限制插件获得的许可,以便某个应用能加载非信任组件而不用担心其恶意行为。为了启用这种隔离,需要远程机制来使用多个 AppDomains;不同 的 AppDomains 其对象无法相互直接调用,他们必须跨 AppDomain 边界进行封送。尤其是类的共享实例必须从 MarshalByRefObject 派生。

这就是我现在要讲的 AppDomains。接下来我将描述一个非常简单的插件模型,它不需要 AppDomains。假设你生成了一个图像编辑器,并且你想让其他开发人员编写插件来实现诸如曝光、模糊或使部分像素变绿等特效。 此外,如果你拥有数据库所有权,你想让别的开发人员编写专门的导入/导出过滤器,以便对你的数据和他们自定义的文件格式之间进行转换。在这种情况下,应用程序在启动时加载所有的插件,插件一直保留加载状态,也就是说一直到用户退出程序。该模型不需要服务器程序具备重新加载功能,插件与应用程序本身具有相同的安全许可。所以没有必要使用 AppDomains;所有插件可被加载到主应用程序域中。这是桌面应用程序典型的使用模式。

为了真正实现这个模型,首先要定义每个插件必须实现的接口。接口实际上就像是 COM 的接口,它是一个抽象基类,在这个类中定义了插件必须实现的属性和方法。在本文的例子中,我顺便写了一个可扩展的文本编辑器,名叫 PGEdit,它带有一个插件接口 ITextPlugin(参见 Figure 4)。ITextPlugin 有两个属性,MenuName 和 MenuPrompt, 以及一个方法 Transform,该方法带一个串参数,对传入的字符串进行处理,然后返回新的串。我为 PGEdit 实现了三个具体的插件:PluginCaps,PluginLower 和 PluginScramble,其功能分别是大写,小写和打乱文本字符。如 Figure 5 所示,PGEdit 的三个插件被添加到 Edit 菜单的情形。

				Figure 4 ITextPlugin


#pragma once

using namespace System;
using namespace System::ComponentModel;

namespace TextPlugin {

   // plug-in interface definition
   public __gc __interface ITextPlugin
   {
      [ReadOnly(true)]
      __property String* get_MenuName();

      [ReadOnly(true)]
      __property String* get_MenuPrompt();

      String* Transform(String* text);
   };
}

Figure 5 带有三个插件的 PGEdit

我编写了一个类叫 CPluginMgr,它负责管理插件(参见 Figure 6)。PGEdit 启动时调用 CPluginMgr::LoadAll 加载所有插件:

				Figure 6 PluginMgr

PluginMgr.h
#pragma once
#include < vector >

using namespace std;
using namespace System;
using namespace System::Collections;

// STL vector of managed Objects, wrapped as gcroot handle
typedef vector < gcroot< Object*> > PLUGINLIST;

////////////////
// .NET Plug-in Manager. This class will load all the DLLs in a folder, 
// looking for assemblies that contain classes that implement a specific 
// interface, and will instantiate any such classes it finds, adding them 
// to a list (STL vector). Note this is a native class, which is why I 
// have to use gcroot, because a native class can't hold pointers to 
// managed objects.
//
class CPluginMgr {
public:
   CPluginMgr(LPCTSTR dir=NULL);
   virtual ~CPluginMgr();

   PLUGINLIST m_objects;      // list (vector) of plug-in objects

   // load all DLLs that implement given interface.
   int LoadAll(Type* iface, int nReserve=10);   

   // Get ith plug-in
   Object* CPluginMgr::GetPlugin(int i)
   {
      return m_objects[i];
   }

   // ditto, using []
   Object* operator[](int i)
   {
      return GetPlugin(i);
   }

protected:
   // helper: load single plug-in
   int LoadPlugin(Type* iface, LPCTSTR pathname);

   CString m_dir;          // plug-in directory where DLLs are
};

PluginMgr.cpp
#include "stdafx.h"
#include "PluginMgr.h"

using namespace System;
using namespace System::Reflection;

CPluginMgr::CPluginMgr(LPCTSTR dir) : m_dir(dir)
{
   if (m_dir.IsEmpty()) {
      // default plug-in directory is exedir/PlugIns, where exedir is the
      // folder containing the executable
      LPTSTR buf = m_dir.GetBuffer(MAX_PATH); // buffer in which to copy
      GetModuleFileName(NULL, buf, MAX_PATH); // exe path
      PathRemoveFileSpec(buf);                // remove file name part
      m_dir.ReleaseBuffer();                  // free buffer
      m_dir += _T("\\PlugIns");               // append "PlugIns"
   }
}

CPluginMgr::~CPluginMgr()
{
}

//////////////////
// Load and instantiate all plug-ins that implement a given interface. Note
// this will load all DLLs in the plug-in directory, even ones that don't
// implement the interface—there's no way to unload an Assembly w/o using
// AppDomains.
//
int CPluginMgr::LoadAll(Type* iface, int nReserve)
{
   ASSERT(iface);
   ASSERT(iface->IsInterface);

   m_objects.reserve(nReserve);  // for efficiency

   // Use MFC to find *.dll in the plug-ins directory, and load each one
   CFileFind libs;
   CString spec;
   spec.Format(_T("%s\\*.dll"), m_dir);
   TRACE(_T("Loading %s\n"), spec);
   BOOL bMore = libs.FindFile(spec);
   while (bMore) {
      bMore = libs.FindNextFile();
      LoadPlugin(iface, libs.GetFilePath());
   }
   TRACE(_T("%d plugins found\n"), m_objects.size());
   return m_objects.size();
}

//////////////////
// Load single DLL file, looking for given interface
//
int CPluginMgr::LoadPlugin(Type* iface, LPCTSTR pathname)
{
   int count=0;
   try {
      Assembly * a = Assembly::LoadFrom(pathname);
      Type* types[] = a->GetTypes();
      for (int i=0; iLength; i++) {
         Type *type = types[i];
         if (iface->IsAssignableFrom(type)) {
            TRACE(_T("Found type %s in %s\n"), CString(type->Name), 
               pathname);
            Object* obj = Activator::CreateInstance(type);
            m_objects.push_back(obj);
            count++;
         }
      }

   } catch (Exception* e) {
      TRACE(_T("*** Exception %s loading %s, ignoring\n"),
         CString(e->ToString()), pathname);
   }
   if (!count)
      TRACE(_T("*** Didn't find %s in %s\n"), CString(iface->Name), 
         pathname);

   return count;
}

BOOL CMyApp::InitInstance()
{
    ...
    m_plugins.LoadAll(__typeof(ITextPlugin));
}

此处 m_plug-ins 为 CPluginMgr 的一个实例。构造函数的参数为一个子目录名(默认值是 “Plugins”);LoadAll 搜索该文件夹查找程序集,在该程序集中包含的类实现了所请求的接口。当它找到这样一个程序集,CPluginMgr 便创建一个该类的实例并将它添加到一个列表中(STL vector)。下面是关键代码段:

for (/* each type in assembly*/) {
    if (iface->IsAssignableFrom(type)) {
       {Object* obj = Activator::CreateInstance(type);
        m_objects.push_back(obj);
        count++;
    }
}

换句话说,如果类型(type)可被赋值给 ITextPlugin,CPluginMgr 则创建一个实例并将其添加到数组。因为 CPluginMgr 是一个本机类,它无法直接保存托管对象,所以数组 m_objects 实际上是一个 gcroot 类型的数组。如果你在 Visual C++ 2005 中使用新的 C++ 语法,可用 Object^ 替代。注意 CPluginMgr 是一个通用类,支持任何你设计的接口。只要实例化并调用 LoadAll 即可,并且你最终要用插件对象数组。CPluginMgr 报告它在 TRACE 流中找到的插件。如果你有多个接口,那么你可能得为每个接口使用单独的 CPluginMgr 实例,以便保持插件之间的隔离。

在性能上,CLR 团队的 Joel Pobar 在 MSDN 杂志 2005 年7月刊里写了一篇令人恐怖的文章(Reflection: Dodge Common Performance Pitfalls to Craft Speedy Applications),在这篇文章中,他讨论了使用反射的最佳实践。他建议利用程序集层面的属性来具体说明程序集中哪个类型实现了插件接口。这样便允许插件管理器快速查找并实例化插件,而不是非得循环查找程序集中的每种类型,如果类型太多,那将是个昂贵的操作。如果你发现本期专栏里的代码在加载你自己的插件时性能很糟的话,你应该考虑改用 Joel 推荐的方法。但是对于一般的情况,这个代码足以胜任。

一旦你加载了插件,那么如何使用它们呢?这样依赖于你的应用程序,一般你会有一些像下面这样的典型代码:

PLUGINLIST& pl = theApp.m_plugins.m_objects;
for (PLUGINLIST::iterator it = pl.begin(); it!=pl.end(); it++) {
    Object* obj = *it;
    ITextPlugin* plugin = dynamic_cast< ITEXTPLUGIN* >(obj);
    plugin->DoSomething();
    }
}

(PLUGINLIST 是一个 typedef,用于 vector< GCROOT< OBJECT*>>)。PGEdit 的 CMainFrame::OnCreate 函数有一个类似这样的循环,添加每个插件的 MenuName 到 PGEdit 的 Edit 菜单。CMainFrame 指定命令 IDs 从 IDC_PLUGIN_BASE 开始。Figure 7 示范了视图是如何使用 ON_COMMAND_RANGE 来处理命令的。具体细节请下载源代码。

		Figure 7 Using ON_COMMAND_RANGE

#include "StdAfx.h"
#include "View.h"
#include "PGEdit.h"

#using < TextPlugin.dll >
using namespace TextPlugin;

IMPLEMENT_DYNCREATE(CMyView, CEditView)
BEGIN_MESSAGE_MAP(CMyView, CEditView)
   ON_COMMAND_RANGE(IDC_PLUGIN_BASE, IDC_PLUGIN_END, OnPluginCmd)
   ON_UPDATE_COMMAND_UI_RANGE(IDC_PLUGIN_BASE, IDC_PLUGIN_END, 
      OnPluginCmdUI)
END_MESSAGE_MAP()

void CMyView::OnPluginCmd(UINT id)
{
   CEdit& edit = GetEditCtrl();
   int begin,end;
   edit.GetSel(begin,end);
   if (end>begin) {
      Object* obj = theApp.m_plugins.GetPlugin(id - IDC_PLUGIN_BASE);
      ASSERT(obj);
      ITextPlugin* plugin = dynamic_cast< ITextPlugin* >(obj);
      if (plugin) {
         CString text;
         edit.GetWindowText(text);
         text = text.Mid(begin, end-begin);
         text = plugin->Transform(text);
         edit.ReplaceSel(text);
         edit.SetSel(begin,end);
      }
   }
}

void CMyView::OnPluginCmdUI(CCmdUI* pCmdUI)
{
    CEdit& edit = GetEditCtrl();
    int begin,end;
    edit.GetSel(begin,end);
    pCmdUI->Enable(begin!=end);
}

我已展示了 PGEdit 是如何加载和存取插件的,但你要如何实现插件呢?那是很容易的事情。首先生成一个定义接口的程序集——本文的例子中就是 TextPlugin.dll。该程序集不实现任何代码或类,仅仅定义接口。记住,.NET 是语言中立的,所以没有源代码,与 C++ 头文件完全不同。相反,你生成定义接口的程序集并将它分发给编写插件的开发人员。插件与该程序集链接,于是他们从你提供的接口派生。例如,下面的 C# 代码:

using TextPlugin;
public class MyPlugin : ITextPlugin
{
... // implement ITextPlugin
}

  Figure 8 展示了用 C# 编写的 PluginCaps 插件。正像你所看到的,它十分简单。有关细节请参考本文的源代码。

		Figure 8 CapsPlugin

using System;
using System.Reflection;
using System.Runtime.CompilerServices;
using TextPlugin;

public class CapsPlugin : ITextPlugin
{
   public CapsPlugin() {}

   public String MenuName
   {
      get { return "Uppercase"; }
   }

   public String MenuPrompt
   {
      get { return "Convert selected text to ALL UPPERCASE"; }
   }

   public String Transform(String text)
   {
      return text.ToUpper();
   }
}

顺祝编程愉快!

您的提问和评论可发送到 Paul 的信箱:cppqa@microsoft.com.

作者简介

Paul DiLascia 是一名自由作家,软件咨询顾问以及大型 Web/UI 的设计师。他是《Writing Reusable Windows Code in C++》书(Addison-Wesley, 1992)的作者。业余时间他开发 PixeLib,这是一个 MFC 类库,从 Paul 的网站 http://www.dilascia.com 可以获得这个类库。

本文出自 MSDN Magazine 的 October 2005 期刊,可通过当地报摊获得,或者最好是 订阅