领先技术

应用程序可扩展性:MEF 对IoC

Dino Esposito

image: Dino Esposito
Microsoft .NET Framework 4 有一个有趣的新组件,它经过专门设计,能够有效解答一个长期存在的问题:如何编写可在运行时发现其所有组成部分的可扩展应用程序?

正如 Glenn Block 在其 2010 年 2 月的文章“在 .NET 4 中使用托管可扩展性框架构建可组合的应用程序”(msdn.microsoft.com/magazine/ee291628) 中所述,托管可扩展性框架 (MEF) 可用于简化可组合和基于插件的应用程序的构建。作为一位从 1994 年就开始研究这个问题的人(是的,这是我作为开发人员最初遇到的真正挑战之一),我绝对欢迎在这个问题领域的任何建议解决方案。

MEF 不需要您购买、下载和引用任何其他库,而是为您提供了一个简单的编程接口,因为它关注的是解决现有应用程序的常规、第三方可扩展性的问题。Glenn 的文章对 MEF 进行了细致的介绍,如果您正在研究基于插件的应用程序,则应当考虑阅读这篇文章。

在本文中,我将引导您完成构建可扩展应用程序所需的步骤,我将使用 MEF 作为基础黏合剂将应用程序主体和各个外部部分组合在一起。

IoC 与 MEF

不过,在我讲解示例应用程序之前,我想和大家分享一下我对 MEF 和另一种常见框架系列控制反转 (IoC) 的看法。

可以概括地说,MEF 和典型 IoC 框架的功能互相重叠,但不彼此重合。使用大多数 IoC 框架,您可以执行 MEF 不支持的任务。您可以利用功能丰富的 IoC 容器,通过自己的一些努力,模仿一些 MEF 特定的功能。因此,我在课堂和日常工作中提到 MEF 时经常提出的问题是:MEF 和 IoC 工具之间有何差别?我何时真正需要 MEF?

我的看法是,在本质上,MEF 就是一个构建到 .NET Framework 中的 IoC 框架。它并不像今天许多流行的 IoC 框架那样强大,但是可以很好地执行典型 IoC 容器的基本任务。

今天,IoC 框架有三个典型功能。首先,它们可以充当众多对象的工厂,并且可以排定对象关联和依赖关系的链条,从而创建任何所需且已注册类型的实例。其次,IoC 框架可以管理所创建实例的生存期并提供缓存和共用功能。第三,大多数 IoC 框架支持拦截,并允许围绕特定类型的实例创建动态代理,从而使开发人员能够对方法的执行进行前处理和后处理。我在一月份的 Unity 2.0 中 (msdn.microsoft.com/magazine/gg535676) 讲述了拦截的概念。

MEF 在某种程度上可以充当众多对象的工厂,这意味着它可以识别和处理类上需要在运行时解析的成员。MEF 还为实例缓存提供很小的支持,这意味着有一些缓存功能,但是它们不如其他 IoC 框架中的缓存功能那样丰富。最后,在 .NET Framework 4 附带的版本中,MEF 完全没有拦截功能。

综上所述,我们何时应该使用 MEF?如果您从未使用过 IoC 框架,只需要通过添加一些依赖关系注入来清理系统的设计,则 MEF 可能是一个比较方便的起点。因为您可以用它快速地实现目标,所以 MEF 比 IoC 框架更适合。

另一方面,如果您多年来一直使用一个或多个 IoC 框架,熟悉其中的所有功能,则可能除了扫描各种类型的目录来查找匹配类型的能力之外,MEF 对您没有任何帮助。但应当注意,一些 IoC 框架,如 StructureMap (structuremap.net/structuremap/ScanningAssemblies.htm) 已经提供了扫描目录和程序集以查找特定类型或给定接口的实现的功能。使用 MEF,相比 StructureMap(和其他几种框架)而言,这样做可能会更加容易也更加直接。

总而言之,首先要回答的问题是,您寻找的是否是常规可扩展性。如果答案是肯定的,则必须考虑 MEF,如果您还需要处理依赖关系、单一实例和拦截,可能还需要再使用 IoC 工具。如果答案是否定的,则最佳的方法是使用 IoC 框架,除非您的需求只是 MEF 也可以很好解决的基本需要。所有条件都相同的情况下,MEF 优于 IoC 框架,原因在于它构建到 .NET Framework 内部,您不需要采用任何额外的依赖关系。

MEF 和可扩展应用程序

尽管 MEF 有助于构建可扩展的应用程序,这项工作中最精细的部分是设计应用程序的可扩展性。这只是设计,与 MEF、IoC 或其他技术没有什么关系。特别是,您必须考虑要将应用程序的哪些部分提供给插件使用。

插件通常是一个可视元素,需要与主应用程序的 UI 交互、添加或扩展菜单、创建窗格、显示对话框,甚至于添加主窗口或重新调整主窗口的大小。根据您对特定应用程序的插件的想法,要与插件共享的信息量可能只由业务数据(特别是应用程序当前状态的片段)或对可视元素(如容器、菜单、工具栏甚至是特定控件)的引用组成。您将此信息分组为数据结构,并在初始化时将其向下传递给插件。根据该信息,插件应当能够调整自己的 UI,并实现其自有的其他自定义逻辑。

下面是插件的接口。接口依赖于您在主应用程序中标识的注入点。我这里讲的“注入点”指的是应用程序代码中的位置,您将从这个位置调用插件,以使它们获得进入和操作的机会。

有关注入点的示例,可以考虑 Windows Explorer。您可能知道,Windows Explorer 允许您通过外壳扩展来扩展其 UI。这些插件都是在非常特定的时刻调用的,例如,当用户右键单击以显示选定文件的属性时。作为应用程序架构师,您有责任标识出这些注入点以及要在该点向注册的插件传递哪些数据。

理清了每个设计方面后,您可以调查哪些框架可以简化基于插件的应用程序的构建任务。

基于插件的应用程序示例

即使是“查找成员”这样一个简单的应用程序,也可以使用插件使它变得更加丰富,功能更加吸引人。图 1 显示应用程序的基本 UI。您可能要创建一个单独的项目来定义应用程序的 SDK。它将是一个类库,您在这里定义实现插件所需的所有类和接口。图 2 显示了一个示例。

image: The Simple Sample Application

图 1 简单的示例应用程序

图 2 应用程序 SDK 的定义

public interface IFindTheNumberPlugin {
  void ShowUserInterface(GuessTheNumberSite site);
  void NumberEntered(Int32 number);
  void GameStarted();
  void GameStopped();
}

public interface IFindTheNumberApi {
  Int32 MostRecentNumber { get; }
  Int32 NumberOfAttempts { get; }
  Boolean IsUserPlaying { get; }
  Int32 CurrentLowerBound { get; }
  Int32 CurrentUpperBound { get; }
  Int32 LowerBound { get; }
  Int32 UpperBound { get; }
  void SetNumber(Int32 number);
}

public class FindTheNumberFormBase : Form, IFindTheNumberApi {
  ...
}

所有插件都需要实现 IFindTheNumberPlugin 接口。主应用程序表单将从指定的表单类继承,该表单类定义一列用于将信息向下传递到插件的公共 helper 成员。

正如您可以从 IFindTheNumberPlugin 猜到的,注册的插件会在应用程序显示其 UI 时、用户对数字进行新的猜测尝试时以及游戏启动和停止时调用。GameStarted 和 GameStopped 只是通知方法,不需要任何输入。NumberEntered 是一个通知,传入用户在新尝试中刚刚输入并提交的数字。最后,在必须在窗口中显示插件时调用 ShowUserInterface。在本例中,传递了一个 site 对象,如图 3 中所定义。

图 3 插件的 Site 对象

public class FindTheNumberSite {
  private readonly FindTheNumberFormBase _mainForm;

  public FindTheNumberSite(FindTheNumberFormBase form) {
    _mainForm = form;
  }

  public T FindElement<T>(String name) where T:class { ...
}
  public void AddElement(Control element) { ...
}

  public Int32 Height {
    get { return _mainForm.Height; }
    set { _mainForm.Height = value; }
  }

  public Int32 Width { ...
}
  public Int32 NumberOfAttempts { ...
}
  public Boolean IsUserPlaying { ...
}
  public Int32 LowerBound { ...
}
  public Int32 UpperBound { ...
}
  public void SetNumber(Int32 number) { ...
}
}

Site 对象代表插件和宿主应用程序之间的接触点。 插件必须能够获得一定程度的宿主状态可见性,甚至必须能够修改宿主 UI,但是它永远不会知道宿主的内部详情。 这就是您要创建一个插件项目必须引用的中间 site 对象(SDK 程序集的一部分)的原因。

我出于简化的目的在图 3 中省略了大部分方法的实现,但是 site 对象的构造函数会收到对应用程序主窗口的引用,并且,使用图 2 中的 helper 方法(由主窗口对象公开),它可以读取和写入应用程序的状态与可视元素。 例如,Height 成员显示插件如何可以读取和写入宿主窗口的高度。

特别是,FindElement 方法允许插件(在示例应用程序中)在表单中检索特定的可视元素。 假定您已经在 SDK 中公开了一些如何访问特定容器(如工具栏、菜单及类似容器)的技术详情。 在这样一个简单的应用程序中,假定您已经记录了物理控件的 ID。 这是 FindElement 的实现:

public T FindElement<T>(String name) where T:class {
  var controls = _mainForm.Controls.Find(name, true);
  if (controls.Length == 0)
    return null;
  var elementRef = controls[0] as T;
  return elementRef ??
null;
}
With the design of the application’s extensibility model completed, we’re now ready to introduce the MEF.

定义插件的导入

主应用程序将肯定会公开一个属性,该属性会列出所有当前已注册的插件。 例如:

public partial class FindTheNumberForm : 
  FindTheNumberFormBase {
  public FindTheNumberForm() {
    InitializeMef();
    ...
}

 [ImportMany(typeof(IFindTheNumberPlugin)]
 public List<IFindTheNumberPlugin> Plugins { 
    get; set; 
  }
  ...
}

初始化 MEF 意味着准备复合容器,指定您打算使用的目录和可选导出提供程序。 对于基于插件的应用程序,常见的解决方案是从固定文件夹加载插件。 图 4 显示了我的示例中 MEF 的启动代码。

图 4 初始化 MEF

private void InitializeMef() {
  try {
    _pluginCatalog = new DirectoryCatalog(@"\My App\Plugins");
    var filteredCatalog = new FilteredCatalog(_pluginCatalog, 
      cpd => cpd.Metadata.ContainsKey("Level") && 
      !cpd.Metadata["Level"].Equals("Basic")); 

    // Create the CompositionContainer with the parts in the catalog
    _container = new CompositionContainer(filteredCatalog);
    _container.ComposeParts(this);
  }
  catch (CompositionException compositionException) {
    ...
}
  catch (DirectoryNotFoundException directoryException) { 
    ...
}
}

您使用 DirectoryCatalog 来分组可用的插件,并使用 FilteredCatalog 类(它不在 MEF 中,而是 bit.ly/gf9xDK 上 MEF 文档中显示的一个示例)来筛选出一些选定的插件。 特别是,您可以请求所有可加载的插件都有一个指示级别的元数据属性。 缺少该属性,插件会被忽略。

对 ComposeParts 的调用具有填充应用程序的 Plugins 属性的效果。 下一步就是从几个注入点调用插件。 第一次调用插件的时间就是在应用程序加载之后,给插件一个修改 UI 的机会:

void FindTheNumberForm_Load(Object sender, EventArgs e) {
  // Set up UI
  UserIsPlaying(false);

  // Stage to invoke plugins
  NotifyPluginsShowInterface();
}

void NotifyPluginsShowInterface() {
  var site = new FindTheNumberSite(this);
  if (Plugins == null)
    return;

  foreach (var p in Plugins) {
    p.ShowUserInterface(site);
  }
}

在表示用户刚刚启动新游戏、退出当前游戏或进行猜测神密数字新尝试的事件处理程序中,也会出现类似的调用。

编写示例插件

插件只是一个实现应用程序可扩展性接口的类。 图 1 中有趣的应用程序插件显示用户到目前为止所做的尝试次数。 尝试次数由应用程序的业务逻辑跟踪,并通过 site 对象公开给插件。 插件必须完成的所有工作就是:准备自己的 UI,将其与尝试次数绑定,并将其附加到主窗口。

示例应用程序的插件将在主窗口的 UI 中创建新的控件。 图 5 显示示例插件。

图 5 计数器插件

[Export(typeof(IFindTheNumberPlugin))]
[PartMetadata("Level", "Advanced")]
public class AttemptCounterPlugin : IFindTheNumberPlugin {
  private FindTheNumberSite _site;
  private Label _attemptCounterLabel;

  public void ShowUserInterface(FindTheNumberSite site) {
    _site = site;
    var numberToGuessLabelRef = _host.FindElement<Label>("NumberToGuess");
    if (numberToGuessLabelRef == null)
      return;

    // Position of the counter label in the form 
    _attemptCounterLabel = new Label {
      Name = "plugins_AttemptCounter",
      Left = numberToGuessLabelRef.Left,
      Top = numberToGuessLabelRef.Top + 50,
      Font = numberToGuessLabelRef.Font,
      Size = new Size(150, 30),
      BackColor = Color.Yellow,
      Text =  String.Format("{0} attempt(s)", _host.NumberOfAttempts)
    };
    _site.AddElement(_attemptCounterLabel);
  }

  public void NumberEntered(Int32 number = -1) {
    var attempts = _host.NumberOfAttempts;
    _attemptCounterLabel.Text = String.Format("{0} attempt(s)", attempts);
    return;
  }

  public void GameStarted() {
    NumberEntered();
  }

  public void GameStopped() {
  }
}

插件创建新的 Label 控件,并将它放在现有 UI 元素的下方。然后,只要插件收到通知,有新的数字输入,则计数器将会更新,以根据业务逻辑的状态显示当前的尝试次数。图 6 显示了运行中的插件。

image: The Sample App and a Few Plug-Ins

图 6 示例应用程序和几个插件

插入

今天的最后,我讲一下设计可扩展应用程序中最精细的任务,即宿主和插件接口的设计。这是一个纯粹的设计任务,与功能列表和用户需求有关。

然而,当进入实施阶段后,则不管插件接口如何,您都有很多需要完成的实际任务,如选择、加载和验证插件。在这方面,MEF 能够为您提供极大的帮助,因为它简化了待加载插件目录的创建,并且采用与 IoC 框架相同的方式自动加载插件。

注意,MEF 仍在不断地开发中,您可以在 mef.codeplex.com 上找到最新的代码、文档和示例代码。

Dino Esposito 是《Programming Microsoft ASP.NET MVC》(Microsoft Press,2010)一书的作者,也是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。您可访问他的博客,网址为 weblogs.asp.net/despos

衷心感谢以下技术专家对本文的审阅:Glenn Block