第一章-依赖注入的基础知识:什么,为什么以及如何
依赖注入的蓝图(Putting Dependency Injectionon the map)
依赖注入(DI)是面向对象编程中最容易被误解的概念之一。 混乱非常丰富,涉及术语,目的和机制。应该将其称为依赖注入(Dependency Injection),依赖倒置(Dependency Inversion),控制反转(Inversion of Control)甚至是第三方插件吗?DI的目的仅仅是为了支持单元测试(Unit testing),还是有更广泛的目的?DI与服务定位器(Service Locator)相同吗? 我们需要DI容器(DI Container)来应用DI吗?
有很多讨论DI的博客文章,杂志文章,会议演讲等等,但是不幸的是,许多文章使用相矛盾的术语或给出了不好的建议。整体上都是如此,甚至像Microsoft这样的大人物也增加了混乱。
不必是这种方式。在本书中,我们介绍并使用一致的术语。 在大多数情况下,我们采用并澄清了其他人定义的现有术语,但有时,我们会添加一些以前不存在的术语。 这极大地帮助我们发展了DI范围或边界的规范。
所有不一致和错误建议背后的根本原因之一是:DI的界限非常模糊。DI在哪里结束,其他面向对象的概念在哪里开始? 我们认为,在DI和编写优秀的面向对象代码的其他方面之间无法划清界限。要谈论DI,我们必须引入其他概念,例如 SOLID,整洁代码(Clean Code) 甚至面向方面的编程。我们认为,在不涉及其他一些主题的情况下,我们无法可靠地撰写有关DI的文章。
本书的第一部分可以帮助您了解DI在软件工程的其他方面的位置-可以这么说。第1章为您提供了DI的快速浏览,涵盖了DI的目的,原理和好处,并概述了本书其余部分的范围。它着眼于全局,没有涉及很多细节。如果您想了解DI是什么以及为什么对DI感兴趣,那么这就是开始的地方。 本章假定您不具备DI的先验知识。 即使您已经了解了DI,您可能仍想阅读它—事实证明它可能与您期望的有所不同。
另一方面,第2章和第3章完全保留给一个大例子。该示例旨在使您对DI更加具体。为了将DI与更传统的编程风格进行对比,第2章展示了示例电子商务应用程序的典型紧密结合的实现。然后,第3章使用DI重新实现它。
在这一部分中,我们将对DI进行一般性讨论。这意味着我们不会使用任何所谓的DI容器(DI Container)。 完全可以在不使用DI容器(DI Container)的情况下应用DI。DI容器(DI Container)是有用但可选的工具。因此,第1部分,第2部分和第3部分或多或少完全忽略了DI容器(DI Container),而是以与容器无关的方式讨论DI。然后,在第4部分中,我们返回DI容器(DI Container)剖析三个特定的库。
第1部分为本书的其余部分建立了上下文。本指南针对的读者没有任何DI的先验知识,但是经验丰富的DI从业人员也可以从这些章节的摘要中受益,以了解整本书中使用的术语。在第1部分结束时,即使某些具体细节仍然有些模糊,您也应该对词汇表和整体概念有一个牢固的掌握。 没关系-在您阅读本书时,本书会变得更加具体,因此第2、3和4部分应回答您在阅读第1部分后可能遇到的问题。
您可能已经听说过制作酱汁意大利面酱很困难。即使在经常做饭的人中,许多人也从未尝试过做饭。很可惜,因为酱汁很美味。(传统上它与牛排搭配,但它也是白芦笋,荷包蛋和其他菜肴的绝佳佐餐。)有些产品采用了现成的调味料或速溶混合物等替代品,但这些替代品并没有真正的满足感 。
酱汁贝纳酱是一种由蛋黄和黄油制成的乳化酱,以龙蒿,山罗卜,青葱和醋调味。它不含水。制作它的最大挑战是准备工作可能会失败。 调味料可能会凝结或分离,如果发生任何一种,您都无法将其复活。 准备大约需要45分钟,因此尝试失败意味着您可能没有时间进行第二次尝试。 另一方面,任何厨师都可以准备酱汁蛋黄酱。 这是他们培训的一部分,正如他们会告诉您的,这并不困难。
您不必成为一名专业厨师即可制作酱汁贝纳酱。任何学习它的人都会失败至少一次,但是一旦掌握了它,每次都会成功。我们认为依赖注入(Dependency Injection)(DI)
就像沙司酱。假设这样做很困难,而且,如果您尝试使用它并失败,很可能没有时间进行第二次尝试。
定义
依赖注入(Dependency Injection)
是一组软件设计原理和模式,可让您开发松散耦合(Loose Coupling)
的代码。
尽管人们对DI充满恐惧,不确定性和怀疑(FUD),但学习它就像制作酱汁意大利面酱一样容易。 您在学习过程中可能会犯错,但是一旦掌握了这项技术,您将永远不会再一次成功地应用它。
软件开发问答网站Stack Overflow提供了以下问题的答案:“如何向5岁的孩子解释依赖注入(Dependency Injection)
?”John Munsch 给出的评分最高的答案针对(假想的)五岁的调查员提供了令人惊讶的准确类比:
当您自己将物品从冰箱中取出时,可能会引起问题。您可能会打开门,可能会得到妈妈或爸爸不想要的东西。 您甚至可能在寻找我们什么没有或已经过期的东西。
您应该做的是陈述一个需求,“我需要在午餐时喝点东西”,然后我们将确保您坐下吃饭时能得到一些东西。
对于面向对象的软件开发而言,这意味着:协助类(collaborating classes)(五岁)应该依靠基础架构(infrastructure)(父母)来提供必要的服务
。
注意 在 DI 术语中,我们经常谈论服务(services)和组件(components)。服务(services)通常是抽象(Abstraction),是对提供服务的事物的定义。 抽象的实现通常称为组件(component),即包含行为的类(class)。 因为服务和组件都是如此重载的术语,所以在本书中,您通常会看到我们使用术语“抽象(Abstraction”)”和“类(class)”来代替。
本章的结构相当线性。首先,我们介绍DI,包括它的目的和好处。 尽管我们包括了示例,但总的来说,本章的代码少于本书中的任何其他章。 在介绍DI之前,我们将讨论DI的基本目的—可维护性(maintainability)。 这很重要,因为如果准备不充分,很容易误解DI。接下来,在一个示例(Hello DI!)之后,我们讨论收益和范围,并为该书列出路线图。 学完本章后,您应该为本书其余部分中的更高级概念做好准备。
对于大多数开发人员而言,DI似乎是创建源代码的一种相当落后的方式,并且像酱汁贝纳伊丝一样,涉及很多FUD。 要了解DI,您必须首先了解其目的。
编写可维护的代码(Writing maintainable code)
DI的作用是什么?DI本身不是目标;相反,这是达到目的的一种手段。最终,大多数编程技术的目的是尽可能高效地交付工作软件。 一方面是编写可维护的代码。
除非您仅编写原型,或者从未编写过第一个版本的应用程序,否则您会发现自己正在维护和扩展现有的代码库。 通常,为了有效地使用此类代码库,它们的可维护性越好。
使代码更具可维护性的一种极好的方法是通过松散耦合(Loose Coupling)。 早在1994年,当“四人帮”撰写《设计模式》时,这已经是常识:
编程面向接口,而不是实现。
重要的建议不是结论,而是设计模式的前提。松散耦合(Loose Coupling)使代码可扩展,而可扩展性使其可维护。DI只不过是一种允许松散耦合(Loose Coupling)的技术。此外,对DI有许多误解,有时它们会妨碍正确理解。在学习之前,您必须先学习已经知道的(您认为的)知识。
关于DI的常见神话(Common myths about DI)
您可能以前从未接触过或听说过DI,这太好了。跳过本节,直接转到第1.1.2节。但是,如果您正在阅读这本书,则可能至少是在对话中,在您继承的代码库中或在博客文章中碰到过这本书。 您可能还已经注意到,它附带了大量的意见。 在本节中,我们将研究多年来出现的关于DI的四个最常见的误解,以及为什么它们不是真的。 这些神话包括:
- DI仅与后期绑定(Late binding)相关。
- DI仅与单元测试(Unit testing)相关。
- DI是类聚合的一种抽象工厂。
- DI需要一个DI容器(DI Container)。
尽管这些神话都不是正确的,但它们仍然很普遍。 在您开始学习DI之前,我们需要消除它们。
后期绑定(Late binding)
在这种情况下,后期绑定(Late binding)是指无需重新编译代码即可替换应用程序各部分的功能。一个支持第三方加载项的应用程序(例如Visual Studio)就是一个示例。 另一个示例是支持不同运行时环境的标准软件。
假设您有一个在多个数据库引擎上运行的应用程序(例如,一个同时支持Oracle
和SQL Server
的数据库引擎)。为了支持此功能,其余的应用程序通过接口与数据库对话。代码库提供了该接口的不同实现,以分别访问Oracle
和SQL Server
。在这种情况下,您可以使用配置选项来控制对于给定的安装应使用哪种实现。
常见的误解是,DI仅与这种情况有关。这是可以理解的,因为DI支持这种情况。但是谬论是认为这种关系是对称的。DI启用后期绑定(Late binding)的事实并不意味着它仅与后期绑定(Late binding)方案相关。 如图1.1所示,后期绑定(Late binding)只是DI的许多方面之一。
如果您认为DI仅与后期绑定(Late binding)方案相关,则需要学习这一点。DI的功能远不止于后期绑定(Late binding)。
单元测试(Unit testing)
有人认为DI仅与支持单元测试(Unit testing)有关。尽管DI当然是支持单元测试(Unit testing)的重要组成部分,但事实并非如此。 实话实说,我们最初对DI的介绍源于在测试驱动开发(Test-Driven Development)(TDD)的某些方面的苦苦挣扎。在此期间,我们发现了DI,并了解到其他人已使用它来支持我们正在解决的某些相同情况。
即使您不编写单元测试(Unit testing)(如果您不编写单元测试(Unit testing),也应该立即开始),由于它提供的所有其他好处,DI仍然很重要。 声称DI仅与支持单元测试(Unit testing)有关,就像宣称与DI仅仅与支持后期绑定(Late binding)有关。图1.2显示,尽管这是一个不同的视图,但它的宽度和图1.1一样狭窄。在本书中,我们将尽力向您展示整个图片。
如果您认为DI仅与单元测试(Unit testing)有关,请取消学习此假设。DI不仅可以进行单元测试(Unit testing)。
图1.1 通过DI启用了后期绑定(Late binding),但是要假定仅适用于后期绑定(Late binding)场景,则是对更广阔的前景采取狭窄景色的看法。 |
---|
图1.2 也许您一直认为单元测试(Unit testing)是DI的唯一目的。 尽管该假设与后期绑定(Late binding)假设是不同的观点,但对于更广阔的前景,它也是一个狭窄的观点。 |
---|
类聚合的抽象工厂(Abstract Factory)
也许最危险的谬误是DI涉及某种通用的抽象工厂(Abstract Factory)
,您可以使用它来创建应用程序中所需的依赖项(Dependencies)的实例。
抽象工厂(Abstract Factory)
抽象工厂(Abstract Factory)通常是一个包含多个方法的抽象,其中每个方法都允许创建某种类型的对象。
抽象工厂模式(Abstract Factory pattern)的典型用例是必须在多个平台上运行的用户界面(
UI
)工具箱或客户端应用程序。 为了在所有平台上实现高度的代码可重用性,例如,您可以定义一个IUIControlFactory
抽象类,它允许为消费者创建某些类型的控件,例如文本框和按钮:public interface IUIControlFactory { IButton CreateButton(); ITextBox CreateTextBox(); }
对于每个操作系统(OS),您都可以对此
IUIControlFactory
进行不同的实现。 在这种情况下,只有两种工厂方法,但是根据应用程序或工具箱,可能会有更多方法。 需要注意的重要一点是,抽象工厂(Abstract Factory)指定了工厂方法的预定义列表。
在本章的引言中,我们写道:“协作类应该依靠基础结构来提供必要的服务。” 您最初对这句话有什么想法?您是否认为基础架构是可以查询以获取所需依赖(Dependencies)的某种服务? 如果是这样,您并不孤单。许多开发人员和架构师将DI视为一种可用于查找其他服务的服务。 这称为服务定位器(Service Locator)
,但与DI完全相反。
服务定位器(Service Locator)通常被称为类聚合的抽象工厂(Abstract Factory),因为与普通的抽象工厂相比,可解析类型的列表是不确定的,并且可能是无止境的。它通常具有一种允许创建各种类型的方法,非常类似于以下内容:
public interface IServiceLocator
{
object GetService(Type serviceType);
}
重要 如果您将DI视为服务定位器(Service Locator)(即通用工厂),那么这是您需要学习的。DI与服务定位器(Service Locator)相反; 这是一种结构化代码的方法,因此您不必强制要求依赖。 相反,您需要消费者提供它们。
DI容器(DI Containers)
与以前的误解紧密相关的是DI需要DI容器(DI Container)的概念。如果您先前错误地认为DI涉及服务定位器(Service Locator),那么很容易得出结论,DI容器(DI Container)可以承担服务定位器(Service Locator)的责任。可能是这种情况,但根本不应该使用DI容器(DI Container)。
DI容器(DI Container)是一个可选库,可在连接应用程序时使编写类变得更容易,但绝不是必需的。如果您在没有DI容器(DI Container)的情况下编写应用程序,则称为Pure DI。这可能需要更多的工作,但除此之外,您不必在任何直接DI原则上都做出让步。
定义 Pure DI是在没有DI容器(DI Container)的情况下应用DI的实践。
重要 如果您认为DI需要DI容器(DI Container),则这是另一个需要学习的概念。DI是一组原则和模式,DI容器(DI Container)是有用但可选的工具。
我们尚未确切解释什么是DI容器(DI Container),以及如何以及何时使用它。我们将在第3章的末尾对此进行更详细的介绍。 第4部分完全致力于此。
您可能会认为,尽管我们揭露了关于DI的四个误解,但我们还没有对其中任何一个提出令人信服的论据。确实如此。从某种意义上说,这本书是反对这些常见误解的一个主要论据,因此,我们稍后一定会再讨论这些主题。例如,在第5章中,第5.2节讨论了为什么服务定位器(Service Locator)是反模式(anti-pattern)。
根据我们的经验,学习是至关重要的,因为人们经常尝试改进我们告诉他们的DI知识,并使其与他们已经知道的知识保持一致。发生这种情况时,要花一些时间才能最终意识到他们的一些最基本的假设是错误的。我们想为您节省经验。如果可以的话,请阅读本书,好像您对DI一无所知。
理解DI的目的
DI不是最终目标,而是达到目标的手段。DI支持松散耦合(Loose Coupling),松散耦合(Loose Coupling)使代码更易于维护。这是一个不错的主张,尽管我们可以将您推荐给四人帮等知名机构,以获取详细信息,但我们只能公平地解释为什么这样做是对的。
为了传达这一信息,下一部分将比较软件设计和几种带有电气布线的软件设计模式。 我们发现这是一个有力的类比。我们甚至用它向非技术人员解释软件设计。
在这个类比中,我们使用四种特定的设计模式,因为它们相对于DI经常出现。在本书中,您会看到许多关于这三种模式的示例—装饰器(Decorator)模式,组合(Composite)模式和适配器(Adapter)模式。(我们将在第4章中介绍第四个空对象模式(Null Object pattern))。如果您对这些模式不那么熟悉,请不要担心:您会在本书的最后完成。
软件开发仍然是一个新兴行业,因此在许多方面我们仍在研究如何实现良好的体系结构。 但是在更传统的专业领域(例如建筑业)中具有专长的人很早就意识到了这一点。
一个生活例子:入住便宜的酒店
如果您住的是便宜旅馆,您可能会遇到如图1.3所示的环境。在这里,酒店为方便起见提供了一个吹风机,但显然他们不信任您将吹风机留给下一位客人使用:该设备直接连接到壁装电源插座上。酒店管理部门认为,更换偷来的吹风机的成本很高,足以证明否则实施效果明显较差。
图1.3 在便宜的旅馆房间里,您可能会发现吹风机直接与壁装电源插座相连。 这等效于使用编写紧密耦合的代码的常规做法。 |
---|
当吹风机停止工作时会发生什么?酒店必须请一位熟练的专业人员。要固定硬线吹风机,必须切断房间的电源,暂时无法使用。 然后,技术人员必须使用专用工具断开吹风机的连接,然后换上新的吹风机。如果幸运的话,如果您幸运的话,技术人员会记得重新打开房间的电源,然后再重新测试一下新的吹风机是否正常工作。这个过程听起来很熟悉吗?
这就是处理紧密耦合(tightly coupled)的代码的方式。在这种情况下,吹风机与墙壁紧紧相连,您不能轻易修改其中的一个而不影响另一个。
将电线与设计模式进行比较
通常,我们不会通过将电缆直接连接到墙上来将电器连接在一起。相反,如图1.4所示,我们使用插头和插座。插座定义了插头必须匹配的形状。
与软件设计类似,套接字(socket)是一个接口,而带有其设备的插头是一种实现。 这意味着该房间(应用程序)具有一个或(希望有)更多的插座,并且该房间的用户(开发人员)可以随意插入设备,甚至可能是客户提供的吹风机。
图1.4 通过使用插座和插头,可以将吹风机松散地连接到墙上的插座。 |
---|
与硬线吹风机相反,插头和插座定义了用于连接电器的松散耦合(Loose Coupling)模型。只要插头(实现)适合插槽(实现接口),并且可以处理伏特和赫兹的数量(遵循接口约定),我们就可以通过多种方式组合电器。尤其有趣的是,可以将许多常见组合与众所周知的软件设计原理和模式进行比较。
首先,我们不再局限于吹风机。如果您是普通读者,我们会认为与台式机相比,您需要的计算机电源要多得多。没问题:您拔下吹风机的电源并将计算机插入同一插座(图1.5)。
图1.5 使用插座和插头,可以用计算机替换图1.4中的原始吹风机。 这对应于里氏替换原则(Liskov Substitution Principle)。 |
---|
里氏替换原则(Liskov Substitution Principle)
令人惊奇的是,插座的概念比计算机早了几十年,但它却为计算机提供了必不可少的服务。最初的插座设计者不可能预见到个人计算机,但是由于设计是如此多才多艺,因此可以满足最初未曾想到的需求。
无需更改插座或接口即可切换插头或实现的功能类似于称为
里氏替换原则(Liskov Substitution Principle)
的中央软件设计原理。该原则指出,我们应该能够在不破坏客户端或实现的情况下,将接口的一种实现替换为另一种。当涉及到DI时,
里氏替换原则(Liskov Substitution Principle)
是最重要的软件设计原则之一。正是这一原则使我们能够解决将来可能发生的需求,即使我们今天无法预见它们也是如此。
如果您目前不需要使用计算机,可以将其拔出电源。即使什么都没插,房间也不会爆炸。也就是说,如果从墙壁上拔下计算机的电源插头,墙壁插座和计算机都不会发生故障。
但是,使用软件时,客户通常希望获得服务。如果删除该服务,则会收到NullReferenceException
。为了处理这种情况,您可以创建不执行任何操作的接口的实现。 这种设计模式称为空对象(Null Object),对应于具有儿童安全插座的插头(没有电线或电器的插头仍可插入插座)。 而且,由于您使用的是松散耦合(Loose Coupling),因此您可以用不执行任何操作而不会造成麻烦的东西替换实际的实现。 如图1.6所示。
图1.6 拔下计算机的插头时,如果用儿童安全插座的插头塞住,房间和计算机都不会爆炸。 这可以粗略地比作空对象(Null Object)模式。 |
---|
您还可以执行许多其他操作。如果您居住在断断续续的电源故障附近,则可能需要通过插入不间断电源(UPS)来保持计算机运行。如图1.7所示,您将UPS连接到壁装电源插座,并将计算机连接到UPS。
图1.7 可以引入UPS,以在断电时保持计算机运行。 这对应于装饰器设计模式。 |
---|
计算机和UPS具有不同的用途。 每个人都有一个单一责任(Single Responsibility),不会侵犯另一个单位。 UPS和计算机可能由两家不同的制造商生产,在不同的时间购买,并分别插入。如图1.5所示,您可以在没有UPS的情况下运行计算机,也可以想象在停电期间通过将吹风机插入UPS来使用吹风机。
在软件设计中,这种将同一个接口的一个实现与另一个实现截取的方式称为装饰器(Decorator)设计模式。它使您能够逐步引入新功能和横切关注点(Cross-Cutting Concerns),而无需重写或更改很多功能的现有代码。
向现有代码库添加新功能的另一种方法是使用新实现重构接口的现有实现。当将多个实现聚合为一个时,将使用组合(Composite)设计模式。图1.8说明了如何将各种设备插入配电盘。
电源板只有一个插头,您可以将其插入单个插座,电源板本身提供了多个插座,可用于各种设备。这使您可以在计算机运行时添加和卸下吹风机。以相同的方式,通过修改组成的接口实现集,组合(Composite)设计模式使添加或删除功能变得容易。
这是最后一个例子。有时您会遇到插头无法插入特定插座的情况。如果您去过另一个国家,您可能会注意到世界各地的插座都不同。如果在旅行时随身携带图1.9中的照相机之类的东西,则需要适配器进行充电。适当地,有一个同名的设计模式。
适配器(Adapter)设计模式的工作原理与其物理名称相同。您可以使用它来将两个相关但独立的接口彼此匹配。 当您有一个现有的第三方API
要公开为应用程序使用的接口实例时,此功能特别有用。 与物理适配器一样,适配器(Adapter)设计模式的实现范围可以从简单到极其复杂。
图1.8 配电盘可将多个设备插入一个壁装电源插座。 这对应于组合(Composite)设计模式。 |
---|
图1.9 旅行时,通常需要使用适配器将设备插入异物插座(例如,为相机充电)。这对应于适配器设计模式。有时,平移就像改变插头的形状一样简单,或者就像将电流从交流电(AC)转换成直流电(DC)一样复杂。 |
---|
插座和插头模型的惊人之处在于,数十年来,它被证明是一种简单而通用的模型。基础架构确立后,任何人都可以使用它,并适应不断变化的需求和意外需求。更有趣的是,当我们将此模型与软件开发联系起来时,所有构建模块都已经以设计原则和模式的形式出现。
松散耦合(Loose Coupling)的优点在软件设计中与在物理插座和插头模型中相同:一旦基础结构确立,任何人都可以使用它,并且可以适应不断变化的需求和不可预见的需求,而无需对应用程序进行大的更改代码库和基础架构。 这就意味着,理想情况下,新的要求只需要添加新的类,而无需更改系统中其他已经存在的类。
能够在不修改现有代码的情况下扩展应用程序的概念称为开放/封闭原则(Open/Closed Principle)。不可能出现这样的情况,即您的代码100%始终处于打开状态以进行扩展,而关闭则进行修改。不过,松散耦合(Loose Coupling)确实会使您更接近该目标。
而且,在每一步中,向系统添加新功能和要求变得更加容易。能够添加新功能而无需接触系统的现有部件,这意味着问题是隔离的。这样可以使代码更易于理解和测试,从而可以管理系统的复杂性。这就是松散耦合(Loose Coupling)可以为您提供帮助的原因,这就是为什么它可以使代码库更易于维护。我们将在第4章中更详细地讨论开放/封闭原则(Open/Closed Principle)
。
到目前为止,您可能想知道在代码中实现这些模式时的外观。不用担心 如前所述,我们将在本书中向您展示这些模式的大量示例。实际上,在本章的后面,我们将向您展示装饰器(Decorator)模式
和适配器(Adapter)模式
的实现。
松散耦合(Loose Coupling)的简单部分是面向接口编程,而不是面向实现编程。问题是,“实例从何而来?” 从某种意义上讲,这就是整本书的目的:这是DI寻求回答的核心问题。
您不能像创建具体类型的新实例一样创建接口的新实例。 这样的代码无法编译:
接口不包含任何实现,因此这是不可能的。writer
实例必须使用其他机制创建。DI解决了这个问题。有了DI的目的概述,我们认为您已经准备好举一个例子。
一个简单的例子:你好,DI!(A simple example: Hello DI!)
Hello DI! 的代码
您可能曾经看过用单行代码编写的Hello World
示例。 在这里,我们将做一些非常简单的事情,并使之变得更加复杂。 为什么? 我们很快会讲到这一点,但首先让我们看看用DI制作的Hello World
会是什么样子。
协作者(Collaborators)
为了了解程序的结构,我们将从查看控制台应用程序的Main
方法开始。然后,我们将向您展示合作的课程;但首先,这是Hello DI
的Main
方法!应用:
private static void Main()
{
IMessageWriter writer = new ConsoleMessageWriter();
var salutation = new Salutation(writer);
salutation.Exclaim();
}
由于程序需要写入控制台,因此它将创建一个新的ConsoleMessageWriter
实例,该实例封装了该功能。它将消息编写器传递给Salutation
类,以便Salutation
实例知道在何处写入其消息。由于现在一切都已正确连接,因此您可以通过Exclaim
方法执行逻辑,这会将消息写入屏幕。
Main
方法内部对象的构造是Pure DI的基本示例。没有使用DI容器(DI Container)来构成Salutation
及其ConsoleMessageWriter
依赖关系。 图1.10显示了协作者之间的关系。
图1.10 Hello DI的协作者之间的关系! |
---|
ConsoleMessageWriter implements the IMessageWriter interface that Salutation uses.ConsoleMessageWriter 实现了Salutation 使用的IMessageWriter 接口。The Main method creates new instances of both the ConsoleMessageWriter and Salutation classes.Main 方法创建ConsoleMessageWriter 和Salutation 类的新实例。In effect, Salutation uses ConsoleMessageWriter , although this indirect usage isn't shown.实际上,尽管未显示这种间接用法,但 Salutation 使用的是ConsoleMessageWriter 。 |
实现应用程序逻辑(Implementing the application logic)
该应用程序的主要逻辑封装在Salutation
类中,如清单1.1所示。
清单1.1 Salutation类封装了主要的应用程序逻辑
public class Salutation
{
private readonly IMessageWriter writer;
public Salutation(IMessageWriter writer) <----- 使用构造方法注入为Salutation类提供IMessageWriter依赖关系
{
if (writer == null)
throw new ArgumentNullException("writer"); <--- Guard Clause验证提供的IMessageWriter不为空
this.writer = writer;
}
public void Exclaim()
{
this.writer.Write("Hello DI!"); <----- 发送Hello DI! 消息到IMessageWriter依赖项
}
}
Salutation
类依赖于称为IMessageWriter
的自定义接口(下定义)。它通过其构造函数请求它的一个实例。 这种做法称为构造函数注入(Constructor Injection)。 防御性语句(Guard Clause)会抛出异常,从而验证所提供的IMessageWriter
是否为null
。最后,您可以通过调用Exclaim
方法的Write
方法,在Exclaim
方法的实现中使用先前注入的IMessageWriter
实例。 这将发送Hello DI!
消息到IMessageWriter
依赖关系。
定义 构造函数注入(Constructor Injection)是通过将所需依赖项指定的类的构造函数的参数来静态定义所需依赖项列表的操作。(在第4章中详细描述了构造函数注入(Constructor Injection),其中还包含类似代码示例的更详细的演练。)
用DI术语来说,我们说IMessageWriter
依赖项是使用构造函数注入(Constructor Injection)到Salutation
类中的。 请注意,Salutation
不了解ConsoleMessageWriter
。 它仅通过IMessageWriter
接口与之交互。IMessageWriter
是为此场合定义的简单接口:
public interface IMessageWriter
{
void Write(string message);
}
它可以有其他成员,但是在这个简单的示例中,您只需要Write
方法。它由ConsoleMessageWriter
类实现,Main
方法传递给Salutation
类:
public class ConsoleMessageWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
ConsoleMessageWriter
类通过包装.NET基础类库(BCL)的Console
类来实现IMessageWriter
。 这是我们在1.1.2节中讨论的 适配(Adapter)设计模式的简单应用。
DI的好处(Benefits of DI)
您可能想知道用两个类和一个接口替换一行代码的好处,导致总共28行。您可以轻松解决以下所示的相同问题:
private static void Main()
{
Console.WriteLine("Hello DI!");
}
DI似乎有些过分,但使用DI可以带来很多好处。前面的示例比通常用于在C#中实现Hello World
的普通单行代码更好吗? 在此示例中,DI增加了2800%的开销,但是,随着复杂度从一行代码增加到成千上万,该开销减少了,并且几乎消失了。 第3章提供了应用DI的更复杂示例。 尽管与实际应用程序相比,该示例仍然过于简化,但您应该注意到DI的介入程度要小得多。
如果您发现先前的DI示例过于设计,我们不怪您,但是请考虑这一点:就其本质而言,经典的Hello World
示例是一个简单问题,具有明确规定和严格的要求。 在现实世界中,软件开发从未像现在这样。 需求变化并且经常是模糊的。 您必须实现的功能也往往要复杂得多。DI通过实现松散耦合(Loose Coupling)来帮助解决此类问题。 具体来说,您将获得表1.1中列出的好处。
表1.1从松散耦合中获得的收益。 每种收益总是可用的,但根据情况的不同,其价值也会有所不同。
好处 | 描述 | 什么时候有价值? |
---|---|---|
后期绑定(Late binding) | 服务可以与其他服务交换,而无需重新编译代码。 | 价值标准软件,但或许更少 |
可扩展性(Extensibility) | 可以以未明确计划的方式扩展和重用代码。 | 总是有价值的。 |
并行开发(Parallel development) | 可以并行开发代码。 | 在大型、复杂的应用程序中很有价值;在小型、简单的应用程序中就没有这么多价值了。 |
可维护性(Maintainability) | 类有明确的责任 | 总是有价值的。 |
可测性(Testability) | 类的单元测试(Unit testing)。 | 总是有价值的。 |
我们首先列出了后期绑定(Late binding)的好处,因为根据我们的经验,这是大多数人心目中最重要的好处。当架构师和开发人员无法理解松散耦合(Loose Coupling)的好处时,很可能是因为他们从不考虑其他好处。
后期绑定(Late binding)
当我们向接口和DI解释编程的好处时,用一个服务交换另一个服务的能力对大多数人来说是最显著的好处,因此他们倾向于在只考虑这个好处的情况下权衡利弊。还记得我们建议你可能需要先忘却,然后才能学习吗?您可能会说,您非常了解自己的需求,因此您永远不必用任何其他东西来替换SQL Server
数据库。但需求会改变。
NoSQL、Microsoft Azure和可组合性的争论
几年前,当我试图说服开发人员和架构师DI的好处时,我(Mark)经常遇到一个空白的表达。“好的,那么您可以将关系数据访问组件换成其他组件。为什么?有没有关系数据库的替代品?”
在高度可伸缩的企业场景中,XML文件似乎从来都不是一个令人信服的替代方案。这在过去几年里发生了重大变化。
Azure是在PDC 2008上宣布的,它已经做出了很多努力,甚至说服了顽固的Microsoftonly组织重新评估了他们在数据存储方面的地位。 关系数据库现在有了真正的替代品,我只需要问人们是否希望他们的应用程序可以支持云。 替换论点现在变得更加强大。
整个
NoSQL
概念中都有相关的动向,该概念围绕非规范化数据(通常是文档数据库)对应用程序进行建模。 但是诸如事件源之类的概念也变得越来越重要。
在1.2.1节中,您没有使用后期绑定(Late binding),因为您通过硬编码新的ConsoleMessageWriter
实例的创建,显式地创建了IMessageWriter
的新实例。但是,您可以通过更改这一行代码来引入后期绑定(Late binding):
IMessageWriter writer = new ConsoleMessageWriter();
要启用后期绑定(Late binding),可以使用以下内容替换该行代码。
清单1.2 后期绑定(Late binding)
IMessageWriter
实现
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
string typeName = configuration["messageWriter"];
Type type = Type.GetType(typeName, throwOnError: true);
IMessageWriter writer = (IMessageWriter)Activator.CreateInstance(type);
注意 清单1.2采用了一些快捷方式来说明问题。事实上,它受到第5章详细介绍的约束构造反模式(anti-pattern)的影响。
通过从应用程序配置文件中提取类型名并从中创建类型实例,可以使用反射来创建IMessageWriter
的实例,而无需在编译时知道具体类型。要实现此功能,请在应用程序配置文件的messageWriter
应用程序设置中指定类型名称:
{
"messageWriter":
"Ploeh.Samples.HelloDI.Console.ConsoleMessageWriter, HelloDI.Console"
}
松散耦合(Loose Coupling)支持后期绑定(Late binding),因为只有一个地方可以创建IMessageWriter
实例。因为Salutation
类只针IMessageWriter
接口工作,所以它从不注意到区别。在Hello DI! 中,例如,后期绑定(Late binding)将使您能够将消息写入与控制台不同的目标;例如,数据库或文件,添加这样的特性是有可能的,即使你没有明确的计划。
可扩展性(Extensibility)
成功的软件必须能够改变。您需要添加新功能并扩展现有功能。松散耦合(Loose Coupling)使您能够有效地重新组合应用程序,这与使用电气插头和插座时的灵活性类似。
假设您要制作Hello DI!仅允许经过身份验证的用户编写邮件,此示例更加安全。 清单1.3显示了如何在不更改任何现有功能的情况下添加该功能—您只需添加IMessageWriter
接口的新实现即可。
清单1.3 扩展Hello DI!具有安全功能的应用程序
public class SecureMessageWriter : IMessageWriter <--- 实现IMessageWriter接口,同时使用它
{
private readonly IMessageWriter writer;
private readonly IIdentity identity;
public SecureMessageWriter(
IMessageWriter writer, <-------- 请求IMessageWriter实例的构造方法注入
IIdentity identity)
{
if (writer == null)
throw new ArgumentNullException("writer");
if (identity == null)
throw new ArgumentNullException("identity");
this.writer = writer;
this.identity = identity;
}
public void Write(string message)
{
if (this.identity.IsAuthenticated) <------ 验证用户是否已通过身份验证
{
this.writer.Write(message); <---------- 如果通过身份验证,则使用注入的消息编写器写入消息
}
}
}
除了IMessageWriter
实例之外,SecureMessageWriter
构造函数还需要IIdentity
实例。Write
方法是通过首先使用注入的身份检查当前用户是否经过身份验证来实现的。如果是这种情况,则允许修饰writer
字段写入消息。唯一需要更改现有代码的地方是Main
方法,因为您需要以不同于以前的方式组合可用类:
IMessageWriter writer =
new SecureMessageWriter( <----- ConsoleMessageWriter被SecureMessageWriter装饰器拦截。
new ConsoleMessageWriter(),
WindowsIdentity.GetCurrent());
注意 与清单1.2相比,您现在使用的是硬编码的
ConsoleMessageWriter
。
请注意,使用新的SecureMessageWriter
类包装或修饰旧的ConsoleMessageWriter
实例。同样,Salutation
类未修改,因为它只使用IMessageWriter
接口。类似地,也不需要修改或复制ConsoleWriter
类中的功能。您使用System.Security.Principal.WindowsIdentity
类检索代表执行此代码的用户的身份。
如前所述,松散耦合(Loose Coupling)使您能够编写对开放的代码进行扩展,而对封闭的代码进行修改(open for extensibility,but closed for modification)。唯一需要修改代码的地方是应用程序入口点。SecureMessageWriter
实现应用程序的安全功能,而ConsoleMessageWriter
解决用户界面问题。这使您可以彼此独立地改变这些方面,并根据需要进行组合。 每个类别都有其自己的单一职责(Single Responsibility)。
并行开发(Parallel development)
关注点分离(Separation Of Concerns)使得并行开发代码成为可能。当一个软件开发项目发展到一定规模时,有必要让多个开发人员在同一个代码库上并行工作。在更大的范围内,甚至有必要将开发团队分成多个可管理规模的团队。每个团队通常被指派负责整个应用程序的某个领域。为了划分职责,每个团队开发一个或多个需要集成到最终应用程序中的模块。除非每个团队的领域是真正独立的,否则有些团队可能依赖于其他团队开发的功能。
定义 在面向对象软件设计中,模块(module)是一组逻辑上相关的类(或组件(components)),其中一个模块(module)独立于其他模块,并且可以与其他模块互换。通常,您会看到一个层(layer)由一个或多个模块组成。
在前面的示例中,由于SecureMessageWriter
和ConsoleMessageWriter
类不直接相互依赖,因此它们可以由并行团队开发。他们只需要就共享接口IMessageWriter
达成一致。
可维护性(Maintainability)
随着每个类的职责变得明确和受限,整个应用程序的维护变得更加容易。这是单一责任原则(Single Responsibility Principle)的结果,该原则规定每一类只应承担单一责任。我们将在第二章更详细地讨论单一责任原则(Single Responsibility Principle)。
向应用程序添加新功能变得更简单,因为可以清楚地知道应该在哪里应用更改。通常情况下,您不需要更改现有代码,而是可以添加新类并重新组合应用程序。这又是开放/封闭原则(Open/Closed Principle)在起作用。
故障排除也往往变得不那么费力,因为可能的罪魁祸首的范围缩小了。有了明确定义的职责,您通常会很清楚从哪里开始寻找问题的根本原因。
可测试性(Testability)
当应用程序可以进行单元测试(Unit testing)时,它被认为是可测试的。对于某些人来说,可测试性(Testability)是他们的后顾之忧。对其他人来说,这是绝对的要求。就个人而言,我们属于后一类。在Mark的职业生涯中,他拒绝了几份工作,因为他们涉及使用某些不可测试的产品。
可测试性(Testability)
可测试(Testable)这个术语是非常不精确的,但是它在软件开发社区中被广泛使用,主要由那些实践单元测试(Unit testing)的人使用。原则上,任何应用程序都可以通过试用来测试。测试可以由使用应用程序的人通过其
UI
或它提供的任何其他接口来执行。这样的手动测试既耗时又昂贵,因此自动化测试是首选。您将发现不同类型的自动化测试—单元测试(Unit testing)、集成测试(integration testing)、性能测试(performance
testing)、压力测试(stress testing)等等。因为单元测试(Unit testing)对运行时环境的要求较少,所以它往往是最有效和健壮的测试类型。通常是在这种情况下,测试性被评估。单元测试(Unit testing)提供了对应用程序状态的快速反馈,但是只有当有问题的单元能够与其依赖关系正确隔离时,才有可能编写单元测试(Unit testing)。一个单元的粒度到底有多细,这一点有点模棱两可,但每个人都同意,它肯定不是跨越多个模块的东西。在单元测试(Unit testing)中,隔离测试模块的能力是至关重要的。
只有当应用程序易受单元测试(Unit testing)的影响时,它才被认为是可测试的。确保可测试性的最安全的方法是使用TDD开发它。
应该注意的是,单靠单元测试(Unit testing)并不能确保应用程序正常工作。完整系统测试或其他中间类型的测试仍然是验证应用程序是否按预期工作所必需的。
可测试性(Testability)的好处也许是我们列出的最具争议性的。一些开发人员和架构师仍然不练习单元测试(Unit testing),因此他们认为这种好处充其量是无关紧要的。但是,我们将其视为软件开发的重要组成部分,这就是为什么我们在表1.1中将其标记为“始终有价值”。 Michael Feathers甚至将术语“旧版应用程序”定义为单元测试(Unit testing)未涵盖的任何应用程序
几乎是偶然的,松散耦合(Loose Coupling)支持单元测试(Unit testing),因为使用者遵循里氏替换原则(Liskov Substitution Principle):他们不关心依赖的具体类型。这意味着您可以将双重测试(Test Doubles)注入到被测系统(SUT)中,如清单1.4所示。
双重测试(Test Doubles)
创建依赖项的实现是一种常见的技术,这些依赖项充当实际或预期实现的替代项。这种实现称为双重测试(Test Doubles),它们永远不会在最终的应用程序中使用。相反,当实际依赖项不可用或不需要使用时,它们充当它们的占位符。
当真正的依赖关系是缓慢的、昂贵的、破坏性的或者仅仅超出当前测试的范围时,双重测试(Test Doubles)是有用的。有一个完整的模式语言围绕着双重测试(Test Doubles)和许多子类型,比如存根(Stubs)、模拟(Mocks)和伪造(Fakes)
用特定于测试的替换来替换预期的依赖项的能力是松散耦合(Loose Coupling)的副产品,但是我们选择将其列为单独的好处,因为派生值是不同的。我们个人的经验是,即使在集成测试期间,DI也是有益的。尽管集成测试通常与真实的外部系统(如数据库)通信,但仍然需要有一定程度的隔离。换言之,仍然有理由替换、截取或模拟正在测试的应用程序中的某些依赖项。
拦截信息(Intercepting text messages)
我(Steven)在多个应用程序上工作,这些应用程序通过第三方服务发送短信。我不想让我们的测试环境将这些短信发送到真正的网关,因为每条短信都有成本,我当然也不想不小心用这些测试短信向手机发送垃圾邮件。
另一方面,在手动测试过程中,手机会收到短信。但是,在本例中,应用了一个修饰器,将发送到网关的电话号码更改为测试人员可以提供的号码。通过这种方式,测试人员能够在自己的手机上获取所有信息,并验证被测系统。
根据您正在开发的应用程序的类型,您可能关心或不关心后期绑定(Late binding)的能力,但我们始终关心可测试性。一些开发人员不关心可测试性,但发现后期绑定(Late binding)对他们正在开发的应用程序很重要。不管怎样,DI在未来提供了选项,而现在的额外开销最小。
示例:单元测试(Unit testing)HelloDI
逻辑
在1.2.1节中,您看到了Hello DI!例子。虽然我们先向您展示了最终的代码,但是我们使用TDD开发了它。清单1.4显示了最重要的单元测试(Unit testing)。
注意 如果你没有单元测试(Unit testing)的经验,不要担心。它们偶尔会在这本书中出现,但决不是阅读它的先决条件。
清单1.4 使用单元测试(Unit testing) 测试
Salutation
类
[Fact]
public void ExclaimWillWriteCorrectMessageToMessageWriter()
{
var writer = new SpyMessageWriter();
var sut = new Salutation(writer); <------- 使用SpyMessageWriter测试间谍对IMessageWriter依赖项进行存根(Stubs)。
sut.Exclaim();
Assert.Equal(
expected: "Hello DI!",
actual: writer.WrittenMessage);
}
public class SpyMessageWriter : IMessageWriter
{
public string WrittenMessage { get; private set; }
public void Write(string message)
{
this.WrittenMessage += message;
}
}
Salutation
类需要IMessageWriter
接口的实例,因此需要创建一个实例。您可以使用任何实现,但在单元测试(Unit testing)中,双重测试(Test Double)可能很有用—在本例中,您可以使用自己的Test Spy实现。
在这种情况下,双重测试(Test Double)与生产实施一样涉及。 这是我们的示例多么简单的产物。 在大多数应用中,双重测试(Test Double)比其所代表的具体生产实施要简单得多。重要的部分是提供IMessageWriter
的特定于测试的实现,以确保您一次仅测试一件事。 目前,您正在测试Salutation
类的Exclaim
方法,因此您不希望IMessageWriter
的生产实现污染测试。 若要创建Salutation
类,请使用构造函数注入(Constructor Injection)传递IMessageWriter
的Test Spy实例。
在执行 SUT 之后,您可以调用Assert.Equal
来验证预期结果是否等于实际结果。 如果使用字符串“ Hello DI!”调用了IMessageWriter.Write
方法。SpyMessageWriter
会将其存储在其WrittenMessage
属性中,并且Equal
方法完成。但是,如果未调用Write
方法或使用其他值调用了Write
方法,则Equal
方法将引发异常,并且测试将失败。
松散耦合(Loose Coupling)提供了许多好处:代码变得更易于开发、维护和扩展,并且更易于测试 (code becomes easier to develop, maintain,and extend, and it becomes more Testable)。这甚至不是特别困难。我们是根据接口编程的,而不是具体的实现。唯一的主要障碍是弄清楚如何获得这些接口的实例
。DI通过从外部注入依赖项来克服这个障碍。造函数注入(Constructor Injection)是实现这一点的首选方法,不过在第4章中我们还将探讨一些其他选项。
注入什么和不注入什么 (What to inject and what not to inject)
在上一节中,我们描述了首先考虑DI的动机。如果您确信松散耦合(Loose Coupling)是有好处的,则可以使所有松散耦合(Loose Coupling)成为可能。总体来说,这是个好主意。当您需要决定如何包装模块时,松散耦合(Loose Coupling)特别有用。但是,您不必将所有内容都抽象出来并使其可插入。在本节中,我们将提供一些决策工具,以帮助您决定如何对依赖关系进行建模。
.NET BCL由许多程序集组成。每次编写使用BCL程序集中的类型的代码时,都会向模块添加依赖项。在上一节中,我们讨论了松散耦合(Loose Coupling)如何重要以及如何对接口进行编程是基石。这是否意味着您不能引用任何BCL程序集,而直接在您的应用程序中使用它们的类型?如果您想使用System.Xml
程序集中定义的XmlWriter
,该怎么办?
你不必平等对待所有的依赖关系。BCL中的许多类型可以在不影响应用程序耦合程度的情况下使用—但不是所有类型。了解如何区分不构成危险的类型和可能加强应用程序耦合程度的类型是很重要的。主要关注后者。
当您学习DI时,将依赖项分类为稳定性依赖项(Stable Dependencies)和过度性依赖项(Volatile Dependencies)是很有帮助的。决定接缝(Seams)在哪里很快就会成为你的习惯。下一节将更详细地讨论这些概念。
接缝(Seam)--组件与组件的连接点
在决定对抽象类而不是具体类型进行编程的任何地方,都将接缝(Seam)引入应用程序中。 接缝(Seam)是从应用程序的各个组成部分组装应用程序的地方,类似于在接缝(Seam)处将衣服缝在一起的方式。也是一个可以拆卸应用程序并独立处理模块的地方。
Hello DI!
我们在第1.2节中构建的示例包 Salutation
和ConsoleMessageWriter
之间的接缝(Seam),如下图所示。Salutation
类并不直接依赖于ConsoleMessageWriter
类,而是使用IMessageWriter
接口来编写消息。您可以在这个接缝(Seam)处将应用程序拆开,然后用不同的消息编写器重新组装它。
Hello DI中的接缝(Seam)! 第1.2节中的申请 |
---|
The Hello DI application contains a SEAM between the Salutation and
ConsoleMessageWriter
classes because the Salutation class only writes through the ABSTRACTION of theIMessageWriter
interface
Hello DI
应用程序在Salutation
和ConsoleMessageWriter
类之间包含一个接缝(SEAM),因为Salutation
类只通过IMessageWriter
接口的抽象类进行写入
IMessageWriter
就是 接缝(Seam) 的接入点
稳定性依赖项(Stable Dependencies)
BCL中的许多模块及其后的模块都不会对应用程序的模块化程度构成威胁。 它们包含可重用的功能,您可以使用这些功能使自己的代码更简洁。 BCL模块始终对您的应用程序可用,因为它需要运行.NET Framework,并且由于它们已经存在,因此对并行开发的关注不适用于这些模块。 您始终可以在另一个应用程序中重用BCL库。
默认情况下,您可以将BCL中定义的大多数(但不是全部)类型视为安全或稳定依赖项(Stable Dependencies)。 我们称它们为稳定性的是因为它们已经存在了,它们倾向于向后兼容,并且调用它们具有确定的结果。大多数稳定依赖项都是BCL类型,但其他依赖项也可以是稳定的。稳定依赖项的重要标准包括:
- 类或模块已存在。
- 您希望新版本不会包含突破性的更改。
- 所讨论的类型包含确定性算法。
- 您永远都不会期望用另一个替换,包装,修饰或拦截该类或模块。
其他示例可能包括专门的库,这些库封装了与您的应用程序相关的算法。例如,如果您正在开发涉及化学的应用程序,则可以引用包含化学特定功能的第三方库。
引用DI容器(DI Container)
DI容器(DI Container)本身可能被视为稳定依赖项(Stable Dependencies)或过度性依赖项(Volatile Dependency),具体取决于您是否要替换它们。当您决定将应用程序基于特定的DI容器(DI Container)时,您可能会在整个应用程序生命周期中被这种选择所困扰。这就是为什么您应该将容器的使用限制在应用程序入口点的另一个原因。仅入口点应引用DI容器(DI Container)。
通常,通过排除可以将依赖项视为稳定的。如果它们不易波动,则它们是稳定的。
过度性依赖项(Volatile Dependency)
将接缝(Seam)引入应用程序是额外的工作,因此,仅在必要时才进行操作。有必要在接缝(Seam)后面隔离一个依赖关系的原因可能有多个,但这些原因与松散耦合(Loose Coupling)的好处密切相关(在第1.2.1节中进行了讨论)。
这种依赖关系可以通过它们干扰这些好处中的一个或多个的倾向来识别。它们不稳定,因为它们没有为应用程序提供足够的基础,因此我们将它们称为过度性依赖。如果满足以下任何条件,则应将依赖关系视为过度性:
-
依赖项引入了为应用程序设置和配置运行时环境的要求(The Dependency introduces a requirement to set up and configure a runtime environment for the application.)。易变的不是具体的.NET类型,而是它们所暗示的有关运行时环境的信息。
数据库是过度性依赖项(Volatile Dependency)的BCL类型的很好的例子,而关系数据库是典型的例子。 如果您不将关系数据库隐藏在接缝(Seam)的后面,则永远不能用任何其他技术替换它。这也使设置和运行自动化单元测试(Unit testing)变得困难。(即使Microsoft SQL Server客户端库是BCL中包含的一项技术,其用法也意味着关系数据库。)其他进程外资源(如消息队列,web services甚至文件系统)都属于此类。这种依赖性的症状是缺乏后期绑定(Late binding)和可扩展性(extensibility),以及可测试性被禁用。
-
依赖关系尚不存在,或者仍在开发中(The Dependency doesn't yet exist, or is still in development.)。
-
依赖关系未安装在开发组织中的所有计算机上(The Dependency isn't installed on all machines in the development organization.)。 可能无法在所有操作系统上都安装昂贵的第三方库或依赖项的情况就是如此。 最常见的症状是禁用可测试性。
-
依赖关系包含不确定性行为(The Dependency contains nondeterministic behavior)。 这在单元测试(Unit testing)中尤其重要,因为所有测试都必须是确定性的。不确定性的典型来源是随机数和取决于当前日期或时间的算法。
由于BCL定义了不确定性的常见来源,例如
System.Random
,System.Security.Cryptography.RandomNumberGenerator
或System.DateTime
。现在,您不可避免地要引用定义它们的程序集。 但是,您应该将它们视为过度性依赖项(Volatile Dependency),因为它们会破坏可测试性。
重要 过度性依赖项(Volatile Dependency)是DI的重点。您是在应用程序中引入接缝(Seams)的原因是过度性依赖项(Volatile Dependency),而不是稳定的依赖项(Stable Dependencies)。同样,这使您有义务使用DI进行合成。
既然您了解了稳定性依赖项(Stable Dependencies)和过度性依赖项(Volatile Dependency)之间的区别,就可以开始了解DI范围的轮廓。松散耦合(Loose Coupling)是一种普遍的设计原则,因此DI(作为使能器)应在代码库中的任何位置。DI主题和良好的软件设计之间并没有强硬的界限,但是要定义本书其余部分的范围,我们将快速介绍它所涵盖的内容。
DI生命周期(DI scope)
正如我们之前所讨论的,DI的一个重要元素是将各种职责分解为单独的类。我们从类中拿走的一项职责是创建依赖关系的实例。创建依赖关系实例的任务称为对象组合(Object Composition)。
我们在Hello DI! 中讨论了这一点! 例如,释放我们的Salutation
类以创建其依赖关系的责任。 相反,此职责已移至应用程序的Main
方法。 图1.11再次显示了UML图。
图1.11 Hello DI的协作者之间的关系!应用(重复) |
---|
The Main method takes responsibility for the creation of both Salutation and
ConsoleMessageWriter
.
Main
方法负责创建Salutation
和ConsoleMessageWriter
。
ConsoleMessageWriter
is injected by Main into Salutation.
ConsoleMessageWriter
由Main
注入到Salutation
中。
Salutation
depends onIMessageWriter
and has no idea which implementation it uses.Salutation 只依赖于
IMessageWriter
,不知道它使用哪个实现。
当类放弃对依赖项的控制时,它放弃的不仅仅是选择特定实现的决定。通过这样做,作为开发人员,我们获得了一些优势。一开始,让类放弃对创建对象的控制似乎是一个缺点,但我们并没有失去这种控制——我们只是将它移动到另一个地方。
注意 作为开发人员,我们通过移除类对其依赖项的控制来获得控制权。这是单一责任原则(Single Responsibility Principle)的应用。类不必处理其依赖项的创建。
对象组合(Object Composition)并不是我们删除的唯一控制维度:类也失去了控制对象生命期(lifetime)的能力。当一个依赖实例被注入到一个类中时,使用者不知道它是什么时候被创建的,或者它什么时候会超出范围。这一点消费者不必担心。使使用者忽略其依赖项的生存期简化了使用者。
DI为您提供了以统一的方式管理依赖关系的机会。当使用者直接创建并设置依赖项(Dependencies)实例时,每个实例都可以以自己的方式进行。 这可能与其他消费者的做法不一致。您无法集中管理依赖关系,也无法轻松解决横切关注点(Cross-Cutting Concerns)问题。使用DI,您可以拦截(Intercept)每个依赖(Dependency)实例并在将其传递给使用者之前对其进行操作。 这提供了应用程序的可扩展性。
使用DI,您可以在拦截依赖项并控制其生存期的同时编写应用程序。对象组合(Object Composition)、拦截(Interception)和生存期管理(Lifetime Management)是DI的三个维度。接下来,我们将简要介绍其中的每一项;本书的第3部分将介绍更详细的处理方法。
对象组合(Object Composition)
要获得可扩展性(extensibility)、后期绑定(Late binding)和并行开发(parallel development)的好处,必须能够将类组合到应用程序中。这意味着您将希望通过将各个类放在一起来创建一个应用程序,就像将电器插在一起一样。而且,与电器一样,当引入新的需求时,您希望轻松地重新排列这些类,理想情况下,无需对现有类进行更改。
对象组合(Object Composition) 通常是将DI引入应用程序的主要动机。事实上,最初,DI是对象组合(Object Composition)的同义词;这是Martin Fowler关于这个主题的原始文章中讨论的唯一方面。
您可以通过多种方式将类组合到应用程序中。当我们讨论后期绑定(Late binding)时,我们使用了一个配置文件和一些动态对象实例化来从可用模块中手动组成应用程序。我们还可以使用DI容器(DI Container)将“配置代替代码”使用。 我们将在第12章中返回这些内容。
许多人将DI称为控制反转(IoC)。这两个术语有时可以互换使用,但DI是IoC的子集。在整本书中,我们始终使用最具体的术语-DI。 如果我们指的是IoC,我们将专门提及它。
依赖注入还是控制反转?
“控制反转(Inversion of Control originally)”一词最初是指任何一种编程风格,其中一个整体框架或运行时控制程序流。根据该定义,大多数在.NET框架上开发的软件都使用
IoC
。当你写一个ASP.NET.例如,当您编写ASP.NET Core MVC
应用程序时,您会创建带有操作方法的控制器类,但它的ASP.NET Core
会调用您的操作方法。 这意味着您不受控制-框架受控制。现在,我们已经习惯于使用框架,所以我们不认为这有什么特别之处,但这是一种不同于完全控制代码的模式。对于.NET应用程序,尤其是命令行可执行文件,仍然可能发生这种情况。一旦调用Main,您的代码就处于完全控制状态。它控制程序流程,生命周期—一切。没有引发任何特殊事件,也没有调用任何重写的成员。
在DI得名之前,人们开始将管理依赖项的库称为“控制反转(Inversion of Control Containers)”,不久,
IoC
的含义逐渐向该特定含义漂移:“对依赖关系的控制倒置(Inversion of Control over Dependencies)”。 始终是分类学家,马丁·福勒(Martin Fowler)引入了术语“依赖注入(Dependency Injection)”以在依赖管理的上下文中专门指IoC
。 此后,依赖注入已被广泛接受为最正确的术语。 简而言之,IoC
是一个更广泛的术语,包括但不限于DI。
对象生命周期(Object Lifetime)
放弃了对其依赖项(Dependencies)的控制的类放弃的不仅仅是选择抽象的特定实现的能力。它还放弃了控制实例何时创建以及何时超出范围的能力。
在.NET中,垃圾收集器(garbage collector)会为我们处理这些事情。消费者(consumer)可以将其依赖项(Dependencies)注入其中,并在需要时使用它们。完成后,相关性将超出范围。如果没有其他类引用它们,则可以进行垃圾回收(garbage collection)。
如果两个使用者共享相同类型的依赖关系,该怎么办? 清单1.5展示了您可以选择向每个使用者使用一个单独的实例,而清单1.6则表明您可以选择在多个使用者之间共享一个实例。但是从 消费者(consumer)的角度来看,没有什么区别。根据里氏替换原则(Liskov Substitution Principle),使用者必须平等对待给定接口的所有实例。
清单1.5 获得相同依赖类型的实例的使用者
IMessageWriter writer1 = new ConsoleMessageWriter();
IMessageWriter writer2 = new ConsoleMessageWriter(); <--创建具有相同IMessageWriter依赖关系的两个实例
var salutation = new Salutation(writer1);
var valediction = new Valediction(writer2); <--每个使用者都有自己的私有实例。
清单1.6 共享同一类型依赖项实例的使用者
IMessageWriter writer = new ConsoleMessageWriter(); <--创建一个实例。
var salutation = new Salutation(writer);
var valediction = new Valediction(writer); <---相同的实例被注入到两个使用者中。
由于可以共享依赖关系,因此单个使用者无法控制其生命周期。只要被管理对象可以超出范围并被垃圾回收,这并不是什么大问题。 但是,当依赖项实现IDisposable
接口时,事情将变得更加复杂,我们将在8.2节中进行讨论。 总体而言,生命周期管理(Lifetime Management)是DI的一个独立维度,并且非常重要,因此我们将第8章的所有内容都保留了下来。
拦截(Interception)
当我们将依赖项的控制权委托给第三方时,如图1.12所示,我们还提供了在将依赖项传递给使用依赖项的类之前修改依赖项的能力。
在Hello DI!
例如,我们最初将一个ConsoleMessageWriter
实例注入到一个Salutation
实例中。然后,修改这个示例,我们通过创建一个新的SecureMessageWriter
添加了一个安全特性,当用户通过身份验证时,它只将进一步的工作委托给ConsoleMessageWriter
。这允许您保持单一责任原则(Single Responsibility Principle.)
。这样做是可能的,因为您总是对接口进行编程;回想一下,依赖项(Dependencies)必须始终是抽象类(Abstractions)的。对于Salutation
,它并不关心提供的IMessageWriter
是ConsoleMessageWriter
还是SecureMessageWriter
。SecureMessageWriter
可以包装仍然执行实际工作的ConsoleMessageWriter
。
图1.12 拦截ConsoleMessageWriter |
---|
翻译
The
ConsoleMessageWriter
DEPENDENCY is directly injected into its consumer, Salutation.
ConsoleMessageWriter
依赖关系直接注入到其消费者Salutation
中。The arrows indicate the direction of the injection; the direction of DEPENDENCY goes the opposite way.
箭头指示注入的方向;依赖关系的方向则相反。
Instead of injecting the originally intended
ConsoleMessageWriter
DEPENDENCY, you can modify it by wrapping another class around it before you pass it on to its consumer. In this case, the wrapper class is theSecureMessageWriter
.您可以在将
ConsoleMessageWriter
传递给其使用者之前,通过在ConsoleMessageWriter
周围包装另一个类来修改它,而不是注入最初想要的ConsoleMessageWriter
依赖项。在本例中,包装器类是SecureMessageWriter
。
说明 拦截(Interception)是装饰器(Decorator)设计模式的一个应用。如果您不熟悉装饰器(Decorator)设计模式,请不要担心。我们将在第9章提供一个复习,它完全致力于拦截(Interception)。
这种拦截能力使我们沿着面向方面编程(Aspect-Oriented Programming)(AOP)的道路前进,这是一个密切相关的主题,我们将在第10章和第11章中介绍。通过拦截(Interception)和AOP,您可以以一种结构良好的方式应用横切关注点(Cross-Cutting Concerns),如日志记录、审计、访问控制、验证等,从而保持关注点分离(Separation of Concerns)。
DI的三个维度
尽管DI一开始是一系列旨在解决对象组合(Object Composition)问题的模式,但后来这个术语也扩展到了对象生命周期(Object Lifetime)和拦截(Interception)。今天,我们认为DI以一种一致的方式包含了这三个方面。
对象组合(Object Composition)往往会主导整个画面,因为如果没有灵活的对象组合(Object Composition),就不会有截获,也不需要管理对象生命期(Object Lifetime)。对象组合(Object Composition)已经主导了本章的大部分内容,并将继续主导这本书,但你不应该忘记其他方面。对象组合(Object Composition)提供了基础,生命周期管理(Lifetime Management)解决了一些重要的副作用。但主要是在拦截(Interception)方面,你才开始收获好处。
结论
依赖注入(Dependency Injection)是达到目的的手段,而不是目标本身。这是实现松散耦合(Loose Coupling)的最佳方法,松散耦合(Loose Coupling)是可维护代码的重要组成部分。松散耦合(Loose Coupling)带来的好处并不总是显而易见的,但随着代码库复杂性的增加,这些好处会随着时间的推移而变得明显。关于与DI相关的松散耦合(Loose Coupling)的一个重要观点是,为了有效,它应该在您的代码库中无处不在。
紧密耦合(tightly coupled)的代码库最终会退化为意大利面代码;而设计良好、松散耦合(Loose Coupling)的代码库可以保持可维护性。要达到真正灵活的设计,需要的不仅仅是松散耦合(Loose Coupling),但接口编程是一个先决条件。
提示 DI必须是无处不在的。您不能轻易地将松散耦合(Loose Coupling)改装到现有的代码库上。
DI只是设计原则和模式的集合。与其说工具和技术,不如说是一种思考和设计代码的方式。DI的目的是使代码可维护。 小型代码库(例如经典的Hello World示例)由于其大小而具有固有的可维护性。 这就是为什么在简单示例中,DI往往看起来像是过度设计。 代码库越大,好处越明显。 我们在接下来的两章中专门介绍了一个更大,更复杂的示例,以展示这些好处。
总结
- 依赖注入(Dependency Injection)是一组软件设计原则和模式,使您能够开发松散耦合(Loose Coupling)的代码。松散耦合(Loose Coupling)使代码更易于维护。
- 当您有一个松散耦合(Loose Coupling)的基础设施时,任何人都可以使用它,并且可以适应不断变化的需求和意外的需求,而不必对应用程序的代码库及其基础设施进行大的更改。
- 由于可能的罪魁祸首的范围缩小了,故障排除的工作量往往减少了。
- DI支持后期绑定(Late binding),即用不同的类或模块替换类或模块,而不需要重新编译原始代码。
- DI使您可以更轻松地以未明确计划的方式扩展和重用代码,类似于使用电源插头和插座时具有灵活性的方式。
- DI简化了在相同代码库上的并行开发,因为关注点分离(Separation of Concerns)使每个团队成员甚至整个团队都可以更轻松地在孤立的部分上工作。
- DI使软件更具可测试性(Testable),因为在编写单元测试(Unit testing)时可以用测试实现替换依赖项。
- 当您练习DI时,协作类应该依靠基础结构来提供必要的服务。 您可以通过让类依赖于接口而不是具体的实现来实现此目的。
- 班级不应该向第三方询问他们的依存关系。 这是一个称为服务定位器(Service Locator)的反模式(anti-pattern)。 相反,类应该使用构造函数参数静态地指定其所需的依赖关系,这种做法称为构造函数注入(Constructor Injection)。
- 许多开发人员认为DI需要专门的工具,即所谓的DI容器(DI Container)。 这是一个神话。 DI容器(DI Container)是有用但可选的工具。
- 实现DI的最重要的软件设计原则之一是里氏替换原则(Liskov Substitution Principle)。它允许将接口的一个实现替换为另一个实现,而不破坏客户机或实现。
- 依赖项(Dependencies)被认为是稳定的,因为它们已经可用,具有确定性行为,不需要安装运行时环境(如关系数据库),并且不需要被替换、包装或拦截。
- 当依赖项(Dependencies)处于开发阶段、并非在所有开发机器上都可用、包含不确定性行为或需要被替换、包装或拦截时,它们被认为是不稳定的。
- 过度性依赖项(Volatile Dependency)是DI的焦点。我们将过度性依赖项(Volatile Dependency)注入到类的构造函数中。
- 通过从使用者中移除对依赖项(Dependencies)的控制,并将该控制移到应用程序入口点,您可以更轻松地应用横切关注点(Cross-Cutting Concerns),并且可以更有效地管理依赖关系的生命周期。
- 为了成功,你需要广泛地应用DI。所有类都应该使用构造函数注入(Constructor Injection)获得它们所需的过度性依赖项(Volatile Dependency)。很难在现有的代码基础上改进松散耦合(Loose Coupling)和DI。