65.9K
CodeProject 正在变化。 阅读更多。
Home

从零开始精通 MEF

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (263投票s)

2012年5月2日

CPOL

40分钟阅读

viewsIcon

409725

downloadIcon

8094

了解如何从完全不懂托管可扩展性框架 (Managed Extensibility Framework) 的初学者成长为一名高级用户。

引言

为什么我们在购买新笔记本电脑时会关注它有哪些接口? 我们不仅忍受着笔记本侧面这些孔洞,甚至在孔洞不够多的时候还会抱怨。 答案当然是,这些接口允许我们扩展我们的笔记本。 我们可以连接第二个显示器、外部硬盘驱动器或许多其他设备。 这并不意味着原装笔记本就不好;这只是意味着不同的使用场景适合不同的配置。 那么,为什么我们坚持要构建没有“接口”的应用程序呢?

在 .NET 4.0 中,微软为我们提供了托管可扩展性框架 (MEF)。 这个框架允许我们轻松地在应用程序中创建可扩展点(接口)。 最明显的用途是插件。 你可以允许客户创建自己的菜单项,就像他们在 Microsoft Word 或 Visual Studio 中所做的那样。 然而,MEF 还有其他用途。 例如,如果你预计业务规则将来可能会被更改或扩展(这种情况从来不会发生,对吧?),你可以使用 MEF 来简化这个过程。 我将在下面向你展示如何实现。

为什么我应该关心 MEF?

MEF 与 IoC (控制反转) 有很多相似之处,但也存在一些差异。 MEF 专注于将部件引入你的应用程序,即使实现未知或不本地化,而 IoC 更侧重于松散耦合已知的部件。 这仍然留下了一个问题:何时使用 MEF? 最简单的解释就是,当你想为你的应用程序提供插件时。 然而,我认为使用 MEF 有更多原因。 在我看来,使用 MEF 的主要原因如下:

  • 允许用户为你的应用程序开发自己的插件。
  • 允许他人扩展或修改你的应用程序的工作方式,而无需访问或更改源代码。
  • 易于测试(只需更改 DLL 的来源,你就可以使用你的应用程序来处理一套完整的模拟对象)。
  • 松散耦合库项目到你的应用程序,特别是当它们被多个项目使用时。

最后一个原因是相当广泛的。 让我举个例子。 假设你创建了一种安全访问数据库的标准方法。 也许你有一些自定义数据访问业务逻辑。 最终,你得到了一个暴露 `ReadData` 和 `WriteData` 两个方法的接口。 你想在所有内部构建的应用程序中使用这个 DLL。 你可以将 DLL 放在每个服务器的 GAC 中,或者将其附加到每个项目中。 这两种解决方案都有其优点,但现在你有第三个选择。 你可以将其集中存储并通过 MEF 加载。 然后,你可以在需要时轻松更改它,而无需进行大型的部署问题(只要你不更改接口,而你永远不应该更改接口)。

总之,使用 MEF 有很多原因。 随着你对 MEF 越来越熟悉,你会在应用程序中看到越来越多可以做得更好的地方。 这是你工具箱中真正想要的一个工具。

目标读者

当我开始学习如何使用 MEF 时,有很多资源帮助了我。 我找到了实际的文章,展示了如何在真实应用程序中使用框架,也找到了深入解释某些功能的文章。 我似乎从未找到一篇同时兼具实用性和深度的文章。 我最终不得不从各个来源搜集零散的知识。

本文旨在以实际的方式深入探讨 MEF 是什么。 我将尝试从头到尾带你了解 MEF。 在此过程中,我将构建示例应用程序,以便你能确切地看到每个功能是如何工作的。 对于那些熟悉 MEF 的人来说,本文按功能划分,以便你可以快速找到你需要的帮助区域。

示例应用程序

我开发了一个示例应用程序,涵盖了下面的每个主题。我尽量让示例保持简单易懂,同时又具备实用性。在附件的代码部分,你会找到一个包含八个项目的解决方案。在每个部分的开头,我都会告诉你查找该部分示例的对应项目。每个项目开头都有文本说明它涵盖了哪些部分。最后,处理特定部分的也用部分名称进行了标记。对于项目 Example05(涉及外部库),我创建了另外两个项目(Common 和 ExternalLibrary)。

理论

MEF 用于在你的应用程序中设计“接口”。 它可以创建单用途接口(想象一下 VGA 接口)和多用途接口(想象一下 USB 接口)。 它还规定了如何创建插入这些接口的“插头”。 听起来很复杂,但实际上它不过是正确使用接口和一点标记。 我可以长篇大论地解释它的设计原理和功能,但这可能难以理解。 相反,让我们直接深入研究如何使用 MEF,然后我们可以回到理论。

基础知识

在你的应用程序中,MEF 有三个基本部分。 如果我们继续笔记本的比喻,我们可以将这三个部分可视化为笔记本上的 USB 接口、一个带有 USB 连接器的外部硬盘驱动器,以及将 USB 连接器插入接口的手。 在 MEF 术语中,接口定义为 `[Import]` 语句。 这个语句放在属性上方,告诉系统这里需要插入某个东西。 USB 线被定义为 `[Export]` 语句。 这个语句放在类、方法或属性上方,表示这是要插入某个地方的项目。 一个应用程序可以(而且很可能会)有很多这样的导出和导入。 第三个部分的工作是找出我们有哪些接口,从而需要插入哪些线缆。 这就是 `CompositionContainer` 的工作。 它就像手一样,插入与相应接口匹配的线缆。 那些不匹配接口的线缆将被忽略。

简单示例

示例项目名称:Example01

让我们看一个简单的例子。 如果你对 MEF 进行过任何研究,你可能已经见过这种类型的示例了,但我们需要先爬行才能行走。 这个应用程序将有一个 `string`,它在运行时导入一个值。 我们将导出一个消息放入 `string`,然后显示该字符串以证明它有效。

要跟随这个例子,只需在 Visual Studio 中启动一个类型为“控制台应用程序”的新 C# 项目。 创建项目后,你将拥有一个“Program.cs”文件。 在该文件中,你应该看到以下代码:

static void Main(string[] args)
{
}

由于这是一个静态应用程序,我们需要创建一个实例而不是直接使用它,以便我们可以使用 MEF。 创建一个新的 void 方法,名为 Run,然后在 Main 方法中添加代码来实例化程序并调用 Run 方法。 你的代码现在应该如下所示:

static void Main(string[] args)
{
    Program p = new Program();
    p.Run();
}

void Run()
{
}

现在我们已经设置好了基础结构。 这些行没有任何 MEF 特定的内容,所以我希望将这些代码与我们的 MEF 代码分开,以保持清晰。 现在让我们进入实现基本 MEF 所需的步骤。

步骤 1:你需要在应用程序中添加对 `System.ComponentModel.Composition` 的引用。 MEF 在这里。 这依赖于 .Net 4.0 框架。 理论上,你可以从 Codeplex 下载它,并(大部分)使其在 .Net 3.5 上运行,但这不受支持且不推荐。

步骤 2:在你的 Program.cs 文件中添加一个 using 语句,引用 `System.ComponentModel.Composition`。 这将允许我们直接使用 Import 和 Export 语句。 它还允许我们稍后调用容器上的“ComposeParts”方法(现在只需记住这个信息——稍后会明白)。

步骤 3:添加一个类型为 string 的类级变量,名为 `message`。 在该变量的上方,添加一个 `[Import]` 语句。 你的代码应该如下所示:

[Import]
string message;

这就是我们的“接口”。 我们要求我们的手找到一个适合字符串变量的插头。

步骤 4:在 Program 类之外添加一个新的类(它可以是不同的 cs 文件,也可以是同一个文件——为了简单起见,我把它放在同一个文件里)。 将其命名为 `MessageBox`。 在该类内部,添加一个名为 `MyMessage` 的字符串属性,该属性在 get 调用时返回一个示例消息。 在属性的上方,添加 `[Export]`。 你的类应该如下所示:

 public class MessageBox
{
    [Export()]
    public string MyMessage
    {
        get { return "This is my example message."; }
    }
}

`MyMessage` 属性是我们的“插头”,将插入上面定义的字符串“接口”。 我们已经定义了接口和插头,但我们还没有完成。 如果我们现在停止,应用程序会运行,但什么都不会发生。 原因是,我们还没有定义将插头插入接口的方法。 这将在下一步。

步骤 5:这是 MEF 新手可能会感到困惑的步骤,因为实际上包含了好几个步骤。 我将带你逐步了解此步骤的每个部分,以便你确切地了解正在发生的事情。 最后,我们将把此步骤中的所有代码放入一个名为 `Compose` 的辅助方法中。 这将使我们的应用程序能够以干净的方式挂钩所有内容。 为了使此操作更简单,我们还应该添加一个 `System.ComponentModel.Composition.Hosting` 的 using 语句。 这将使下面的代码更简洁。

首先,我们需要创建一个包含所有导出和导入的目录。 这只是一个关于什么被导出和什么需要被导入的清单。 目录需要知道去哪里查找。 在我们的例子中,我们将指向执行的程序集。 这告诉目录盘点我们当前的应用程序。 这可能显得多余,但别忘了 MEF 是设计用来加载外部资源作为插件的。 在更复杂的应用程序中,你需要创建多个目录。 但现在,我们只创建这一个目录,如下所示:

AssemblyCatalog catalog = new AssemblyCatalog(System.Reflection.Assembly.GetExecutingAssembly());

接下来,我们需要将这个目录放入 `CompositionContainer`。 这将为我们提供组合(挂钩)目录中的导出和导入所需的方法。 代码如下:

CompositionContainer container = new CompositionContainer(catalog);

最后,我们需要实际挂钩一切。 在这种情况下,可以通过告诉我们的新容器组合部件 (`ComposeParts`) 或仅满足一次导入 (`SatisfyImportsOnce`) 来实现。 无论哪种情况,我们都需要传入需要满足导入的实例(需要插入接口的地方)。 我们将通过传入 `this` 关键字来实现,它告诉方法查看我们当前的 Program 实例以查找导入语句。 因为我们使用的是如此简单的示例,所以上面列出的任何一种组合方法都可以工作。 区别在于 `SatisfyImportsOnce` 语句只会挂钩一次部件。 这意味着对我们的部件目录的任何更改(添加或删除部件)都不会自动反映到我们的应用程序中。 我们稍后会更深入地讨论这个高级主题。 但现在,我们将使用最简单的实现,如下所示:

container.SatisfyImportsOnce(this);

现在我们有了我们的辅助方法。 如果你一直跟着做,你应该有一个方法如下:

private void Compose()
{
    AssemblyCatalog catalog = new AssemblyCatalog(System.Reflection.Assembly.GetExecutingAssembly());
    CompositionContainer container = new CompositionContainer(catalog);
    container.SatisfyImportsOnce(this);
}

步骤 6:要使这一切正常工作,我们需要做的最后一件事是让我们的 `Run` 方法调用 `Compose` 方法,然后显示我们的 message 变量的值。 由于这是一个控制台应用程序,我也会在方法末尾添加一个 `Console.ReadLine`,以便在我们按下某个键之前应用程序不会关闭。 这是完整的 Run 方法:

void Run()
{
    Compose();
    Console.WriteLine(message);
    Console.ReadKey();
}

这就是创建一个简单的 MEF 应用程序所需的一切。 如果你现在运行这个应用程序,你将在屏幕上看到消息“This is my example message.”。 如果你得到了与我不同的结果,或者只想查看工作文件中的最终结果,请参阅我附加代码中的 Example 01。

总结一下,我们创建了一个新的控制台应用程序,其中有一个类型为 `string` 的“接口”,名为 message。 我们设计了一个类型为 `string` 的“插头”,它返回消息“This is my example message.”。 然后我们设置了一个容器,它接受我们的部件目录并(一次性)将它们挂钩。 这允许我们显示 message 变量的值,该变量已从我们的“插头”中填充了值。

生成的疑问

有很多简单的 MEF 演示到此结束,但对我来说,这个演示产生了很多问题,所以在我们继续之前,让我们先回答其中一些问题。

  1. 当我执行 `SatisfyImportsOnce` 或 `ComposeParts` 方法时,我是立即创建了我所有需要的导入的实例吗? 是的。
  2. 有趣。 能详细说说吗? 在大多数情况下,立即创建每个导入的实例是可以的。 然而,如果你可能不使用所有实例,或者你想稍后加载它们,你可以使用延迟加载你的导入。 我们将在下面深入探讨。
  3. 如果我导出一个类,然后在两个位置导入它,我会得到该类的两个实例吗? 默认情况下,MEF 将每个导出视为一个 `Singleton`……嗯,可以说是。 如果所有默认设置都已启用,你将获得一个单例。 有关默认值如何工作的详细说明,请参阅“对象实例化”部分。
  4. 我能创建被标记为导出的“常规”类实例吗? 是的,你可以。 但请注意,类内部的任何导入语句都不会被满足,因为实例是在 MEF 满足导入之后创建的。 你可以通过在 `container.SatisfyImportsOnce(this);` 这行代码中传入实例引用来改变这一点。
  5. 如果我使用 MEF,我应该在应用程序的任何地方都使用它吗? 如果你有一把锤子,你会用它来满足你所有的家居维修需求吗? 如果是这样,你可能在家庭维修方面并不比我好。 直接回答你的问题,不,MEF 只是你可以在应用程序中使用的工具之一。 它非常擅长处理一种需求(插件)。 它可以做其他事情(IoC/DI),但那不是它的设计目的。 MEF 就像锤子一样,可以做很多事情,但有时最好使用其他工具。
  6. 如果我想要很多同一个对象,我该怎么办? 根据你的需求,有几种方法可以处理。 我们可以使用不同的接口(如果你希望同一个对象在应用程序的不同部分以不同的方式使用),我们可以将其导入多个变量(如果你想在代码的多个位置使用同一个对象),或者我们可以使用 `ImportMany`(如果你想要多个具有相同签名的不同对象)。 我们将在下面看到所有这些解决方案。

允许 Null 导入

示例项目名称:Example02

有时你可能需要一个可能匹配也可能不匹配导出项的导入。 以我们的笔记本电脑为例,如果我们没有填满所有的 USB 接口,我们不希望笔记本电脑爆炸。 然而,如果我们不更改任何默认设置,这就是我们的应用程序在没有填满所有导入语句时会发生的情况。 我们可以拥有任意数量的非匹配导出,但第一个没有匹配导出的导入语句将引发异常。 如果这不是你想要的,有一种方法可以改变默认行为。 在导入语句上,你可以添加命名的参数来修改导入的工作方式。 其中一个参数称为 `AllowDefaults`。 如果将其设置为 true,MEF 将尝试满足导入语句,但如果失败,它将在变量中放置一个 null 值。 导入将如下所示:

[Import(AllowDefault = true)]
string message;

在你的代码中,请确保在以后测试该变量,以确保它不是 null,然后再使用它。 否则,你只是将应用程序崩溃的异常转移到了应用程序的稍后生命周期。

对象实例化

示例项目名称:Example02

如前所述,默认情况下,MEF 创建每个导出作为 `Singleton`。 这在技术上是正确的,并且许多描述都会在这里停止。 然而,事实有点复杂。 在 MEF 中,有三个 `PartCreationPolicy` 值可以指定。 这三个值是 `Shared`、`NonShared` 和 `Any`。 默认情况下,所有导出都使用 `Any`。 如果导入语句未指定它正在查找的 `PartCreationPolicy` 的类型,则 `Any` 策略将转换为 `Shared`。 `Shared` 策略是 `Singleton`,当然,`Non-Shared` 表示每次导入时都会创建一个新实例。

示例 1:首先,我们将看最简单的例子,以便我们先熟悉一下:

[Export, PartCreationPolicy(CreationPolicy.Any)]
public class MyClass {…}

[Import]
MyClass _port1;

[Import]
MyClass _port2;

在这个示例中,`_port1` 和 `_port2` 变量将获得 `MyClass` 对象的同一个 `Singleton` 实例。 导出语句在功能上等同于 `[Export]`。 我明确设置了 `PartCreationPolicy` 来展示默认情况下发生了什么。

示例 2:接下来,让我们看看如何更改导出语句以在每次导入时创建一个实例:

[Export, PartCreationPolicy(CreationPolicy.NonShared)]
public class MyClass {…}

[Import]
MyClass _port1;

[Import]
MyClass _port2;

这次,`_port1` 和 `_port2` 将获得 `MyClass` 对象的独立实例。 实例的创建是通过反射完成的,这可能会有点耗费资源。 有办法减少这种成本,但它们超出了本文篇幅的范围。

示例 3:我已经暗示过,你可以指定导入接受哪种类型的 `PartCreationPolicy`。 让我们看看如何做到这一点:

[Export, PartCreationPolicy(CreationPolicy.Shared)]
public class MyClass {…}

[Import(RequiredCreationPolicy = CreationPolicy.Shared)]
MyClass _port1;

[Import]
MyClass _port2;

在这种情况下,`_port1` 变量要求导出是 `Shared`。 因为我们的导出是 `Shared`,`_port1` 将像往常一样获得 `MyClass` 对象的 `Singleton` 实例。 `_port2` 变量也将获得同一个 `Singleton` 实例。 但是,如果我们把导出改为 `NonShared`,我们会在 `_port1` 变量上得到一个错误,因为没有导出能匹配必需的导入。

示例 4:这引出了我们最初关于默认导出是 `Singleton` 但导入也必须默认才能实现这一点的问题。 让我们看一个有趣的例子:

[Export, PartCreationPolicy(CreationPolicy.Any)]
public class MyClass {…}

[Import(RequiredCreationPolicy = CreationPolicy.Shared)]
MyClass _port1;

[Import(RequiredCreationPolicy = CreationPolicy.NonShared)]
MyClass _port2;

我敢打赌你很想知道这里会发生什么。 我再次在导出上指定了 `Any`,以便你能看到它。 我们本可以直接写 `[Export]`。 当 `_port1` 变量请求一个具有 `Shared` 部分创建策略的对象时,它会获得 `MyClass` 对象的 `Singleton` 实例。 然而,当 `_port2` 变量请求一个具有 `NonShared` 部分创建策略的对象时,它会获得 `MyClass` 变量的一个新实例。 通过将 `PartCreationPolicy` 指定为 `Any` 或不指定任何策略,导出将变成导入所需的内容。 如果导入中没有指定任何内容,你将获得一个 `Singleton`。 请确保你理解这一点。 如果你期望你的导出总是 `Singleton`,即使只标记为 `[Export]`,而有人决定在导入上指定 `RequiredCreationPolicy` 为 `NonShared`,那么你可能会遇到麻烦。

指定导出类型

示例项目名称:Example02

当你将一个对象标记为导出时,默认情况下 MEF 会查看对象的类型,并将其与需要该类型的导入匹配。 因此,当我们导出字符串时,它会与类型为 string 的导入匹配。 然而,在示例应用程序之外的任何情况都会出现问题,因为你可能希望导出多个字符串类型的对象。 如果存在多个可以满足导入的导出,MEF 将会抛出错误,因为它不知道使用哪个导出(请参阅下面的“注意事项”)。 有一种方法可以通过使用 `ImportMany`(有关此主题的更多信息,请参阅下方)将多个导出实例合并到一个导入中,但最有可能的是,我们希望匹配特定的导出到特定的导入。 做到这一点的方法是向 MEF 描述此导出。 你可以通过多种方式做到这一点。 第一种方法是指定一个契约名称。 这可以是任何你选择的名称。 例如:

[Export(“MyConfigInfo”)]
public class MyClass  {…}

[Import(“MyConfigInfo”)]
object _port1;

有几点需要注意。 首先,我使用了一个魔术字符串来标识我的导出。 我需要使用相同的魔术字符串来标识导入。 如果我没有在导入语句中使用 `“MyConfigInfo”`,MEF 将会抛出错误,说它找不到我想要的导出(请参阅“注意事项”)。 其次,我改变了 `_port1` 的类型,以表明类型不再需要匹配。 由于我们明确说明了导入和导出如何相互识别,变量的类型无关紧要。 但这并不意味着你可以将 `string` 放入 `int` 变量中。 这仍然会引发错误(能集体喊一声“ Duh”吗?)。 我不推荐使用契约名称来标识导出。 原因是,你依赖于正确输入所有内容,并且无法使用 Intellisense。 但是,这只是我遵循的一个经验法则。 在某些情况下,这是最佳解决方案。 一如既往,选择最适合你环境的方法。

我们也可以通过类型在 Export/Import 中识别对象。 这种方法确实可以使用 Intellisense,并且可以更简洁(在我看来,但如果你不同意,随时在下面尽情地嘲讽我)。 当你导出类对象时,你可以利用 `Interfaces`。 例如:

[Export(typeof(IConfigInfo))]
public class MyClass : IConfigInfo {…}

[Import(typeof(IConfigInfo))]
object _port1;

这相当直接。 基本上,如果你熟悉面向接口编程,你已经准备好了。 在你的导出语句中,只需指定用于标识导出的类型,然后对导入做同样的事情。

如果你真的想更具体,你可以同时指定契约名称和契约类型。 基本上,它将上述两种情况结合起来。 最终结果将如下所示:

[Export(“MyInfo”, typeof(IConfigInfo))]
public class MyClass : IConfigInfo {…}

[Import(“MyInfo”, typeof(IConfigInfo))]
object _port1;

你可能会问一个问题,那就是如果你同时导出了契约名称和契约类型,但只根据其中一个标准导入,会发生什么? 答案是,它将不起作用。 如果你在导出中指定了具体信息,你必须在导入中具有完全匹配的特异性。 唯一的例外是当你没有在导出或导入中指定类型时。 由于 MEF 实际上隐含了类型,你可以在一个地方隐式,在另一个地方显式,连接仍然可以工作。

好的,到目前为止都明白了吗? 太好了,因为我们要让它变得更复杂。 到目前为止,我们讨论了导出类和值类型等对象。 还有一个领域我们还没有触及,那就是方法。 MEF 允许我们像导出整个类一样导出和导入方法。 就像其他一切一样,你可以指定契约名称和/或契约类型。 这里稍微不同的一点是,我们可以使用 `Action` 或 `Func` 这样的委托来指定契约类型。 这是一个同时使用契约名称和契约类型的示例:

[Import("ComplexRule", typeof(Func<string, string>))]
public Func<string, string> MyRule{ get; set; }

[Export("ComplexRule", typeof(Func<string, string>))]
public string ARule(string ruleText)
{
    return string.Format("You passed in {0}", ruleText);
}

要调用该规则,你可以这样做:

Console.WriteLine(MyRule("Hello World"));

我确保这个例子有点高级,以便你能确切地看到它是如何工作的。 如果你以前没有使用过委托和匿名类型,你可能需要先回顾一下它们,然后再继续学习 MEF 的这部分。 同时学习两种技术并一起使用它们是调试灾难的根源。

所以,我们在这里所做的是导出一个有一个参数(类型为 `string`)和一个输出(类型为 `string`)的方法。 我们将其导入一个属性,然后像调用本地方法一样调用它。 我们还为方法导出应用了一个自定义名称,以便与具有相似签名的其他方法区分开来。

继承导出

示例项目名称:Example03

MEF 的一个真正强大的功能是 `InheritedExport` 的概念。 通过这个功能,你可以确保任何继承了带有 `[InheritedExport]` 标记的项的类都将被导出。 这就像一个导出语句一样,所以你可以有一个契约名称和/或契约类型。 在深入探讨它的好处之前,让我们先看一个例子:

[InheritedExport(typeof(IRule))]
public interface IRule


public class RuleOne : IRule {…}
public class RuleTwo : IRule {…}

即使 `RuleOne` 和 `RuleTwo` 没有明确的导出语句,它们都将被导出为 `IRule` 类型(顺便说一句,请反复阅读这个接口名称,直到你感觉好一些——我不仅是程序员,还是自我帮助教练)。 现在,看一下这个场景,思考一下可能性。 首先,如果你将 `Interface` 放在不同的程序集中,`RuleOne` 和 `RuleTwo` 所在的程序集就不需要知道 MEF。 你还保证了导出名称和类型是正确的。 最后,你(大部分)确保实现接口的合同部分是具体类被导出为一个部分。 我说大部分是因为有一个关键字可以用来阻止导出。 你可以在类的顶部使用 `[PartNotDiscoverable]` 标签来指定该类不应被 MEF 库发现。 该类仍然可以显式添加(我们稍后会讲到),但它不会被 `ComposeParts` 或 `SatisfyImportsOnce` 方法发现。

导入多个相同的导出

示例项目名称:Example03

如果你测试了上面的示例,你可能会遇到一个错误,说“找到了多个匹配约束的导出”(参见“注意事项”)。 这是因为你有一个导入语句和多个匹配你导入的导出语句。 用我们的类比来说,我们有两个 VGA 线,但只有一个 VGA 接口。 我们的手不知道该插入哪一个。 有时这是我们自己的错误。 我们定义了两个完成相同工作的部件。 然而,有时我们就是希望这样。 一个常见的例子是一组需要运行的规则。 对于任何给定的场景,你只拥有一条规则的可能性很小。 相反,你可能会有很多规则。 这就是 `[ImportMany]` 语句的用武之地。 它允许我们导入零个或多个匹配的导出项。 你注意到吗? **一个 `ImportMany` 语句在你没有匹配的导出语句时不会失败**。 但是,它会初始化 `ImportMany` 语句所在的集合。 这可以防止在调用集合时发生集合未初始化的错误。 让我们看看这是如何工作的:

[ImportMany(typeof(IRule))]
List<IRule> _rules;

这个 `ImportMany` 语句将与上面的 `InheritedExport` 示例一起工作。 现在你可以使用 `foreach` 循环遍历 `_rules` 列表,并单独调用每个规则。

延迟实例创建

示例项目名称:Example03

我们之前讨论过 MEF 在运行 `ComposeParts` 或 `SatisfyImportsOnce` 时创建所有必需导入实例的事实。 通常这应该是可以的。 新创建的类实例通常不会占用太多内存。 然而,有时你可能希望延迟实例化一个或一组对象。 为此,只需使用 Microsoft 已经为我们提供的 `Lazy<T>` 进行延迟实例化即可。 导出端没有任何改变,只有导入端需要像这样修改:

[Import]
Lazy<MyClass> myImport;

请注意,这并不是真正的 MEF 技术,而是 MEF 有效地使用 `Lazy<T>`。 它不是 MEF 特有的实现。 别忘了,当你延迟加载某项时,你需要使用 `Value` 属性来访问你实际想要的实例。 在我们的例子中,它看起来会像这样:

myImport.Value.MyMethod()

使用延迟加载来延迟实例化某些类肯定有其用处,特别是如果你不确定是否会使用所有导入的实例,或者某些导入会消耗大量资源。 同样,仅仅因为你找到了一个新工具,并不意味着它是所有工作的最佳工具。

描述导出

示例项目名称:Example04

现在我们已经学习了如何使用 `ImportMany` 将一组匹配的导出带入一个数组,以及如何等到需要时再实例化我们的导入,导出装饰的问题就出现了。 如何在不查看其内部的情况下区分一个导出与另一个? 答案是元数据。 我们可以将元数据附加到我们的导出上,以向导入应用程序描述有关导出的信息。 例如,假设我们要导入许多业务规则。 每个规则都有相同的签名,但处理如何处理不同状态的记录。 这可能是使用延迟加载的绝佳机会,因为我们很可能不会对每种状态都使用这个规则。 这也是使用元数据的绝佳机会,因为我们可以描述每个规则处理的状态。 让我们看一个例子。 这是我们的导出类对象:

public interface IStateRule
{
    string StateBird();
}

[Export(typeof(IStateRule))]
[ExportMetadata("StateName","Utah")]
[ExportMetadata("ActiveRule", true)]
public class UtahRule : IStateRule
{
    public string StateBird()
    {
        return "Common American Gull";
    }
}

[Export(typeof(IStateRule))]
[ExportMetadata("StateName", "Ohio")]
[ExportMetadata("ActiveRule", true)]
public class OhioRule : IStateRule
{
    public string StateBird()
    {
        return "Cardinal";
    }
}

我在每个导出中添加了两项元数据,只是为了向你展示你可以堆叠它们。 我只会根据一项数据(StateName)进行过滤,但我可以根据它们是否处于活动状态来过滤这些导出。 这样我们就可以针对同一状态有多个导出。 如果我们将激活日期进行过滤,那会更好,这样我们就可以始终获取最新版本,但旧版本也可以保留,以防我们需要它们来处理旧记录等等。

这是我们导入这些类对象的方式:

[ImportMany(typeof(IStateRule))]
IEnumerable<Lazy<IStateRule, IDictionary<string, object>>> _stateRules;

请注意,我们正在对一个变量使用延迟加载、[ImportMany] 和导出元数据。 哇。 这是多少不同的项目挤在两行代码里。 如果我们之前没有讨论过这些概念,它可能会非常令人困惑,但我相信现在对你来说很简单。 不过,万一不清楚,让我解释一下。 我们正在进行 ImportMany,以便我们可以引入多个具有相同签名的项目。 在我们的例子中,签名是导出将是 IStateRule 类型。 我们将这一组放入 IEnumerable,以便我们可以循环遍历导入规则的集合。 接下来,我们指定项目将是 `Lazy<T>`,这样它们就不会立即加载,而是仅在被调用时加载。 最后,在指定要导入的项目类型(`IStateRule`)之后,我们添加一个参数来指定一个 IDictionary 集合。 这就是元数据声明。

现在我们已经设置好了一切,下面是我们如何访问元数据。 在这个例子中,我只想调用犹他州的规则:

Lazy<IStateRule> _utah = _stateRules.Where(s => (string)s.Metadata["StateName"] == "Utah").FirstOrDefault();
if (_utah != null)
{
    Console.WriteLine(_utah.Value.StateBird());
}

上面的部分的第一个语句是一个简单的 `Linq` 查询,它将我指定的条件设置为第一个匹配项。 在这种情况下,我指定“StateName”值为“Utah”。 这将把 `Lazy<IStateRule>` 副本放入我的本地变量 `_utah` 以供使用。 我在 `Linq` 查询中没有尝试获取 `IStateRule` 的实际实例,因为如果找不到该值,系统将会抛出异常。

通常我尽量保持示例简单且有针对性,所以我会避免标准的错误处理和其他基础结构。 然而,在这种情况下,我认为包含一些基本的错误处理功能是最好的。 由于我们在 `Dictionary` 中使用简单的 `string` 键,因此在每个导出中没有列出正确的配对的可能性非常大。 我在这里所做的是确保使用 `FirstOrDefault` 方法,这样如果查询没有返回任何数据,它就不会失败。 然后我也会检查 `_utah` 变量是否不为 `null`,然后再尝试调用它。

<<举手>>

我看到了那只手,但我已经知道你要问什么:“**为什么在这个例子中没有使用 `[InheritedExport]`?**” 我希望你不要问,因为我没有一个非常令人满意的答案。 既然你问了,我就给你我所知道的。 当你在这个示例中对 `IStateRule` 接口使用 `[InheritedExport]` 标签时,元数据不会与导出一起导出。 据我所知,这是因为元数据需要在导出本身的同时导出,而 `[InheritedExport]` 标签似乎在不同的时间处理。 我已经尝试了多种技巧来让它工作,但都没有成功。 如果你知道如何实现这一点(无需变通),请告诉我。 我一定会在此发布你的解决方案并给你应得的荣誉。

高级元数据

示例项目名称:Example04

为了使元数据更加有用,我们不是使用 `Dictionary<string, object>`,而是可以指定自己的类或接口。 最简单的方法是在应用程序的导入端进行。 你只需将 `Dictionary<string, object>` 替换为你的接口或类名,如下所示:

[ImportMany(typeof(IStateRule))]
IEnumerable<Lazy<IStateRule, IStateRuleMetadata>> _stateRules;

我的接口看起来像这样:

public interface IStateRuleMetadata
{
    string StateName { get; }
}

这里要注意的一点是,我将我的属性设为只读(只有 getter)。 这是故意的。 MEF 如果你试图包含 setter 会抛出错误。 然而,不需要 setter,所以这没问题。 要访问此元数据,请使用元数据点属性,如下所示:

Lazy<IStateRule> _utah = _stateRules.Where(s => (string)s.Metadata.StateName == "Utah").FirstOrDefault();

正如你所见,我导入端的代码不再使用字符串作为键名,而是使用属性名。 这允许我在设计时利用 Intellisense,这使我的开发更轻松,并使我更容易避免难以调试的错误。

我敢肯定你一定在想导出端有什么变化。 答案是没有任何变化。 你仍然使用字符串指定元数据属性(没有 Intellisense)。 MEF 在运行时负责如何将这些挂钩到你的元数据类或接口。 这尤其酷,特别是如果你像我一样使用接口作为元数据,因为你的接口实际上从未在你的代码中实现过。 MEF 为你实现它。

加载其他程序集

示例项目名称:Example05

到目前为止,我们一直关注如何让当前程序集中的导出与我们的导入匹配。 MEF 为我们提供了比这更多的选项,这很重要。 让我们看看我们可以使用的不同选项。

程序集目录 (Assembly Catalog)

这是我们到目前为止一直在使用的目录。 你传入一个特定的程序集,MEF 会处理该程序集内部件的发现。 在我们的例子中,我们一直传入对当前程序集的引用(`Assembly.GetExecutingAssembly`)。

目录目录 (Directory Catalog)

这是有趣的那个。 它接受目录的相对或绝对路径。 MEF 将扫描该目录(但不扫描子目录)中标记有 MEF 导出的程序集。 它会将找到的任何部件添加到目录中。 你可以通过添加一个指定程序集名称格式的参数来进一步细化。 例如,你可以放入 `*.dll` 来只包含 dll 文件,或者你可以放入 `CompanyName*.dll` 来只包含以你的公司名称开头的 dll。 这是一个使用相对路径和过滤器设置目录目录的示例:

DirectoryCatalog catalog = new DirectoryCatalog("Plugins", "*.exe");

我决定搜索一个 exe 文件只是为了表明它不必是 dll。 请注意,文件夹名称不需要包含任何前导字符,如斜杠或点。

类型目录 (Type Catalog)

这是我个人不使用的。 这个目录的目的是只加载目录中指定类型的导出。 要指定类型,你需要将它们直接添加到构造函数行。 为了完整起见,我将在下面包含一个示例:

TypeCatalog catalog = new TypeCatalog(typeof(IRule), typeof(IStateRule));

注意我添加了两种类型,但可以根据需要添加更多类型。

聚合目录 (Aggregate Catalog)

当一个目录不够时,你可以创建一个聚合目录。 这个目录只是一个目录的目录。 你可以将任意数量的目录添加到这个目录中。 只需将你的目录传递给构造函数即可。 大多数人为了简单起见,会在聚合目录的构造函数行中创建他们各自的目录,这样他们就不必为每个目录创建单独的变量。 我将向你展示这种技术:

AggregateCatalog catalog = new AggregateCatalog(new AssemblyCatalog(Assembly.GetExecutingAssembly()), new DirectoryCatalog(@"C:\Plugins"));

如你所见,我在 `AggregateCatalog` 中创建了两种不同类型的目录(`DirectoryCatalog` 和 `AssemblyCatalog`)。 这将使我的系统能够发现内部部件以及 temp 文件夹中所有程序集中的部件。 请注意,我使用了字符串字面量字符(@)来避免在我的文件夹路径中转义斜杠。

在运行时添加新程序集

示例项目名称:Example05

既然我们知道了如何用 MEF 加载一个文件夹中的所有程序集,那么动态添加程序集的想法就不远了。 这将允许我们删除新的 dll 文件到我们的文件夹,并让当前运行的应用程序加载并使用它们。

为了实现这一点,我们需要结合几件事。 首先,我们将需要使用 `DirectoryCatalog`。 这是将要发生更改的地方(因为我们不会动态更改当前程序集代码)。 接下来,我们需要创建一个变量来保存目录目录,而不是在 `AggregateCatalog` 构造函数中创建新实例。 我们还需要确保将此变量的作用域设定在可以稍后访问它的范围内。 最后,我们需要某种触发器来告诉我们何时应该告诉 `DirectoryCatalog` 更新(从而触发重新组合)。

在实际应用程序中,你很可能会在你的插件文件夹上放置一个 `FileSystemWatcher`,并让该事件触发 `DirectoryCatalog`。 执行此操作超出了本文的范围。 相反,我将向你展示 MEF 端所需的代码。 如何触发该代码由你自己决定。

重新组合的实际实现相当简单。 首先,你需要确保使用 `ComposeParts` 方法而不是 `SatisfyImportsOnce` 方法。 然后,你需要将任何可能获得新部件的 `Import` 或 `ImportMany` 语句标记为参数“`AllowRecomposition=true`”。 然后,你要么向 `CompositionContainer` 添加新内容,要么触发你的 `DirectoryCatalog` 的 `Refresh()`。 部件看起来像这样:

[Import(AllowDefault = true, AllowRecomposition=true)]
string test;

dirCatalog.Refresh();

我还为我的 Import 添加了 AllowDefault 的命名参数,只是为了演示它的用法。 如果你正在导入单个导出,并且将允许对此部件进行重新组合,这也可能很有用。 如果系统没有立即检测到匹配的导出,你不会希望系统崩溃。 到你使用该变量时,重新组合可能已经发生,并且该部件可能已经填充。

请注意,当部件重新组合时,如果一个部件被添加到 ImportMany 中,该列表中的所有部件都会被重建。 这是设计使然,但这意味着你需要理解重新组合将如何影响你的应用程序,并据此做出开发决策。 此外,`AppDomain` 会一直将 dll 保存在内存中,直到它被重启。 如果这样做,你将需要使用 `ShadowCopy` 来删除当前正在使用的 dll。 如果这样做,重新组合将从你的程序集中删除这些部件(但仍会保留 dll 的副本在内存中)。 你的应用程序将正常工作,但并不完全干净。

MEF 的其他特性

我们已经讨论了 MEF 的主要功能,但仍有许多领域我们尚未涵盖。 其中一些很少使用,但至少有几个我认为更有用。 以下是我认为最有用的一些(不分先后)。

构造函数注入

示例项目名称:Example06

MEF 为我们提供了一个如何引入导出的选项。 通常,我们会用 `[Import]` 标签装饰一个变量。 然而,还有一个选项可以通过构造函数注入来填充变量。 最简单的方法是像这样在你的专用构造函数上添加 `[ImportingConstructor]`:

public class MyClass
{
    [ImportingConstructor]
    public MyClass(IRule myRule)
    {
        _myRule = myRule;
    }
}

我为我的类创建了一个专用构造函数,它接受一个接口作为参数。 然后我用 `ImportingConstructor` 标签装饰了构造函数,以表明我希望 MEF 在组合时填充这个构造函数。 MEF 将像对待没有修饰符的 `[Import]` 语句一样推断出它需要的部件。 然而,如果你想添加修饰符来明确告诉 MEF 你需要哪些部件,你可以这样做:

public class MyClass
{
    [ImportingConstructor]
    public MyClass([Import(typeof(IRule))] IRule myRule)
    {
        _myRule = myRule;
    }
}

这使得构造函数有点混乱,但你可以用 `[Import]` 或 `[ImportMany]` 标签在构造函数中完成你能够做的任何事情。 那么现在问题就来了“为什么?” 我认为这个方法最好的用例是当你想要控制部件创建时发生的事情。 例如,你可能希望初始化值或以其他方式设置你的系统。 例如,如果你有一个 `User` 类,它导入一个处理用户薪资信息的类实例,你可能希望立即将 UserID 传递给薪资实例,以便它知道它正在处理谁。

手动获取导出

示例项目名称:Example06

有时你可能想手动获取导出的部件,而不是使用导入语句。 以下是三种如何做到这一点的示例:

string manual1 = container.GetExportedValue<string>();
string manual2 = container.GetExportedValue<string>("ContainerName");
string manual3 = container.GetExportedValueOrDefault<string>();

第一个示例只是查找类型为 string 的部件。 第二个示例查找类型为 string 且具有指定容器名称的部件。 第三个示例展示了如何使用 `GetExportedValueOrDefault` 方法来完成同样的事情,这样如果找不到部件,系统就不会抛出异常。

我个人倾向于避免这种获取值的方式。 它很快就会变得丑陋,并且会让你将你的容器(在某个方法中——不要仅仅将其设为全局变量)暴露给比你通常需要的更广泛的受众。

在 MEF 完成时获取通知

示例项目名称:Example06

有时你可能需要知道导入过程何时完成。 这很简单。 首先,你需要实现 `IPartImportsSatisfiedNotification` 接口。 这个接口为你提供了 `OnImportsSatisfied()` 方法。 当所有导入都成功挂钩后,将触发此方法。

提前关闭 MEF

示例项目名称:Example06

有时你可能希望显式地关闭 MEF 并释放资源,然后才关闭你的应用程序的其余部分。 为此,请像这样调用容器上的 `Dispose()` 方法:

container.Dispose();

不同的部件将以不同的方式关闭,但基本上,容器中持有的部件将在容器被处置时被正确关闭和处置。

注意事项

在使用 MEF 的过程中,你会遇到错误和问题。 以下是一些最常见的问题及其解决方法:

  1. “未找到匹配约束的有效导出” – 这个错误基本上说明了问题。 MEF 没有找到一个导出项来挂钩你的导入。 为什么会发生这种情况有时会令人费解。 首先,检查你是否导出了期望类型的项。 然后,确保契约名称在两端完全相同(如果你正在使用)。 对契约类型也做同样的事情。 最后,如果一切都失败了,并且你确信所有内容都匹配,请确保你在给定的程序集中查找导出。 如果你只在 dll 文件中查找而忘记添加当前程序集,你可能会遗漏你的导出。
  2. “找到了多个匹配约束的导出” – 当你有一个 `Import` 语句时,你只能有一个匹配的 `Export`。 如果 MEF 找到两个,它不知道使用哪一个,所以它会抛出这个错误。 要么使用 `ImportMany`,要么修改其中一个 `Export` 语句使其具有不同的签名。 当你让 MEF 假定契约类型时,这个错误尤其常见。 如果你导出两个字符串,你将收到此错误。 尝试将一切都开发为 `Interface`。
  3. “导出不可分配给类型……” – 这意味着你指定了一个 `Import` 类型,但你不能将该类型放入变量中。 仅仅因为你称一个 `int` 为 `string` 并不能改变它。 另一种可能性是你正在尝试 `ImportMany`,但你意外地只放了 `Import`。 在这种情况下,你将尝试将一个项目放入类型为 `IEnumerable` 的项目中,这行不通(它试图分配而不是调用 `Add` 方法)。 反之亦然——如果你在一个变量上放了一个 `ImportMany`,但变量无法接受一组实例,你就会收到这个错误。

有关 MEF 错误的更多信息,MSDN 提供了一些很好的信息:http://msdn.microsoft.com/en-us/library/ff603380.aspx

使用 MEF 的方法

MEF 可以(并且正在)在整个 .NET 堆栈中使用。 常见的用法是与 WPF 和 Silverlight(请查看 Caliburn.Micro 以了解 MEF 与 MVVM 结合的出色实现)。 然而,你也可以将 MEF 与 ASP.NET 结合使用,使 MVC 更具可扩展性。 基本上,该技术几乎可以与任何东西一起使用。 更多的是要确保用例证明了该工具的合理性。

MEF 的未来

MEF 已经存在一段时间了。 目前,团队正在开发新版本的 MEF(目前称为 MEF2 - 聪明,对吧?)。 你可以通过阅读 BCL Team 博客来了解最新版本包含的内容。

结论

在本文中,我们从托管可扩展性框架的最初开始,并稳步地介绍了 MEF 的主要部分。 我们已经涵盖了简单的示例,也涵盖了我们很可能在生产应用程序中看到的更现实的案例。 我希望这对你有所帮助。 如果你发现有不清楚的地方,请在下面告诉我。 一如既往,我感谢你建设性的反馈。

历史

  • 2012年5月2日 - 初始版本
  • 2012年5月16日 - 添加了“注意事项”部分
  • 2012年5月19日 - 小错误修复 
  • 2012年5月21日 - 添加了“为什么我应该关心 MEF?”部分 
© . All rights reserved.