在过去几年中,依赖关系注入 (DI) 模式在 .NET 开发人员社区一直受到关注。长时间以来,优秀的博客作者们讨论着 DI 的优点。MSDN 杂志 针对这一主题发表了多篇文章。.NET 4.0 将发布某种类似 DI 的功能,并计划以后将其发展为完整的 DI 系统。
阅读有关 DI 的博客文章时,我注意到,这一主题有一个很小却很重要的倾向。作者们谈论的是如何在整个应用程序环境中使用 DI。但如何编写使用 DI 的库或框架呢?关注重点的变化,对模式的使用有何影响?这是几个月前我们研究 Enterprise Library 5.0 的体系结构时首先遇到的问题。
Microsoft Enterprise Library (Entlib) 是 Microsoft 模式与实施方案组开发的著名版本。迄今为止,其下载次数已超过两百万。可以想到的单位 — 从金融机构、政府机关到餐厅和医疗设备制造商 — 都在使用它。顾名思义,Entlib 是一种库,可帮助开发人员处理许多企业开发人员都会面临的问题。如果您不熟悉 Entlib,请访问我们的网站 p&p 开发中心,以了解更多信息。
Entlib 在很大程度上由配置驱动。它的大部分代码专用于读取配置,然后基于配置组合对象图。Entlib 对象可能非常复杂。大多数块都包含大量可选功能。此外,还有许多用于支持检测等功能的底层基础结构,它们也需要进行关联。我们不希望用户仅仅为了使用 Entlib 而去手动创建检测提供程序、读取配置,等等,所以将对象的创建封装在了工厂对象和静态外层之后。
Entlib 版本 2 到版本 4 的核心是一个名为“ObjectBuilder”的小型框架。ObjectBuilder 的作者将 ObjectBuilder 描述为“一种用于构建依赖关系注入容器的框架”。Enterprise Library 只是使用 ObjectBuilder 的 p&p 项目之一;其他使用 ObjectBuilder 的 p&p 项目包括 Composite UI Application Block、Smart Client Software Factory 和 Web Client Software Factory。Entlib 特别注重说明的“框架”部分,将一个很大的自定义功能集构建至 ObjectBuilder。读取 Entlib 配置和组合对象图时,需要使用这些自定义功能。在很多情况下,也需要用它们来改进现有 ObjectBuilder 实现的性能。
缺点在于,需要不少时间才能对 ObjectBuilder 本身(设计极为抽象,再加上完全没有文档,ObjectBuilder 的复杂性绝非虚言)和 Entlib 自定义功能都有所了解。因此,如果要编写与 Entlib 的对象创建策略有关的自定义块,一开始就需要进行大量学习,常常令人感觉困难重重。
此外,在 Entlib 4.0 中,我们发布了 Unity 依赖关系注入容器,这进一步增加了复杂性。DI 具有很多优点,我们希望确保为无法从众多优秀开放源代码容器中选用一种(无论什么原因)的客户提供一个很好的选择 — Microsoft 的 DI。当然,我们也希望在使用 Unity 时轻松实现 Entlib 对象的运行。在 Entlib 4.0 中,Unity 集成与现有 ObjectBuilder 基础结构一道,成为了并行对象创建系统。现在,块编写者不仅需要了解 ObjectBuilder 和 Entlib 扩展,还需要了解 Unity 内部机制,以及其中的部分 Entlib 扩展。这不是朝正确的方向前进。
2009 年 4 月,我们开始 Entlib 5.0 的开发。这一版本的主要目的是“以简化取胜”。这不仅包括为最终用户(调用 Entlib 的开发人员)进行简化,也包括对 Entlib 代码本身进行简化。通过这些改进,我们可以更方便地保持 Entlib 的进一步发展,客户也可以更方便地对它进行了解、自定义和扩展。
我们知道,有些重要方面需要改进,其中之一是对象创建管道。保留两个并行但不同的代码集实现同一功能会后患无穷。必须改变这种情况。
我们制定了以下重构目标:
现有客户端代码不必仅因体系结构更改而更改。可要求重新编译,但不可要求更改源代码(当然,客户端 API 可以因其他 原因进行更改)。可处理内部 API 或可扩展 API。
删除冗余对象创建管道。应只保留一种(而非两种或更多)创建对象的方式。
不使用 DI 的客户不应受在内部使用 DI 的 Entlib 的影响。
确实需要 DI 的客户可以选择所需容器,然后从中获取自己的对象和 Entlib 对象。
无论从单独还是组合的角度来讲,这些目标都意味着要进行大量工作。从表面看,“一个对象创建管道”目标相当简单。我们决定完全删除基于 ObjectBuilder 的系统,在内部采用一个 DI 容器作为对象创建引擎。但是,我们需要考虑“不应更改现有客户端代码”。传统 Entlib API 是一组静态外层和工厂。例如,使用日志记录块来记录一条消息可采用如下方式:
1 |
Logger.Write("My Message"); |
实际上,Logger 外层使用 LogWriter 对象的实例执行实际工作。那么,Logger 外层如何获得 LogWriter?LogWriter 是一个相当复杂的类,具有大量依赖关系,因此,如果采用新建的方式,配置是无法正确关联的。我们认为,在 API 中,Logger 和所有其他静态类需要一个全局容器实例。我们可以仅保留一个全局 Unity 容器,但是,我们需要考虑“客户选择所需容器”。
我们希望 Unity 和 Entlib 组合能实现一流的体验。我们也希望通过其他容器也能实现这种一流体验。尽管 DI 容器的常规功能都一致,但访问这些功能的方式却有很大差异。实际上,许多容器创建者都认为他们的配置 API 是主要竞争优势。因此,我们如何将 Entlib 配置映射到差异很大的容器 API 上?
这是计算机科学领域公认的事实:计算机科学中的所有问题都可以通过添加一个间接层解决。这正是我们解决容器独立问题的方法。我们把这个间接层称为容器配置程序。从本质上说,配置程序的作用是读取 Entlib 的配置,并对容器进行配置以便匹配。
遗憾的是,读取配置本身还不够。Entlib 的配置文件格式很大程度上是以最终用户为中心的。用户配置日志记录类别、异常策略和缓存后备存储。但不说明要完成相应功能实际所需的对象、要向构造函数传递的值以及要设置的属性。另一方面,DI 容器配置的内容则是“将此界面映射到此类型”、“调用此构造函数”和“设置此属性”等。我们需要另一个间接层将块的配置映射到实际所需对象来实现块。另一种方法是,让每一个配置程序(每个容器都需要一个配置程序)都知道每一个块的详细信息。很明显,这不可行;对块代码进行任何更改都将波及所有配置程序。如果有人编写自定义块,会发生什么情况?
我们最后开发了一组名为“TypeRegistration”的对象。各配置节负责生成一个类型注册模型 ,一系列 TypeRegistration 对象。TypeRegistration 的接口如图 1 所示。
图 1 TypeRegistration 类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class TypeRegistration { public TypeRegistration(LambdaExpression expression); public TypeRegistration(LambdaExpression expression, Type serviceType); public Type ImplementationType { get; } public NewExpression NewExpressionBody { get; } public Type ServiceType { get; private set; } public string Name { get; set; } public static string DefaultName(Type serviceType); public static string DefaultName<TServiceType>(); public LambdaExpression LambdaExpression { get; private set; } public bool IsDefault { get; set; } public TypeRegistrationLifetime Lifetime { get; set; } public IEnumerable<ParameterValue> ConstructorParameters { get; } public IEnumerable<InjectedProperty> InjectedProperties { get; } } |
该类的内容很多,但基本结构非常简单。该类描述单个类型所需的配置。ServiceType 是用户从容器进行请求的接口,而 ImplementationType 则是实际实现该接口的类型。Name 是注册服务时应使用的名称。生存期可确定单一实例(每次都返回同一实例)或瞬态(每次都创建新的实例)创建行为。其他在此就不一一列举了。我们选择使用 lambda 表达式来创建 TypeRegistration 对象,因为这样可以非常方便地在单一紧凑的范围内指定所有这些信息。以下是从数据访问块创建类型注册的示例:
1 2 3 4 5 6 7 8 |
yield return new TypeRegistration<Database>( () => new SqlDatabase( ConnectionString, Container.Resolved<IDataInstrumentationProvider>(Name))) { Name = Name, Lifetime = TypeRegistrationLifetime.Transient }; |
此类型注册表示“如果请求名为 Name 的数据库,则返回一个新的 SqlDatabase 对象,该对象由 ConnectionString 和 IDataInstrumentationProvider 构造”。此处使用 lambda 的好处在于,在编写块时,可像直接新建对象一样构建这些表达式。编译器将对表达式进行类型检查,这样,我们就不会在无意中调用不存在的构造函数了。若要设置属性,可在 lambda 表达式内使用 C# 对象初始值设定项语法。TypeRegistration 类负责处理检查 lambda、提取构造函数签名、参数、类型等等的详细信息,以免配置程序作者为之操心。
我们用过的一个实用的技巧是调用“Container.Resolved”。该方法实际上不执行任何操作,它的实现如下:
1 2 3 4 |
public static T Resolved<T>(string name) { return default(T); } |
为什么要用它?请注意,此 lambda 表达式实际上从不执行。相反,我们是在运行时通过运行表达式的结构提取注册信息。此方法只是一个众所周知的标记。如果将对 Container.Resolved 的调用作为参数,我们解释为“通过容器解析此参数”。我们发现,用表达式树执行高级工作时,此标记方法技术在很多情况下很有用。
最后,配置的容器的配置文件流程如图 2 所示。
图 2 容器配置
此处要说明一下我们的一项设计决策,这非常重要。TypeRegistration 系统现在不是(以后也绝不会成为)任何 DI 容器的通用、全面配置抽象概念。它是应 Enterprise Library 项目之需专门设计的。模式和实施方案组无意将它作为基于代码的指南。尽管基本概念(将配置提取到抽象模型中)普遍适用,此处的特定实现仅适用于 Entlib。
这样,我们就配置了容器。这只完成了一半工作。如何才能从容器中获取对象?在这方面,容器接口各不相同,令人欣慰的是,这种不同没有其配置接口那样大。
很幸运,这时我们不必创造新的抽象概念。受 2008 年夏 Jeremy Miller 发表的博客文章的启发,Microsoft 的模式和实施方案组、MEF 团队和许多不同的 DI 容器的作者们合作,定义了一个最低通用标准,以解决从容器中解析出对象的问题。该标准作为 Common Service Locator 项目发布在 Codeplex 和 MSDN 上。该接口正好满足我们的需要;在 Enterprise Library 中,无论何时需要从容器中获取对象,都可通过该接口进行调用,并与所用的特定容器隔离开。当然,下一个问题是:容器在哪里?
Enterprise Library 没有任何类型的引导需求。使用静态外层时,不需要在任何位置调用初始化函数。首次需要原始库时,可通过提取配置来运行它。我们必须复制此行为,以便在调用时,库已准备就绪可供使用。
我们需要的是众所周知的标准库,以便获取正确配置的容器。实际上,Common Service Locator 库具有以下功能之一:ServiceLocator.Current 静态属性。由于种种原因,我们决定不使用此属性。主要原因是,其他库,甚至应用程序本身都可使用 ServiceLocator.Current。我们需要在首次访问任何 Entlib 项目时,能够对容器进行设置;其他都不重要,比如人们试图弄明白为何其认真构建的容器会消失,或为何 Entlib 在首次调用可以运行,但后来就不行了。第二个原因与接口本身的一个缺陷有关。无法查询该属性,因而不能确定是否已对其进行了设置。这样就很难确定何时设置容器。
因此,我们构建了自己的静态属性:EnterpriseLibraryContainer.Current。在用户代码中也可以设置此属性,但它是 Enterprise Library 的特定部分,因此,减小了与其他库或主应用程序发生冲突的可能性。首次调用静态外层时,应检查 EnterpriseLibraryContainer.Current。如果已设置,则可使用其值。如果未设置,则应创建一个 UnityContainer 对象,用配置程序对其进行配置,并将其设置为 Current 属性的值。
这样,现在就有了三种不同的方式,可访问 Enterprise Library 的功能。如果使用传统 API,一切都会正常运行。在底层,将创建和使用 Unity 容器。如果要在应用程序中使用不同的 DI 容器,不希望进程中有 Unity,但仍使用传统 API,则可以使用配置程序来配置您的容器,将其封装在 IServiceLocator 中,并附于 EnterpriseLibraryContainer.Current 中,这样,外层仍将正常运行。它们现在才在底层使用您所选择的容器。实际上,在主 Entlib 项目中,我们不提供任何容器配置程序(Unity 除外);我们希望,社区将为其他容器实现配置程序。
第二种方法是直接使用 EnterpriseLibraryContainer.Current。可调用 GetInstance<T>() 以获取任何 Enterprise Library 对象,该对象会提供一个配置程序。同样,如果愿意,也可在其后附一个其他容器。
最后一种方法,您可以直接使用所选容器。必须使用配置程序将 Entlib 配置引导到容器中,但如果要使用容器,则需要对其进行设置,这并不是一个新要求。然后,将所需 Entlib 对象作为依赖关系进行注入,即可正常运行。
回顾一下我们的目标以及我们的设计是否符合这些目标。
现有客户端代码不必仅因体系结构更改而更改。可要求重新编译,但不可要求更改源代码(当然,客户端 API 可以因其他 原因进行更改)。可处理内部 API 或可扩展 API。
符合。原始 API 仍可正常运行。如果您不使用依赖关系注入,则不需要了解也不需要关心您的对象在底层是如何关联的。
删除冗余对象创建管道。应只保留一种(而非两种或更多)创建对象的方式。
符合。代码库不再使用 ObjectBuilder 堆栈;现在,一切都通过 TypeRegistration 和配置程序机制进行构建。每个容器都需要一个配置程序。
不使用 DI 的客户不应受在内部使用 DI 的 Entlib 的影响。
符合。DI 不会自己出现,除非您希望它出现。
确实需要 DI 的客户可以选择所需容器,然后从中获取自己的对象和 Entlib 对象。
符合。您可直接使用所选 DI 容器,也可在静态外层之后使用它。
此外,我们还实现了其他一些优点。简化了 Entlib 代码库。我们从原始实现中删除了大约 200 个类。添加类型注册进行重构之后,一共减少了大约 80 个类。此外,添加的类比删除的类更简单,明显提高了整体结构的一致性,减少了移动部件或特殊情况。
另一个优势是,重构的版本比原始版本更快一些,初步的非正式评估显示,性能提高了 10%。这些数字说明我们的工作是有效的。原始代码中的复杂性大多源于针对 ObjectBuilder 的缓慢实现需要进行一系列性能优化。大多数 DI 容器针对其常规性能进行了大量工作。通过在容器之上重建 Entlib,可以利用这些性能优化工作,从而不必自己完成大量这类工作。随着 Unity 和其他容器向前发展和优化,Entlib 的速度会更快,而无需我们完成大量工作。
Enterprise Library 是一个很好的库示例,它真正利用依赖关系注入容器,而不会与这种容器紧密耦合。如果要编写使用 DI 容器的库,但不希望将自己的选择强加给客户,可以借鉴我们的设计思路。我认为,我们针对“更改”设立的目标,尤其是最后两个,所有 库(而不仅仅是 Entlib)作者都应将其考虑在内:
不使用 DI 的客户不应受在内部使用 DI 的 Entlib 的影响。
确实需要 DI 的客户可以选择所需容器,然后从中获取自己的对象和 Entlib 对象。
设计库时,需要考虑几个问题。请务必考虑以下问题:
库采用什么引导方式?客户是否必须完成特定工作才能设置您的代码,或者,您是否有可正常运行的静态入口点?
您如何对对象图进行建模,以便在配置容器时无需将调用硬编码到该容器中?请参考我们的 TypeRegistration 系统,寻找解决方法。
如何管理要使用的容器?是在内部处理,还是由调用方进行管理?调用方如何通知您要使用哪个容器?
在我们的项目中,我们总结出了一整套很好的答案。希望我们的示例能为您的设计提供帮助。
Chris Tavares 是 Microsoft 模式和实施方案组的开发人员,在该组中,他任 Enterprise Library 和 Unity 项目的开发主管。在 Microsoft 就职之前,他曾从事咨询、压缩包装软件和嵌入式系统的工作。他在博客中发表了 Entlib、p&p 和常规开发方面的文章,网址为:tavaresstudios.com。