UML软件工程组织

让用户通过宏和插件向您的 .NET 应用程序添加功能

 

作者:Jason Clark 出处: Microsoft

 

概述

大多数用户应用程序都受益于可由其他开发人员扩展的能力。扩展一个用户已经很熟悉并针对它进行过培训的现有应用程序往往比从头开发更加简单和有效。因此,可扩展性会使您的应用程序更加吸引人。您可以通过支持插件和宏等功能来使应用程序具有可扩展性。使用 .NET Framework 可以轻松实现这一点,即使核心应用程序不是 .NET Framework 应用程序。在本文中,作者将描述 .NET Framework 的可扩展功能(包括晚期绑定和反射)及它们的使用方式,同时还介绍插件安全注意事项。

想像一下完美的文本编辑器是什么样子的。它启动时间不超过两秒,支持针对流行编程语言的上下文着色和自动缩进,支持多文档界面 (MDI) 以及很酷并且大受欢迎的选项卡式文档排列方式。构想这种完美的文本编辑器的问题在于完美只存在于旁观者的眼中。这些功能只是我对完美的文本编辑器的定义,其他人肯定会有不同的标准。也许完美的文本编辑器所能拥有的最重要的功能就是支持丰富的可扩展性,这样任何开发人员就可以使用他们需要的功能来扩展应用程序。

可扩展的文本编辑器可能支持创建自定义工具栏、菜单、宏,甚至是自定义文档类型。它应该允许我编写能挂钩到编辑进程的插件,以便添加自动完成、拼写检查及其他诸如此类的美妙功能。最后,完美的文本编辑器应该能让我用任何语言编写自己的插件(我个人的首选是 C#)。

诚然,我希望所用的每个应用程序都能按照这种方式来扩展。如果在某些地方编写少量代码就可以自定义自己喜欢的应用程序,那就再好不过。即使我做不到,我也知道其他人能够做到,我再通过从 Internet 下载来利用他们的扩展。这就是我开展此项活动以让所有开发人员都来编写可扩展应用程序的初衷。

理想的可扩展应用程序

许多应用程序都可以使用可插入代码来修改。实际上,整个 Microsoft® Office 应用程序套件都可以进行广泛地自定义,从而人们能够使用 Office 作为平台来编写完整的自定义应用程序。然而,即便有了所有这些可自定义能力,我还是为 Microsoft Word(一个我几乎天天使用的应用程序)编写了我的第一个插件。

原因很简单。Microsoft Office 的所有功能并不能完全符合我的标准,包括:

简单性。我想用已经很熟悉的非常简单的软件工具来操作我的可插入应用程序。
访问权限。我想让我的插件有权访问应用程序中内置的某些对象和功能的子集。这种访问权限应该是自然而然的,如同是我选择的编程语言的一部分。
编程语言。有时我想使用特别选择的编程语言。
能力。除了访问应用程序的文档对象模型 (DOM) 外,我还需要一个丰富的 API。
安全性。我需要能够下载其他人编写并且可以通过 Internet 下载的插件。我希望执行有潜在威胁或错误百出的组件而不必考虑系统的安全。

以上列出的事项虽然简短但近乎苛求。实际上,在 Microsoft .NET Framework 发行之前,这些标准对普通应用程序而言太过严格,是无法做到的。但现在,我可以向您展示如何使用 .NET Framework 来将所有这些可扩展性功能添加到您的托管和非托管应用程序中。

.NET Framework 可扩展性功能

可扩展性构建在晚期绑定之上,它是指在运行时而非编译时(更典型的情况)发现和执行代码的能力。在这几年中,有许多技术创新对晚期绑定做出了重大贡献,其中包括 DLL 和 COM API。.NET Framework 将晚期绑定的简单性提高到一个全新的层次。为加深理解,我们来看一个非常简单的代码示例。

图 1 显示了使用反射在托管对象中执行晚期绑定是如何的简单。如果您在 LateBinder.exe 内构建图 1 中的代码并运行它,则可以将任何程序集(例如从图 2 中的代码构建的程序集)的名称作为命令行参数传递给给它。LateBinder.exe 会反射程序集,并在该程序集中创建从 Form 派生的类的实例,并使它们成为其自己的 MDI 子类。.NET Framework 中的反射使晚期绑定大大简化。

Figure 1 Late Binding

using System;
using System.IO;
using System.Reflection;
using System.Windows.Forms;
using System.Drawing;

class App{
   public static void Main(String[] args){
      // Load the assembly whose name was passed on the command-line
      Assembly a = Assembly.Load(
         Path.GetFileNameWithoutExtension(args[0]));

      // Pass the assembly object to a constructed LateBinderForm
      Application.Run(new LateBinderForm(a));      
   }
}

// Creates late-bound form instances for MDI children
class LateBinderForm:Form{
   public LateBinderForm(Assembly a){
      // Make it MDI
      IsMdiContainer = true;
      
      // Get the types in the assembly
      Type[] types = a.GetTypes();
      // Iterate and find types derived from Form Instantiate them
      foreach(Type t in types){
         if(t.BaseType == typeof(Form)){
            Form f = (Form)Activator.CreateInstance(t);
            f.MdiParent = this; // Set as MDI children
            f.Show();
         }
      }
   }
}

Figure 2 CircleForm.cs

using System;
using System.Drawing;
using System.Windows.Forms;

// Form class that draws a circle in its client area
public class Circle:Form{
   protected override void OnPaint(PaintEventArgs args){
      Int32 diameter = 
         Math.Min(ClientRectangle.Width, 
            ClientRectangle.Height);
      args.Graphics.FillEllipse(Brushes.Blue, 
         (ClientRectangle.Width-diameter)/2, 
         (ClientRectangle.Height-diameter)/2, diameter, 
            diameter);
   }
}

反射是 .NET Framework 的基本工具之一,它促进了可扩展性应用程序的开发。它是我这里提到的可使应用程序可扩展的四种功能之一。

公共类型系统 使用 .NET Framework 一段时间之后,您可能就会开始认为使用公共类型系统 (CTS) 理所当然了。不过,它的确是使该平台中可扩展性变得如此简单的原因之一。CTS 定义了所有托管语言都必须遵循的部分面向对象特征,例如派生的规则、命名空间、对象类型、接口和基元类型。CTS 的这些基本规定是针对公共语言运行库 (CLR) 运行的代码设置的。

反射 反射是在运行时发现信息(例如,程序集实现的类型或类型定义的方法等信息)的能力。之所以反射成为可能,是因为所有托管代码都是通过嵌入到程序集中的数据结构(称为元数据)自描述的。

Fusion .NET Framework 使用 Fusion 将程序集加载到托管进程 (AppDomain) 中。Fusion 有助于实现一些高级功能,如强命名和简化的 DLL 搜索规则。

代码访问安全性 代码访问安全性 (CAS) 是 .NET Framework 的一个功能,可以简化部分受信任代码的执行。简而言之,您可以使用 Microsoft .NET Framework 的功能来限制晚期绑定代码可以访问的内容,这样就不用担心插件破坏用户的系统。

这就是.NET Framework 使可扩展性变成现实的四个功能。然而,由于这些功能是如此的酷,所以一篇文章介绍一个功能不可能将可扩展性讲得非常透彻。因此,最好的做法是从一个任务出发引入这个话题。

可扩展性入门

不管您的应用程序有什么用途,只要它是可扩展的,就必须执行三个基本任务:发现、加载和激活扩展。发现是查找应用程序在运行时绑定的插件和其他代码的过程。加载是将代码(打包为程序集)放入进程或 AppDomain 以便激活并使用由程序集定义的类型的过程。激活是创建晚期绑定对象的实例并调用其方法的过程。

这三个阶段的每一个都包含着许多 .NET 技术和应用程序设计注意事项。虽然技术上能够做到,但 .NET Framework 并没有定义一种特殊的方式来实现可扩展性,所以您可以有许多选择。

加载:在运行时绑定到代码

从逻辑上讲,可扩展性应用程序在加载代码之前要先发现它。但反射必须加载代码才能发现与它有关的内容,所以实际上发现过程可能要求提前加载代码。我们来看一下这是什么意思。

反射可以用来实现晚期绑定。大多数反射类都可以在 System.Reflection 命名空间中找到。三种最重要的类是 System.AppDomain、System.Type 和 System.Reflection.Assembly。后面我将会介绍 System.Type。为了理解 AppDomain 和 Assembly 类,我们简单地看一下托管进程。

CLR 在 Win32® 进程中运行托管代码,其粒度比在非托管应用程序中的粒度更细。例如,一个托管进程可以包含多个 AppDomain,您可以认为后者是一种子进程或轻量级的应用程序容器。

程序集是 DLL 和 EXE 的托管版本,它们包含可重用的对象类型(如类库类型)以及应用程序代码。另外,应用程序的任何扩展或插件也应该存在于程序集中(与 DLL 非常类似)。程序集要加载到托管进程内的一个或多个 AppDomain 中。

每个托管进程至少有一个默认的 AppDomain,同时包含某些共享资源,例如托管堆、托管线程池和执行引擎本身。除了这些逻辑组件之外,托管进程还可以创建任意数量的额外的 AppDomain。请参见图 3,它显示了一个包含两个 AppDomain 的托管进程。在后面有关插件发现的话题中,AppDomain 显得极为重要。

fig03

图 3 托管进程

现在,让我们回到 AppDomain 和 Assembly 类型。您可以使用 Assembly.Load 静态方法将程序集加载到当前的 AppDomain 中。Assembly.Load 方法将引用返回给 Assembly 对象。这个方法在运行时将代码绑定到您的应用程序,方法是加载驻留代码的程序集。

Assembly.Load 通过名称(不带扩展名)从 AppBaseDir 目录加载程序集(AppDomain 就是从这个目录秘密加载已部署程序集的)。默认情况下,Assembly.Load 从加载 EXE 的目录中寻找常规可执行程序。当加载早期绑定的程序集时,Assembly.Load 遵循的程序集绑定规则与 CLR 使用的一样。

可以通过获取 AppDomain 对象的一个引用并调用 AppDomain 的 AppendPrivatePath 和 ClearPrivatePath 实例方法来调整 AppDomain 的 AppBaseDir。如果您使用 Assembly.Load 加载插件程序集,则可能需要操作 AppDomain 的 AppBaseDir。这是因为维护主应用程序目录的子目录下的插件非常有用。具体原因我很快就会解释。

Assembly 类也实现了一个名为 LoadFrom 的方法,它与 Load 的不同之处在于,它带有程序集文件的完整名称(包括路径和扩展名)。LoadFrom 只是简单地加载您指向的文件,而不是按照绑定和发现规则寻找程序集。这在加载用作插件的程序集时十分有用。不过要注意,与 Load 相比,LoadFrom 对性能产生了不利的影响,而且它缺少版本绑定方面的智能,这为除插件方案外的几乎所有方案都带来了负面影响。

一旦获取了 Assembly 对象的引用,您就可以发现包含在程序集中的类型,并创建这些类型的实例和调用方法。接下来的两节中我将介绍这些操作。

发现:在运行时发现代码

当编译应用程序时,您必须显式或隐式地告诉编译器应用程序在运行时绑定和使用的代码。这些代码出现的形式是可广泛重用的类库类型(例如 Object、FileStream 和 ArrayList)以及打包在 helper 程序集中特定于应用程序的类型。编译器存储在程序集清单中实现这些类型的程序集的引用,而 CLR 在运行时引用该清单以加载所有必要的程序集。这就是典型的托管代码绑定过程。

正如前面所提到的,晚期绑定也使用程序集形式的代码;然而,编译器并不直接涉及绑定过程。相反,代码必须在运行时发现它需要加载哪些程序集。该发现过程可以通过各种方法实现。事实上,.NET Framework 并没有指定一个发现方法,尽管它确实为您提供了实现自己的技术的重要工具。

两种发现机制都值得一提。第一种也是最简单的一种机制(从软件角度看)就是维护一个 XML 文件,它记录应用程序在运行时应该绑定的代码。第二种是比较高级的基于反射的方法,它可以使应用程序的可用性更高。我们首先来看 XML 方法。XML 方法只需代码解析 XML 文件并使用其中的信息即可开始加载程序集。以下示例显示了一种可能的 XML 格式:

<PluginAssembly name="MyPlugin.dll">
   <Type name="SomeType"/>
   <Type name="AnotherType"/>
</PluginAssembly>
<PluginAssembly name="MorePlugins.dll">
</PluginAssembly>

这个 XML 包含使用反射实现加载过程所需要的最少信息;也就是想要激活的程序集的名称和程序集中一种或多种类型的名称。代码只需找出 XML 中程序集的名称,然后使用如图 1 所示的代码将程序集加载到 AppDomain 中。

XML 方法易于实现,但产生的应用程序可用性不高。例如,我不是特别喜欢这样的文本编辑器:必须编辑某种 .config 文件才能扩展应用程序。然而,对于服务器端应用程序来说,这种方法可能比使用反射更合适。

基于反射的发现方法可以使用相对于应用程序目录的已知位置来自动操作。例如,我为本文编写的示例应用程序在一个名为“Plugins”的子目录中搜索可能的可扩展性程序集(要下载完整的源代码,请转到本文顶部的链接)。这种特殊的方法比较有用,因为用户只需要将程序集文件复制到文件系统,应用程序在启动时就会绑定新的代码。

有两个原因造成这种方法的实现比 XML 方法困难。首先,必须制订加载程序集应该遵循的标准。其次,必须反射程序集以发现它是否符合标准,以及是否应该加载到 AppDomain 中。

有三个有用的标准可用于在作为插件的程序集中为代码确定一种类型。当将反射方法用于发现时,应该采用这些标准中的一个。

接口标准 代码可以使用反射搜索整个程序集以发现实现已知接口类型的所有类型。

基类标准 代码再次使用反射搜索整个程序集以发现从一个已知基类派生的所有类型。

自定义属性标准 最后,可以使用自定义属性来将一种类型标记为应用程序的插件。

我马上就会向您说明如何使用反射来发现晚期绑定程序集中的某种类型是否实现一个接口,是否从一个基类派生,或者是否属性化。但首先我们来看一下使用这些绑定标准的设计折衷方案。

接口和基类标准比自定义属性方法(或基于反射的晚期绑定的其他任何方法)更加有用,这有两个原因。首先,相对于自定义属性而言,插件开发人员很可能对接口或基类比较熟悉。更重要的是,可以使用接口和基类以早期绑定的方式调用晚期绑定代码。例如,当您将接口作为绑定到一个对象类型的标准使用时,则可以通过该接口类型的引用来使用对象的实例。这样就可以避免需要使用反射来调用方法和访问实例的其他成员,从而提高可扩展应用程序的性能和代码能力。

要想在运行时发现一个有关类型的信息,可以使用反射类型 System.Type。从 Type 派生的类型的每个实例都引用系统中的一个类型。Type 实例可以用于在运行时发现关于该类型的一些事实,比如它的基类或它实现的接口。

要想获取由一个程序集实现的一组类型,只需调用 Assembly 对象的 GetTypes 实例方法即可。GetTypes 方法返回一个 Type 派生的对象的引用数组,程序集中定义的每个类型对应一个引用。

要想确定一个类型是实现一个已知接口还是从某个基类派生,可以使用 Type 对象的 IsAssignableFrom 方法。以下代码片段说明了如何测试 someType 变量所表示的类型是否实现了 IPlugin 接口:

Boolean ImplementsPluginInterface(Type someType){
   return typeof(IPlugin).IsAssignableFrom(someType);
} 

这个原则也适用于测试基类型。

对于发现要在应用程序逻辑中插入哪些类型来说,加载程序集以反射该程序集中的类型是很有效的。但如果没有一种类型与您的标准相匹配,会怎么样呢?简单地说,就是无法卸载在 AppDomain 中已经存在的程序集。这就是基于反射的发现方法比 XML 方法稍难实现的第二个原因。

对于某些客户端应用程序,将代码反射的所有程序集(甚至不符合插件绑定标准的程序集)都加载到进程中也许还可以接受。但对于可扩展的服务器应用程序而言,没有足够的空间来将随后不能卸载的任何程序集都加载进来。这个问题的解决办法分为两个阶段。首先,只要卸载加载程序集的整个 AppDomain,就可以卸载该程序集。其次,可以通过编程方式创建一个临时的 AppDomain,其唯一目的是加载程序集来进行反射,以便发现是否需要将程序集中的类型作为插件使用。一旦发现阶段结束,就可以卸载这个临时 AppDomain 及其所有程序集。

这种解决办法听起来好像很复杂,但其实要实现的只是一段非常简单的代码。本文使用的 PluginManager 类采用了这种方法,它要实现的代码不超过 100 行。如果您想在自己的可扩展应用程序中采用这种方法,您会发现 PluginManager 的源代码非常有用(请参见下载文件)。

激活:创建实例和调用方法

一旦确定了要在应用程序中插入哪些类型,接下来就需要创建对象的实例。这完全可以通过反射来实现;不过您会发现反射速度慢、不实用,而且难以维护。另外,您还应该选择使用反射来创建插件对象的实例,将对象强制转换为已知接口或基类,然后在其整个剩余生命周期内根据已知类型使用这些对象。请记住,接口和基类标准很适合采用这种方法来发现插件类型。

由于有了 CTS,托管代码才是面向对象且类型安全的。晚期绑定代码面临的一个难题是在编译时不一定知道对象的类型。但由于有了 CTS,您可以将任何对象看作是 Object 基类,然后将其强制转换为应用程序和可插入代码已知的某个基类或接口。如果对象无法进行强制转换,则会引发 InvalidCastException 异常,应用程序可以捕获这个异常并进行相应的处理。

然而,在进行任何这样的操作之前,必须创建要绑定到的对象的实例。与早期绑定对象不同,您不能简单地使用 new 关键字来创建实例,因为编译器需要与 new 一起使用的类型的名称,而对于晚期绑定的对象类型,这显然是不知道的。解决办法是采用静态方法 Activator.CreateInstance。这个方法将创建对象的实例(假定引用对象的 Type 派生的实例)和一个可选的 Object 引用数组(用作构造函数参数)。以下代码使用 CreateInstance 创建一个对象,并返回一个已知接口:

IPlugIn CreatePlugInObject(Type type){
   return (IPlugIn) Activator.CreateInstance(type);
}

一旦拥有一个对象并将其强制转换为一种已知类型,就可以通过引用变量调用该对象的方法,就像调用其他任何对象的方法一样。此时,晚期绑定代码中的对象就与应用程序的其他部分无缝集成在一起了。

保护可扩展性

如果您的应用程序将广泛分发,而且任何人都可以编写插件,则必须考虑安全性。幸运的是,.NET Framework 使保护代码变得非常容易。让我们看一下这是如何做到的。

再次想像一下可扩展文本编辑器。在理想的情况下,用户应该能够从 Internet 下载插件并安全地将其插入到该应用程序中,即使该插件是由非受信任的第三方设计的。而且如果它不是受信任的,则应该在部分受信任的状态下执行,在这种状态下,它无权访问像文件系统或注册表这样的系统资源。

.NET Framework 可以通过 CAS 执行部分受信任的代码。因为托管代码是实时编译的,所以 CLR 有能力断言部分受信任的托管代码不执行它缺少权限的操作。如果部分受信任的代码试图执行一个不被允许的操作,CLR 就会引发 SecurityException 异常,应用程序可以捕获该异常。

在某些情况下,代码默认为部分受信任,比如分布在 Internet 中或嵌入 HTML 文档内的控件。然而,您也可以利用 CAS,这样用户就可以安全地使用来自第三方的扩展程序集。在所有情况下,CLR 将程序集视为一个安全单元,这意味着应用程序可以包含多个程序集,授予其中每个程序集的安全权限可能略微有所不同。这对插件来说非常适合。

另外,.NET Framework 提供了许多功能,并且有许多方法,您可以使用这些方法来创建部分受信任的插件。以下步骤采用最简单也是最安全的方法。首先,为应用程序的插件创建两个子目录。一个存放完全受信任的插件,另一个存放部分受信任的插件。然后通过编程方式调整本地安全策略,将代码组与部分受信任插件对应的子目录相关联。最后,为代码组中的代码授予 Internet 权限,也就是说使它拥有一个权限子集,将即使可能有恶意的代码也视为是安全的。

一旦生成了这种代码组,CLR 就会自动将降低的权限与从部分受信任子目录加载的程序集相关联。除了要调整本地安全策略(我马上就会向您介绍如何来做)外,插件的安全性会自动工作。

调整安全策略

.NET Framework 安全策略引擎非常灵活,是可以调整的。在实际情况中,可以使用随 Framework 安装的 .NET Framework Configuration 控制面板 applet 手动调整安全策略(参见图 4)。此外,也可以通过编程方式调整策略。要编写修改安全策略的代码,必须从 SecurityManager 类型开始。这个有用的类型可以帮助您访问 .NET Framework 安装的三种策略级别:Enterprise、Machine 和 User。我建议您将插件的自定义代码组添加到 Machine 策略级别中。要发现 machine PolicyLevel 对象,可以使用如图 5 所示的代码。

fig04

图 4 安全配置

Figure 5 Finding Machine Policy Level

// Get machine policy
static PolicyLevel FindMachinePolicy(){
   PolicyLevel ret = null;
   // Get the policy hierarchy
   IEnumerator en = SecurityManager.PolicyHierarchy();
   en.Reset();
   // Find the policy named "Machine"
   while(en.MoveNext()){
      PolicyLevel level = (PolicyLevel) en.Current;
      if(level.Label == "Machine"){
         ret = level;
         break;
      }
   }
   return ret;
}

代码组是以逻辑层次结构排列的。一旦获取了 PolicyLevel 对象,就可以从 PolicyLevel.RootCodeGroup 属性返回的代码组开始遍历代码组的层次结构。默认情况下,根代码组的名称为 ALL_CODE,它代表所有托管代码。您应该创建自定义的代码组,作为 ALL_CODE 代码组的子组。

图 6 中的代码为从特定 URL 加载的任何代码创建一个自定义代码组。该代码组具有 Internet 权限,并设置了 PolicyStatementAttribute.Exclusive 和 PolicyStatementAttribute.LevelFinal 位来指示与这个代码组匹配的代码只具有这些权限。URL 可以是 HTTP、HTTPS 或 FILE URL。要将这个新代码组与文件系统中的目录相关联,可以使用具有如下结构的文件 URL:file://d:/programs/extensible-app/partially-trusted。

Figure 6 Creating and Saving Custom Code Group

static void MakePluginCodeGroup(
   PolicyLevel level, CodeGroup root, String url){

   // Get the internet permissiion set
   PermissionSet permissions = 
      level.GetNamedPermissionSet("Internet");

   // Create a membership condition for our path
   IMembershipCondition membership = 
      new UrlMembershipCondition(url);

   // Create a policy statement from permissions and condition
   PolicyStatement statement = new 
      PolicyStatement(permissions, 
      PolicyStatementAttribute.Exclusive | 
      PolicyStatementAttribute.LevelFinal);
   // New code group
   UnionCodeGroup group = 
      new UnionCodeGroup(membership, statement);
   group.Description= "Custom code group for plug-ins";      
   group.Name = "Custom group";
   root.AddChild(group);
   SecurityManager.SavePolicyLevel(level);
}

.NET Framework 的 CAS 功能非常灵活,但可能需要一段时间才能习惯使用。通过阅读本文以及我在下载文件中提供的示例应用程序代码,您应该能够获取创建插件体系结构所需的信息。不过,我强烈建议您使用 .NET Framework Configuration 控制面板 applet 来尝试对系统策略进行更改,只有这样才能熟悉概念。如果更改太多,可以随时恢复策略默认值。

可扩展应用程序设计

本文附带的 ExtensibleApp.exe 示例应用程序是一个支持自定义插件的例子。其实,该应用程序只不过是一个 shell,它仅仅显示一个 MDI 窗口并允许用户安装插件。如果您正在使用该示例中的代码作为编写自己的可扩展应用程序的学习工具,则应该特别注意 PluginManager.cs 中的代码。该模块包含 PluginManager 可重用类,它为示例应用程序处理所有非特定于应用程序的插件逻辑。

fig07

图 7 插件安装前

如果您构建并运行 ExtensibleApp.exe 示例,则会发现它允许您选择 DLL 作为插件安装到应用程序中。该示例包含两个插件项目:PluginProject1.dll 和 PluginProject2.dll。它们利用应用程序本身公开的 API 来创建工具栏和菜单,并在应用程序中添加一个文档类型。图 7 显示了插入自定义代码之前的应用程序,图 8 显示了插入之后的应用程序。

fig08

图 8 运行插件

该应用程序使用了本文前面讨论的技术和技巧。另外,它还显示了一些设计方法,这些方法在使应用程序可扩展时应该加以考虑。让我们看一下其中一些注意事项。

版本控制

版本控制是应用程序生命周期的一个重要方面。它也对可扩展应用程序有重要的影响。可扩展应用程序需要定义用来发现和使用插件的接口、基类型或属性类型。如果您将这些类型包括在常规应用程序 EXE 或 DLL 程序集中,则它们的版本会由应用程序的其他部分控制,对于不按相同的时间表进行版本控制的插件程序集来说,这可能会产生绑定冲突。但有一个办法可以解决这个问题。

解决办法就是,应该将用来发现或绑定到插件的任何类型都定义在它自己的程序集中,在该程序集中只有其他由插件产生的类型存在。还应该避免将任何代码(至少不应该太多)都放在程序集中,因为需要尽可能少地对该程序集进行版本控制。同时,也可以根据需要对应用程序的其他部分进行更改和版本控制。应用程序和插件将共享很少进行版本控制的粘连程序集。

这就带来了一个与插件所使用的基类型有关的问题。与接口不同,基类一般包含相同的代码。这是一个急需解决的棘手的问题,它也是我们更喜欢选择通过接口调用晚期绑定对象的主要原因之一。

然而,您应该知道,如果需要对基类和接口程序集中的代码进行版本控制,.NET Framework 提供的灵活性可以满足您将绑定从旧版本程序集重定向到新版本的需要。但这需要对绑定策略作出更改,所作的更改必须输入到应用程序的 app.config 文件或整个系统的全局程序集缓存 (GAC) 中。最好避免这种可能性,因此要管理好插件接口和基类以便尽可能少地对它们进行版本控制(如果曾经这样做过)。

健壮性

请记住,在编写可扩展的应用程序时,虽然您的代码能够得到严格的质量控制,但插件代码却很可能没有得到控制。因此,通过接口或基类引用调用晚期绑定对象的任何代码都应该会遇到这些无法预料的问题。即使开发人员本意不想造成破坏,但部分受信任插件还是可能引发安全异常。同样,部分受信任插件和完全受信任插件都可能有错误存在,因为插件作者对应用程序内部的了解和您是不一样的。

如果您设计的可扩展应用程序只支持由您自己或您的团队编写的插件,则可以不考虑这个问题。但是许多可扩展应用程序都希望当插件对象出现异常情况时能够尽可能妥善地进行恢复。

托管代码的一个强大优势在于,当一个对象确实运行失败时,它会以一种定义完善的方式失败 — 也就是说该对象会引发一个异常。所以如果您一贯坚持注册未处理的异常处理程序,并处理调用插件代码应该会产生的异常,则即使插件失败也可以为用户提供一致的体验。

如果您使用接口调用插件对象,则可以使用一种高级技术,也就是将所有晚期绑定对象包装在一个实现了相同接口的代理对象中。这个常规目的的代理对象会将所有调用传递到基础插件,但也会将与日志记录失败一致的异常处理程序与调用一起包装,同时警告用户插件出现异常及其他类似的情况。对最终的插件健壮性而言,这是一个很好的主意,但对于许多应用程序来说,可能并不需要如此程度的稳定性。

安全注意事项

只要您制订策略来限制所加载的程序集的权限,.NET Framework 就可以维护部分受信任的插件的安全性。然而,这虽然对保护系统起了很大作用,但是不能自动保护应用程序的状态。部分受信任的对象与应用程序中的对象共享一个托管堆,并且需要考虑如何限制它们访问您的内部应用程序对象。以下是一些要点。

如果没有必要,就不要将应用程序中的对象类型指定为公共对象。如果类型为内部类型(默认情况),则可以限制应用程序中的部分受信任代码对其进行访问。然而,很容易在不经意间将公共类型引入应用程序。例如,当您将从 Form 派生的类型添加到项目中时,Visual Studio 向导会生成公共类。这对大多数应用程序来说都是不必要的,所以应该将这些类型中的 public 关键字删除,当您觉得有必要时再添加上去。

同样,如果没有必要,不应该使公共类型的成员成为公共或受保护成员。即使对于不可扩展的应用程序,在您觉得有必要提高其可访问性之前,成员也应该是私有的。因而,如果内部可访问性能够满足要求,就不要提高可访问性。只有当您打算在程序集外公开成员时才使用受保护成员和公共成员。

您应该关注类库类型是否有不好或不完整的安全策略。例如,System.Windows.Forms.MainMenu 类公开一个名为 GetForm 的方法,它返回菜单所在的窗体。这通常是应用程序的主窗体。即使您不打算将对应用程序主窗体的引用传递给部分受信任的插件,您也可能无意中让插件直接访问应用程序中从 Menu 派生的对象,从而允许插件访问应用程序主窗体。CLR 类库开发人员考虑了类似的安全问题。例如,Form.Parent 在返回对父窗体的引用之前要求其调用方具有一个安全权限。例如,以 Internet 权限运行的部分受信任代码在默认情况下无法访问该属性。

正如您所见,部分受信任的插件可能无法访问一般文件系统或注册表,但您仍然需要提防有恶意的插件执行某些操作,比如关闭您的应用程序。对于客户端应用程序,此类问题通常无关紧要。但对于服务器应用程序却是很重大的问题。

在最后一节中,我将简要讨论可扩展应用程序的一些高级可能性。这些主题范围太广,很难详细介绍,大多数应用程序也可能不需要用到,但了解这些可能性是有帮助的。

可卸载的插件程序集

在本文前面介绍插件的章节中,您可能还记得有关卸载不需要的程序集的问题。解决这个问题的办法是在临时的 AppDomain 中测试程序集是否有用,以便在需要时将它们卸载。然后,如果发现有程序集您确实需要用到,可以将它们加载到您的主 AppDomain 中。但是,如果不想让所有插件无限期地存在,该怎么办呢?

对于客户端应用程序,以下做法是可以接受的:加载插件程序集,使用它们的类型直到它们不再有用,然后在应用程序剩余的生命周期内忽略额外的程序集。然而,在服务器端的要求要严格得多。服务器端应用程序必须能够无限期运行,而且不能耗尽诸如进程地址空间等重要资源,所以需要您加载和卸载包含插件类型的程序集。

为做到这一点,您需要在一个临时应用程序域中寻找程序集,然后专门到另一个 AppDomain 中使用该插件类型。这为应用程序的设计增加了一定的复杂性,但也很好地将插件与核心应用程序逻辑分开。

完全在一个独立的 AppDomain 中实例化和使用对象是可以做到的,而在 .NET Framework 中实现它的功能称为远程处理 (Remoting)。远程处理用于跨进程和跨网络访问对象,但也可以用来访问不同 AppDomain 中的对象(即使它们位于相同的 Windows® 进程中)。我无法在这里完整地讲述远程处理,但您可以在以前出版的MSDN® Magazine 中找到详细的介绍,也可以在我的示例 PluginManager 类型中找到一些简单的远程处理代码。

非托管应用程序中的托管插件

有了这么多强大的可扩展功能供您使用,如果您的应用程序是采用非托管语言(如 Visual Basic® 6.0 或 C++)编写的旧式应用程序,您可能会觉得非常失望。而现在大可不必失望了。CLR 本身是一个 COM 对象,它可以宿主在任何 Win32 进程中,可以由能够成为 COM 客户端的任何语言来编写。

这意味着您可以编写插件托管代码(用 C# 或 Visual Basic .NET),然后通过非托管代码加载运行库和粘接代码,再由该代码以您喜欢的方式加载可与您的非托管应用程序进行交互的插件。同时,COM Interop 允许您在托管和非托管代码之间来回无缝地传递 COM 接口。

实际上,有三种方式可以宿主非托管程序集中的托管代码并与其进行交互。可以使用 COM Interop。CLR 允许您采用 C#、Visual Basic .NET 和其他托管语言创建 COM 服务器。您可以在任何非托管应用程序中绑定和使用这些托管对象。也可以使用带有托管扩展的 C++ (MC++),它能够将托管和非托管代码自然地混合在单个进程中。如果您的应用程序是用 C++ 编写的,您会发现托管 C++ 包含丰富的可扩展性功能。另外,还可以直接宿主 CLR。CLR 本身是一个可以宿主的 COM 对象。.NET Framework SDK 带有一个名为 MSCorEE.h 的 C++ 头文件,它包含将运行库作为 COM 对象使用所需的定义。

再次强调,一旦托管代码绑定到非托管应用程序中,托管代码就可以使用本文所介绍的技术来实现应用程序的可扩展性。

小结

.NET Framework 为代码反射、后期绑定和代码安全性提供了一些非常灵活的功能。.这些功能可以以各种方式混合和搭配使用来实现可扩展的应用程序。如果一个可靠的应用程序也是可扩展的,则可能有更长的寿命,也可能获得进一步的发展,这可以促使更多插件的创建并且更广泛地为人们所接受。所以请认真研究这些可扩展性功能。我想结果您会喜欢的。

相关文章请参阅:
.NET Framework: Building, Packaging, Deploying, and Administering Applications and Types
.NET Framework: Building, Packaging, Deploying, and Administering Applications and Types—Part 2

 


版权所有:UML软件工程组织