第五章-DI反模式
许多菜肴都需要用油在锅中烹饪食物。如果您不熟悉手头的食谱,则可以开始加热油,然后转身阅读食谱。但是一旦切完蔬菜,油就会冒出来。 您可能会认为吸烟油意味着平底锅很热,可以煮饭了。对于没有经验的厨师,这是一个普遍的误解。当油开始冒烟时,它们也开始分解。 这就是所谓的烟点。 一旦加热到超过其烟点,大多数油不仅味道难闻,还会形成有害化合物并失去有益的抗氧化剂。
在上一章中,我们简要地将设计模式与配方进行了比较。模式提供了一种通用语言,我们可以用来简洁地讨论一个复杂的概念。当概念(或更确切地说,实现)变形时,我们手上就有一个反模式(anti-pattern)。
定义 反模式(anti-pattern)是解决问题的一种常见方法,它会产生明显的负面影响,尽管也可以使用其他证明有效的书面解决方案。
加热油超过其烟点是可以视为烹饪反模式(anti-pattern)的典型示例。这是一个常见的错误。许多经验不足的厨师都会这样做,因为这样做似乎很合理,但是失去口味和不健康的食物是不利的后果。
反模式(anti-pattern)或多或少是一种形式化的方式,用来描述人们一次又一次地犯下的常见错误。在本章中,我们将介绍一些与DI相关的常反模式(anti-pattern)。在我们的职业生涯中,我们已经看到所有它们都以一种或多种形式使用,并且我们自己犯了所有这些罪名。
在许多情况下,反模式(anti-pattern)表示在应用程序中实施DI的真诚尝试。但是由于不完全遵守DI基础,因此这些实现可能演变为弊大于利的解决方案。 了解这些反模式(anti-pattern)可以使您了解在冒险进入第一个DI项目时应注意的陷阱。但是,即使您已经使用DI多年,仍然很容易出错。
警告 本章与其他章不同,因为我们将向您展示的大多数代码都提供了有关如何不实施DI的示例。 不要在家尝试!
可以通过将代码重构为第4章介绍的DI模式之一来固定反模式(anti-pattern)。确切地说,解决每种情况的难易程度取决于实现的细节。对于每种反模式(anti-pattern),我们都会提供一些通用指导,说明如何将其重构为更好的模式。
注意 由于这不是本书的主题,因此我们从DI反模式(anti-pattern)到DI模式重构的内容受到本章篇幅的限制。如果您想了解更多有关如何将现有应用程序向DI方向迁移的知识,则整本书将讨论重构此类应用程序:有效地使用旧版代码(Michael C. Feathers,Prentice Hall,2004年)。尽管它不仅仅涉及DI,但它涵盖了我们在此处所做的许多相同概念。
旧版代码有时需要采取严厉措施才能使您的代码可测试。这通常意味着需要采取一些小步骤来防止意外破坏先前可以正常运行的应用程序。在某些情况下,反模式(anti-pattern)可能是最合适的临时解决方案。尽管反模式(anti-pattern)的应用可能是对原始代码的改进,但需要注意的是,这并不能使反模式(anti-pattern)变得不那么重要。 存在其他证明有效的可重复解决方案。 表5.1中列出了本章介绍的反模式(anti-pattern)。
表5.1 DI反模式(anti-pattern)
反模式(anti-pattern) | 描述 |
---|---|
控制怪物(Control Freak) | 与控制反转相反,依赖关系是直接控制的。 |
服务定位器(Service Locator) | 隐式服务可以为消费者提供依赖关系,但不能保证这样做。 |
环境上下文(Ambient Context) | 通过静态访问器提供单个依赖关系。 |
约束构造(Constrained Construction) | 假定构造函数具有特定的签名。 |
本章的其余部分将更详细地描述每个反模式(anti-pattern),并按重要性顺序显示它们。 您可以从头到尾阅读,也可以只阅读自己感兴趣的内容,每个部分都有独立的部分。如果您决定只阅读本章的部分内容,建议您阅读控制怪物(Control Freak)和服务定位器(Service Locator)。
正如构造函数注入(Constructor Injection)是最重要的DI模式一样,控制怪物(Control Freak)是最常出现的反模式(anti-pattern)。 它有效地阻止了您应用任何适当的DI,因此在解决其他问题之前,我们需要着重研究这种反模式(anti-pattern)—您也应该如此。 但是,由于服务定位器(Service Locator)看起来正在解决问题,所以这是最危险的。我们将在5.2节中解决。
控制怪物(Control Freak)
控制反转(Inversion of Control)有什么反面? 最初,控制反转(Inversion of Control)一词的产生是为了识别与正常情况相反的事物,但我们不能谈论“照常营业”的反模式(anti-pattern)。 取而代之的是,控制怪物(Control Freak)描述了一个不会放弃对其过度性依赖(Volatile Dependency)的控制类。
定义 每当您在组合根(Composition Root)以外的任何地方过度性依赖(Volatile Dependency)时,都会出现控制怪物(Control Freak)。这违反了我们在3.1.2节中讨论的依赖倒置原则(Dependency Inversion Principle)。
例如,当您使用new
关键字创建Volatile
依赖项的新实例时,就会出现控制怪物(Control Freak)反模式(anti-pattern)。下面的清单演示了控制怪物反模式(Control Freak anti-pattern)的实现。
清单5.1 控制怪物(Control Freak)反模式(anti-pattern)示例 (坏代码)
public class HomeController : Controller
{
public ViewResult Index()
{
var service = new ProductService();
<---HomeController创建过度性依赖(Volatile Dependency)的新实例ProductService,从而导致代码紧密耦合。
var products = service.GetFeaturedProducts();
return this.View(products);
}
}
每次创建Volatile
依赖项时,都会显式声明要控制实例的生存期,其他人将没有机会截获该特定对象。尽管new
关键字在涉及到过度性依赖(Volatile Dependency)是一种代码的味道,但是您不必担心将其用于稳定性依赖项(Stable Dependencies)
注意 一般来说,
new
关键字并不是突然非法的,但是您应该避免使用它来获取组合根(Composition Root)之外的过度性依赖(Volatile Dependency)实例。另外,要注意静态类。静态类也可以是可变的依赖项。虽然您永远不会在静态类上使用new
关键字,但依赖它们会导致相同的问题。
控制怪物(Control Freak)最明显的例子是当你不努力在代码中引入抽象的时候。当Mary实现她的电子商务应用程序(第2.1节)时,您在第2章中看到了几个这样的例子。这种方法并不试图引入DI。但是,即使开发人员听说过DI和可组合性,控制怪物反模式(Control Freak anti-pattern)也经常出现在一些变体中。
在下一节中,我们将向您展示一些类似于我们在生产中看到的代码的示例。在每一种情况下,开发人员都有对接口进行编程的最佳意图,但始终不了解潜在的力量和动机。
示例:通过更新依赖项来控制怪物(Control Freak)
许多开发人员都听说过接口编程的原理,但不了解其背后的深层原理。为了做正确的事情或遵循最佳实践,他们编写的代码没有多大意义。例如,在清单3.9中,您看到了一个ProductService
的示例,它使用IProductRepository
接口的实例来检索特色产品的列表。作为提醒,以下代码重复相关代码:
public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
return
from product in this.repository.GetFeaturedProducts()
select product.ApplyDiscountFor(this.userContext);
}
突出的一点是,repository
成员变量表示一个抽象。在第3章中,您看到了如何通过构造函数注入(Constructor Injection)填充repository
字段,但我们也看到了其他更为天真的尝试。下面的清单显示了一个这样的尝试。
清单5.2 更新
ProductRepository
(坏代码)
private readonly IProductRepository repository;
public ProductService()
{
this.repository = new SqlProductRepository();
<---Control Freak反模式的一个示例,它直接在构造函数中创建一个新实例,从而导致代码紧密耦合。
}
repository
字段被声明为IProductRepository
接口,因此ProductService
类(如GetFeaturedProducts
)中的任何成员都可以将程序添加到接口。尽管这听起来是正确的做法,但这样做并没有什么好处,因为在运行时,类型始终是SqlProductRepository
。除非更改代码并重新编译,否则无法截获或更改存储库变量。此外,如果您硬编码一个变量以使其始终具有特定的具体类型,那么将其定义为抽象将不会获得太多好处。直接更新依赖项是控制怪物(Control Freak)反模式(anti-pattern)的一个例子。
在我们开始分析和解决由控制怪物(Control Freak)
产生的问题的可能方法之前,让我们看更多的例子,让您更好地了解上下文和常见的失败尝试。在下一个例子中,很明显,解决方案不是最优的。大多数开发人员都会尝试改进他们的方法。
示例:通过工厂处理控制怪物(Control Freak)(Example: Control Freak through factories)
解决依赖关系更新带来的明显问题的最常见和错误的尝试涉及某种工厂。 当涉及工厂时,有多种选择。 我们将快速介绍以下各项:
- 混合工厂(Concrete Factory)
- 抽象工厂(Abstract Factory)
- 静态工厂(Static Factory)
如果告诉她只能处理IProductRepository
抽象,Mary Rowan(来自第2章)将介绍一个ProductRepositoryFactory
,该工厂将生成她需要获取的实例。 在她与同事Jens讨论这种方法时,请听一下。 我们预计,他们的讨论将很方便地涵盖我们列出的工厂选项。
Mary:我们需要在此ProductService
类中的IProductRepository
实例。 但是IProductRepository
是一个接口,因此我们不能只是创建它的新实例,我们的顾问说我们也不应该创建SqlProductRepository
的新实例。
Jens:那某种工厂呢?
Mary:是的,我在想同样的事情,但是我不确定该如何进行。 我不明白它如何解决我们的问题。 看这里 -
Mary开始编写一些代码来演示她的问题。 这是玛丽写的代码:
public class ProductRepositoryFactory
{
public IProductRepository Create()
{
return new SqlProductRepository();
}
}
混合工厂(Concrete Factory)
Mary:这个ProductRepositoryFactory
封装了有关如何创建ProductRepository
实例的知识,但是并不能解决问题,因为我们必须像这样在ProductService
中使用它:
var factory = new ProductRepositoryFactory();
this.repository = factory.Create();
看? 现在,我们需要在ProductService
中创建ProductRepositoryFactory
类的新实例,但这仍然对SqlProductRepository
的使用进行了硬编码。我们唯一要做的就是将问题转移到另一类。
Jens:是的,我知道了-我们不能用抽象工厂(Abstract Factory)解决问题吗?
让我们暂停Mary和Jens的讨论以评估发生了什么。Mary完全正确,因为混合工厂(Concrete Factory)类不能解决控制怪物(Control Freak)问题,而只能解决问题。它使代码更加复杂而不增加任何值。现在,ProductService
直接控制工厂的生命周期,而工厂直接控制ProductRepository
的生命周期,因此您仍然无法在运行时拦截(Intercept)或替换Repository
实例。
注意 不要从本节中得出结论,我们通常反对使用混合工厂(Concrete Factory)类。 通过封装复杂的创建逻辑,混合工厂(Concrete Factory)可以解决其他问题,例如代码重复。 但是,它对于DI并没有任何价值。 在合理时使用它。
很明显,混合工厂(Concrete Factory)无法解决任何DI问题,而且我们从未见过以这种方式成功使用它。 詹斯(Jens)对抽象工厂(Abstract Factory)的评论听起来更有希望。
抽象工厂(Abstract Factory)
让我们继续Mary和Jens的讨论,听听Jens对抽象工厂(Abstract Factory)的看法。
Jens:如果我们像这样将工厂抽象化怎么办?
public interface IProductRepositoryFactory
{
IProductRepository Create();
}
这意味着我们尚未对SqlProductRepository
的任何引用进行硬编码,并且可以使用ProductService
中的工厂来获取IProductRepository
的实例。
Mary:但是既然工厂是抽象的,我们如何获得它的新实例?
Jens:我们可以创建它的实现,该实现返回SqlProductService
实例。
Mary:是的,但是我们如何创建该实例呢?
Jens:我们只是在ProductService
中对其进行了更新...哦。 等待 -
Mary:那会让我们回到开始的地方。
玛丽和詹斯很快意识到抽象工厂(Abstract Factory)不会改变他们的处境。 他们最初的难题是他们需要抽象IProductRepository
的实例,而现在他们需要抽象IProductRepositoryFactory
的实例.
摘要工厂通常被过度使用(Abstract Factories are commonly overused)
抽象工厂模式(Abstract Factory)是原始设计模式书中的模式之一。抽象工厂模式(Abstract Factory)比您可能意识到的要普遍得多。 所涉及的类的名称通常隐藏了这一事实(例如,不以Factory结尾)。
但是,当涉及到DI时,抽象工厂经常被滥用。 在第6章中,我们将返回到抽象工厂模式(Abstract Factory),并查看为什么它比代码气味更常见。
既然Mary和Jens拒绝了抽象工厂模式(Abstract Factory)作为可行的选择,那么还有一个破坏性的选择仍然存在。 Mary和Jens即将得出结论。
静态工厂(Static Factory)
让我们听听Mary和Jens决定他们认为可行的方法。
Mary:让我们建立一个静态工厂(Static Factory)。 让我演示给你看:
public static class ProductRepositoryFactory
{
public static IProductRepository Create()
{
return new SqlProductRepository();
}
}
现在,该类是静态的,我们无需处理如何创建它。
Jens:但是我们仍然对返回SqlProductRepository
实例进行了硬编码,那么它对我们有什么帮助?
Mary:我们可以通过配置设置来处理此问题,该配置设置确定要创建的ProductRepository
类型。 像这样:
public static IProductRepository Create()
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
string repositoryType = configuration["productRepository"];
switch (repositoryType)
{
case "sql": return new SqlProductRepository();
case "azure": return AzureProductRepository();
default: throw new InvalidOperationException("...");
}
}
看? 通过这种方式,我们可以确定我们应该使用基于SQL Server
的实现还是基于Microsoft Azure的实现,甚至不需要重新编译应用程序就可以从一个应用程序转换为另一个应用程序。
Jens : Cool! 这就是我们要做的。 那位顾问现在一定很高兴!
注 Mary和Jens的静态
ProductRepositoryFactory
在运行时从配置文件中读取,但是从2.3.3节中回想起这是有问题的:只有完成的应用程序才应依赖配置文件。 应用程序的其他部分(例如ProductRepositoryFactory
)不应从配置文件中请求值,而应由其调用者进行配置。
这样的静态工厂(Static Factory)不能为接口编程的最初目标提供令人满意的解决方案,原因有很多。看一下图5.1中的依赖图。
图5.1 提议的ProductRepositoryFactory 解决方案的依赖关系图 |
---|
翻译
The factory depends on the concrete repository implementations and drags along these DEPENDENCIES. This causes
ProductService
to implicitly depend on them as well because dependencies are transitive.工厂依赖于具体的存储库实现,并拖延这些依赖项。 这也会导致
ProductService
也隐式依赖于它们,因为依赖关系是可传递的。A static
ProductRepositoryFactory
is used byProductService
to createIProductRepository
derivatives.
ProductService
使用静态ProductRepositoryFactory
来创建IProductRepository
派生类。
所有类都需要引用抽象IProductRepository
,如下所示:
ProductService
,因为它使用了IProductRepository
实例ProductRepositoryFactory
,因为它创建了IProductRepository
实例AzureProductRepository
和SqlProductRepository
,因为它们实现了IProductRepository
ProductRepositoryFactory
依赖于AzureProductRepository
和SqlProductRepository
类。 因为ProductService
直接依赖于ProductRepositoryFactory
,所以它也依赖于两个具体的IProductRepository
实现-从4.1.4节回想起依赖关系是可传递的。
我们没有弥补(We're not making this up)
如果我们是本例中的顾问,我们将不会很高兴。实际上,这种解决方案是在Mark参与的项目中提出的,而Steven在过去曾多次经历过类似的设计。Mark参与的项目是一个相当大的项目,目标是财富500强公司的核心业务领域。由于应用程序的复杂性,适当的模块化非常重要。不幸的是,Mark加入该项目太晚了,他的建议被驳回了,因为它们涉及到已经开发的代码库的巨大变化。
Mark继续从事其他项目,但后来获悉,尽管团队设法交付了足够的钱来履行合同,但该项目被认为是失败的,并且努力奋斗。断言该项目失败仅是因为没有雇用DI才是不合理的,但是所采用的方法是缺乏适当设计的征兆。
只要ProductService
依赖于静态ProductRepositoryFactory
,就存在无法解决的设计问题。 如果您在域层中定义静态ProductRepositoryFactory
,则意味着领域层需要依赖于数据访问层,因ProductRepositoryFactory
会创建一个位于该层的SqlProductRepository
。 但是,数据访问层已经取决于领域层,因为SqlProductRepository
使用该层中的类型和抽象,例如Product
和IProductRepository
。这在两个项目之间产生了循环引用。此外,如果将ProductRepositoryFactory
移到数据访问层,由于ProductService
依赖于ProductRepositoryFactory
,因此仍然需要从领域层到数据访问层的依赖关系。这仍然会导致循环依赖性。 图5.2显示了此设计问题。
图5.2 由静态ProductRepositoryFactory 引起的领域层和数据访问层之间的循环依赖关系 |
---|
翻译
Because it depends on both
IProductRepository
andSqlProductRepository
, no matter where you placeProductRepositoryFactory
, it causes a cyclic dependency between the domain layer and the data access layer.因为它同时依赖于
IProductRepository
和SqlProductRepository
,所以无论您将ProductRepositoryFactory
放在何处,都将导致领域层和数据访问层之间的循环依赖。
无论您如何移动类型,防止项目之间出现这些循环依赖性的唯一方法是为所有类型创建一个项目。但是,这不是一个可行的选择,因为它将领域层与数据访问层紧密耦合(tightly coupled),并且不允许替换您的数据访问层。
Mary和Jens最终没有紧密耦合(tightly coupled)的IProductRepository
实现,而是使用了紧密耦合(tightly coupled)的模块。更糟糕的是,工厂总是会拖延所有实施,甚至是不需要的实施! 如果它们托管在Azure
上,则仍需要将Commerce.SqlDataAccess.dll
(例如)与应用程序一起分发。
如果Mary和Jens需要第三种IProductRepository
,则必须更改工厂并重新编译其解决方案。 尽管他们的解决方案可能是可配置的,但它是不可扩展的;如果一个单独的团队甚至公司需要创建一个新的存储库,那么他们将无法访问源代码。也无法将特定的IProductRepository
实现替换为特定于测试的实现,因为这需要在运行时定义IProductRepository
实例,而不是在设计时在配置文件中静态定义。
简而言之,静态工厂(Static Factory)似乎可以解决问题,但实际上,只会使问题复杂化。即使在最佳情况下,它也会迫使您引用过度性依赖项。当将重载的构造函数与外部默认值(Foreign Default)结合使用时,可以看到此反模式(anti-pattern)的另一个变体,如您在下一个示例中看到的那样。
示例:通过重载的构造函数控制怪物(Control Freak)
构造函数重载在许多.NET代码库(包括BCL
)中非常普遍。 通常,许多重载都为使用所有相关参数作为输入的一两个成熟的构造函数提供了合理的默认值。(这种做法称为构造函数链接(Constructor Chaining))。有时,当涉及到DI时,我们还会看到其他用途。
通用的反模式(anti-pattern)定义了特定于测试的构造函数重载,尽管生产代码使用了无参数构造函数,但该重载允许您显式定义依赖项。 当依赖关系的默认实现表示外部默认值(Foreign Default)而不是本地默认值(Local Default)时,这可能是有害的。 正如我们在4.4.2节中解释的那样,您通常希望使用构造函数注入(Constructor Injection)来提供所有过度性依赖(Volatile Dependency),即使那些可能是本地默认值(Local Default)的依赖项也是如此。
外部默认值(Foreign Default)
外部默认值(Foreign Default)设置与本地默认设置相反。 这是默认情况下使用的过度性依赖(Volatile Dependency)的实现,即使它是在不同于其使用者的模块中定义的。 例如,让我们考虑您在前几章的示例电子商务应用程序中看到的
Repository
实现。诸如
ProductService
之类的服务需要IProductRepository
的实例才能工作。 在许多情况下,开发此类应用程序时,请牢记一个合理的实现:一种通过在关系数据库中读取和写入数据来实现所需功能的方法。 使用这种实现作为默认设置将很诱人。 问题在于,您要牢记的默认实现(SqlProductRepository
)是在其他模块中定义的。 这迫使您对数据访问层施加不良的依赖。拖拽不需要的模块使您失去了松散耦合(Loose Coupling)的许多好处,这些好处在第1章中已进行了讨论。重用域层模块会变得更加困难,因为它会拖拽数据访问模块,尽管您可能想在其他地方使用语境。 这也使并行开发更加困难,因为
ProductService
类现在直接依赖于SqlProductRepository
类。
下面的清单显示了具有默认值和重载构造函数的ProductService
类。 这是什么都不做的一个例子。
清单5.3 具有多个构造函数的
ProductService
(Bad Code)
private readonly IProductRepository repository;
public ProductService()
: this(new SqlProductRepository())
{
} <--无参数构造函数将SqlProductRepository外部默认值转发给重载的构造函数。 这导致领域层耦合到SQL数据访问层
public ProductService(IProductRepository repository)
{
if (repository == null)
throw new ArgumentNullException("repository");
this.repository = repository;
} <---- 注入构造函数接受必需的IProductRepository并将其存储在存储库字段中
乍一看,这种编码风格似乎是两全其美的。 它允许提供虚假的依赖关系以进行单元测试(Unit testing); 但是,仍然可以方便地创建该类,而不必提供其依赖项。 以下示例显示了此样式:
var productService = new ProductService();
通过让ProductService
创建SqlProductRepository
过度性依赖(Volatile Dependency)关系,您可以再次强加模块之间的强耦合。 尽管ProductService
可以在不同的IProductRepository
实现中重用,但通过在测试时通过最灵活的构造函数重载提供它们,可以禁用在应用程序中拦截IProductRepository
实例的功能。
现在,您已经看到了一些控制怪物(Control Freak)的示例,我们希望您对要查找的内容有一个更好的了解-出现在过度性依赖(Volatile Dependency)旁的new
关键字。这可以使您避免最明显的陷阱。但是,如果您需要摆脱这种反模式(anti-pattern)的现有影响,下一节将帮助您处理此类任务。
控制怪物(Control Freak)分析
控制怪物(Control Freak)是控制反转(Inversion of Control)的对立面。当直接控制过度性依赖(Volatile Dependency)的创建时,最终会产生紧密耦合(tightly coupled)的代码,而缺少第1章中概述的许多(如果不是全部)松散耦合(Loose Coupling)的好处。
控制怪物(Control Freak)是最常见的DI反模式(anti-pattern)。它代表了大多数编程语言中创建实例的默认方式,因此即使在开发人员从未考虑过DI的应用程序中也可以观察到它。 这种创建新对象的方法自然而根深蒂固,许多开发人员发现很难丢弃它。 即使开始考虑DI,他们也很难摆脱必须以某种方式控制创建实例的时间和位置的思维定势。放开这种控制可能是一个艰难的心理飞跃。 但是,即使您做到了,也要避免其他(尽管较少)的陷阱。
控制怪物反模式(Control Freak anti-pattern)的负面影响
由于控制怪物(Control Freak)产生了紧密耦合的代码,模块化设计的许多好处都可能会丢失。 前面的每个部分都介绍了这些内容,但总结如下:
- 尽管您可以将应用程序配置为使用多个预配置的依赖项之一,但是您不能随意替换它们(Although you can configure an application to use one of multiple preconfigured Dependencies, you can't replace them at will.)。 无法提供在编译应用程序之后创建的实现,当然也不能提供特定实例作为实现。
- 重用消费模块变得更加困难,因为它拖累了依赖关系,而这种依赖关系在新的上下文中可能是不可取的(It becomes harder to reuse the consuming module because it drags with it Dependencies that may be undesirable in the new context.)。 例如,考虑一个模块,该模块通过使用外部默认值而依赖于
ASP.NET Core
库。这使得很难将该模块作为不应该或不依赖ASP.NET Core
的应用程序(例如Windows Service或移动电话应用程序)的一部分来重用。 - 这使得并行开发更加困难(It makes parallel development more difficult.)。 这是因为使用方应用程序与其依赖项的所有实现紧密相关。
- 可测试性受到影响(Testability suffers.)。双重测试(Test Doubles)不能替代依赖项(Dependency)。
经过精心设计,您仍然可以按照明确定义的职责实施紧密耦合(tightly coupled)的应用程序,从而不会影响可维护性。 但是,即便如此,成本仍然很高,而且您将保留许多限制。 鉴于完成此任务所需的大量精力,没有理由继续投资于控制怪物(Control Freak)。 您需要从控制怪物(Control Freak)转向正确的DI。
从控制怪物(Control Freak)向DI重构
要摆脱控制怪物(Control Freak),您需要将代码重构为第4章介绍的一种正确的DI设计模式。作为第一步,您应使用图4.9中给出的指导来确定要使用的模式。在大多数情况下,这将是构造函数注入(Constructor Injection)。 重构步骤如下:
- 确保您正在编程为抽象。在示例中,情况已经如此。但是在其他情况下,您可能需要首先提取接口并更改变量声明。
- 如果您在多个位置创建依赖关系的特定实现,请将它们全部移至单个创建方法。确保此方法的返回值表示为抽象而不是具体类型。
- 现在您只有一个地方可以创建实例,通过实现一种DI模式(例如构造函数注入(Constructor Injection)),可以将这种创建从使用类中移出。
对于前面几节中的ProductService
示例,构造函数注入(Constructor Injection)是一个很好的解决方案。
清单5.4 使用构造函数注入(Constructor Injection)从Control Freak重构 (好代码)
public class ProductService : IProductService
{
private readonly IProductRepository repository;
public ProductService(IProductRepository repository)
{
if (repository == null)
throw new ArgumentNullException("repository");
this.repository = repository;
}
}
到目前为止,控制怪物(Control Freak)是最具破坏性的反模式(anti-pattern),但是即使您对其进行控制,也会出现更细微的问题。 下一节将介绍更多反模式(anti-pattern)。尽管它们比控制怪物(Control Freak)的问题少,但它们也往往更易于解决,因此请保持警惕,并在发现问题时对其进行修复。
服务定位器(Service Locator)
放弃直接控制依赖关系的想法可能很困难,因此许多开发人员将静态工厂(例如5.1.2节中描述的工厂)提升到了新的水平。这导致服务定位器反模式(anti-pattern)。
定义 服务定位器(Service Locator)向组合根(Composition Root)外部的应用程序组件提供对一组无限依赖项(unbounded set of Volatile Dependencies)的访问权限。
由于服务定位器(Service Locator)是最常用的一种,因此它是一个紧密耦合(tightly coupled),可以在第一个使用者开始使用它之前为其配置具体的服务.(但是您同样也可以找到抽象的服务定位器(Service Locator)。)这很可能发生在组合根(Composition Root)中。 取决于特定的实现方式,可以通过读取配置文件或其组合来使用代码配置服务定位器(Service Locator)。 以下清单显示了正在使用的服务定位器反模式(Service Locator anti-pattern)。
清单5.5 使用服务定位器反模式(Service Locator anti-pattern)(坏代码)
public class HomeController : Controller
{
public HomeController() { } <-- HomeController具有无参数的构造函数。
public ViewResult Index()
{
IProductService service =
Locator.GetService<IProductService>(); <---HomeController从静态Locator类请求IProductService实例
var products = service.GetFeaturedProducts(); <--照常使用请求的IProductService
return this.View(products);
}
}
HomeController
具有无参数构造函数,而不是静态定义所需依赖项的列表,而是稍后请求其依赖项。 这对HomeController
的使用者隐藏了这些依赖关系,并使HomeController
难以使用和测试。 图5.3显示了清单5.5中的交互,您可以在其中看到服务定位器(Service Locator)和ProductService
实现之间的关系。
几年前,将服务定位器(Service Locator)称为反模式(anti-pattern)是颇有争议的。争论已经结束:服务定位器(Service Locator)是一种反模式(anti-pattern)。 但是不要惊讶地发现到处都有这种反模式(anti-pattern)的代码库。
图5.3 HomeController 和服务定位器(Service Locator)之间的交互 |
---|
翻译
HomeController
uses theIProductService
interface and requests anIProductService
instance from the SERVICE LOCATOR, which then returns an instance of whatever concrete implementation it’s configured to return.
HomeController
使用IProductService
接口,并向服务定位器(Service Locator)请求一个IProductService
实例,然后该实例返回其配置为返回的任何具体实现的实例。A SERVICE LOCATOR’S prime responsibility is to serve instances of services when consumers request them.
服务定位器(Service Locator)的主要责任是在消费者要求时提供服务实例。
我们与服务定位器的个人历史
在我们分道扬镳之前,服务定位器(Service Locator)和我(Mark)保持着密切的关系数年。尽管我不记得是什么时候第一次碰到一篇著名的文章,将服务定位器(Service Locator)描述为一种模式,但它为我思考了一段时间的问题(如何注入依赖项)提供了潜在的解决方案。 如前所述,服务定位器模式似乎可以解决所有问题,因此我很快着手开发基于该模式的可重用库,我将其方便地命名为服务定位器(Service Locator)。
2007年,我发布了针对Enterprise Library 2的库的完全重写。不久之后,我放弃了该库,因为我意识到它是一种反模式(anti-pattern)。 史蒂文的故事和我的非常相似。
2009年,发布了
Common Service Locator(CSL)
开源项目。CSL
是可重用的库,它实现了服务定位器(Service Locator)模式,类似于Mark的服务定位器(Service Locator)库。 它是对DI容器(DI Container)的resolve
API的抽象,它使其他可重用的库可以解析依赖关系,而不必对特定的DI容器(DI Container)进行严格的依赖。然后,应用程序开发人员可以插入自己的DI容器(DI Container)。受CSL的启发,我(Steven)开始开发自己的DI容器(DI Container),这是一个简单的
CSL
实现。 由于是CSL
实现,因此我方便地将库称为“简单服务定位器(Simple Service Locator)”。 像Mark一样,我很快就意识到服务定位器(Service Locator)是一种反模式(anti-pattern),应用程序开发人员和可重用库都不应使用。 因此,我删除了对CSL
的依赖,并将DI容器(DI Container)重命名为Simple Injector
(https://simpleinjector.org)。
请务必注意,如果仅查看类的静态结构,则DI容器(DI Container)看起来就像服务定位器(Service Locator)。差异是细微的,不在于实现的机制,而在于您如何使用它。 本质上,要求容器或定位器从组合根(Composition Root)解析完整的对象图是正确的用法。 从除组合根以外的其他任何地方要求它提供细粒度的服务意味着服务定位器反模式(Service Locator anti-pattern)。 让我们来看一个示例,该示例显示了运行中的服务定位器(Service Locator)。
示例:使用服务定位器(Service Locator)的ProductService
让我们返回经过反复测试的ProductService
,它需要IProductRepository
接口的实例。 假设我们要应用服务定位器反模式(Service Locator anti-pattern),ProductService
将使用静态GetService
方法,如下面的清单所示。
清单5.6 在构造函数中使用服务定位器 (坏代码)
public class ProductService : IProductService
{
private readonly IProductRepository repository;
public ProductService()
{
this.repository = Locator.GetService<IProductRepository>();
}
public IEnumerable<DiscountedProduct> GetFeaturedProducts() { ... }
}
在此示例中,我们使用通用类型参数来实现GetService
方法,以指示所请求的服务的类型。 您还可以使用Type
参数来指示类型(如果您更喜欢)。
如下清单所示,Locator
类的此实现尽可能短。我们可以添加防御性语句(Guard Clause)和错误处理,但是我们想强调核心行为。 该代码还可以包含一项功能,使它可以从文件中加载配置,但是我们将保留它作为练习。
清单5.7 一个简单的服务定位器(Service Locator)实现
public static class Locator
{
private static Dictionary<Type, object> services = new Dictionary<Type, object>();
<---静态Locator类将所有已配置的服务保存在内部字典中,该字典将抽象类型映射到每个具体实例。
public static void Register<T>(T service)
{
services[typeof(T)] = service;
}
public static T GetService<T>() <--GetService方法允许解析任意抽象。
{
return (T)services[typeof(T)];
}
public static void Reset()
{
services.Clear();
}
}
诸如ProductService
之类的客户端可以使用GetService
方法来请求抽象类型T
的实例。由于此示例代码不包含防御性语句(Guard Clause)或错误处理,因此,如果所请求的类型在字典中没有任何条目,则该方法将抛出相当隐秘的KeyNotFoundException
。 您可以想象如何添加代码以引发更具描述性的异常。
如果之前已将其插入内部字典中,则GetService
方法只能返回所请求类型的实例。 这可以通过Register
方法完成。 同样,此示例代码不包含防御性语句(Guard Clause),因此可以注册一个空值,但更健壮的实现则不允许这样做。此实现还可以永久缓存已注册的实例,但是提出一个可以在每次调用GetService
时创建新实例的实现并不难。在某些情况下,尤其是在单元测试(Unit testing)时,能够重置服务定位器(Service Locator)很重要。 该功能由Reset
方法提供,该方法清除内部字典。
诸如ProductService
之类的类依赖于服务在服务定位器(Service Locator)中的可用功能,因此,请务必对其进行预先配置,这一点很重要。在单元测试(Unit testing)中,这可以通过Stub
实现的双重测试(Test Double)来完成,如下面的清单所示。
清单5.8 取决于服务定位器的单元测试(Unit testing)·(坏代码)
[Fact]
public void GetFeaturedProductsWillReturnInstance()
{
// Arrange
var stub = ProductRepositoryStub(); <--为IProductRepository接口创建一个存根
Locator.Reset(); <--将定位器重置为其默认设置,以防止以前的测试影响此测试
Locator.Register<IProductRepository>(stub); <--使用静态注册方法使用存根实例配置服务定位器
var sut = new ProductService();
// Act
var result = sut.GetFeaturedProducts(); <--执行手头测试所需的任务; GetFeaturedProducts现在将使用ProductRepositoryStub。 Locator.GetService的内部使用会导致Locator.Register和GetFeaturedProducts之间的时间耦合。
// Assert
Assert.NotNull(result);
}
该示例显示了如何使用静态Register
方法通过Stub
实例配置服务定位器(Service Locator)。 如果这样做是在构造ProductService
之前完成的,如示例所示,则ProductService
将使用配置的Stub
对ProductRepository
进行工作。 在完整的生产应用程序中,将在组合根(Composition Root)中为服务定位器(Service Locator)配置正确的ProductRepository
实现。
如果我们唯一的成功标准是可以随意使用和替换依赖项(Dependency),那么从ProductService
类中定依赖关系(Dependencies)的方法肯定可以工作。但是它有一些严重的缺点。
服务定位器分析(Analysis of Service Locator)
服务定位器(Service Locator)是一种危险的模式,因为它几乎可以正常工作。您可以从使用类中找到依赖关系,也可以用不同的实现方式替换这些依赖关系,甚至可以用单元测试(Unit testing)中的测试双精度来代替。 当您应用第1章中概述的分析模型来评估服务定位器(Service Locator)是否可以与模块化应用程序设计的优势相匹配时,您会发现它在大多数方面都适用:
- 您可以通过更改注册来支持后期绑定(Late binding)。
- 您可以并行开发代码,因为您是针对接口编程的,因此可以随意替换模块。
- 您可以实现关注点的良好分离,因此没有什么可以阻止您编写可维护的代码,但是这样做变得更加困难。
- 您可以将依赖关系(Dependencies)替换为双重测试(Test Double),以确保可测试性。
服务定位器(Service Locator)仅在一个区域不足,因此不应掉以轻心。
服务定位器(Service Locator)反模式(anti-pattern)的负面影响
服务定位器(Service Locator)的主要问题在于,它会影响使用它的类的可重用性。这以两种方式体现出来:
- 该类将服务定位器(Service Locator)作为冗余依赖项拖动。
- 该类使它的依赖关系变得不明显。
首先,我们从5.2.1节的示例中查看ProductService
的依赖图,如图5.4所示。
图5.4 ProductService 的依赖关系图 |
---|
翻译
ProductService
uses a SERVICE LOCATOR to create instances of theIProductRepository
interface.
ProductService
使用服务定位器(Service Locator)创建IProductRepository
接口的实例。
除了对IProductRepository
的预期引用之外,ProductService
还取决于Locator
类。 这意味着要重用ProductService
类,您不仅必须重新分发它及其相关的依赖 IProductRepository
,而且还必须重新分发仅由于机械原因而存在的Locator
依赖项。 如果Locator
类是在与ProductService
和IProductRepository
不同的模块中定义的,则想要重用ProductService
的新应用程序也必须接受该模块。
如果DI确实需要工作,也许我们甚至可以忍受对Locator
的额外依赖。我们将其作为获得其他利益而应支付的税款。但是还有更好的选择(例如构造函数注入(Constructor Injection)),因此这种依赖关系是多余的。此外,对于想要使用ProductService
类的开发人员来说,此冗余依赖或IProductRepository
(与其相关的对应对象)都不会明确可见。 图5.5显示Visual Studio
不提供有关使用此类的指南。
图5.5 Visual Studio的IntelliSense 唯一可以告诉我们有关ProductService 类的东西是它具有无参数的构造函数。 它的依赖性是不可见的。 |
---|
如果要创建ProductService
类的新实例,Visual Studio只能告诉您该类具有无参数构造函数。但是,如果随后尝试运行代码,则如果忘记向Locator
类注册IProductRepository
实例,则会遇到运行时错误。如果您不完全了解ProductService
类,则很可能会发生这种情况。
注意 想象一下,您编写的代码位于一个未记录的,混淆的
.dll
中。 使用其他人有多容易? 可以开发接近自我记录的API
,尽管这样做需要实践,但这是一个值得实现的目标。 服务定位器(Service Locator)的问题在于,使用它的任何组件都对其复杂性级别不诚实。 从公共API
来看,它看起来很简单,但事实却很复杂-只有尝试运行它,您才能发现它。
ProductService
类远不能自我记录:您无法确定必须存在哪些依赖项才能起作用。实际上,ProductService
的开发人员甚至可能决定在将来的版本中添加更多的依赖项。 这意味着适用于当前版本的代码可能会在将来的版本中失败,并且您不会收到警告您的编译器错误。服务定位器(Service Locator)使您很容易无意间引入了重大更改。
使用泛型可能会诱使您认为服务定位器(Service Locator)是强类型的。但是,即使是清单5.7中所示的API
,也是弱类型的,因为您可以请求任何类型。 能够编译调用GetService<T>
方法的代码不能保证在运行时不会左右抛出异常。
在进行单元测试(Unit testing)时,还存在另一个问题,即在一个测试用例中注册的双重测试(Test Double)会导致相互依赖的测试(Interdependent Tests)的代码味道,因为在执行下一个测试用例时,它会保留在内存中。 因此,必须在每次测试后通过调用Locator.Reset()
进行夹具拆卸。这是您必须手动记住要做的事情,而且很容易忘记。
与技巧无关
尽管服务定位器(Service Locator)有不同的形式和形状,但通用签名看起来像这样:
public T Resolve<T>()
容易想到每个带有此签名的
API
都是服务定位器,但事实并非如此。 实际上,这是大多数DI容器(DI Container)公开的确切签名。 将其确定为服务定位符的不是API
的静态结构,而是API
在应用程序中扮演的角色。服务定位器反模式(Service Locator anti-pattern)的一个重要方面是,应用程序组件将查询依赖关系,而不是通过其构造函数静态声明它们。 如前所述,这样做有很多弊端。 但是,当组成根目录中的代码查询依赖项时,这些缺点就不存在了。
因为组合根(Composition Root)已经依赖于系统中的其他所有内容(如我们在4.1节中讨论的),所以它不可能拖延额外的依赖关系。 根据定义,它已经知道每个依赖关系。而且,组合根(Composition Root)不可能隐藏其依赖关系-它向谁隐藏? 它的作用是建立对象图。它不需要暴露那些依赖关系。
如果使用不正确,即使通过DI容器(DI Container)查询依存关系也将成为服务定位器(Service Locator)。 当应用程序代码(而不是基础结构代码)主动查询服务以提供所需的依赖项时,则该服务已成为服务定位器(Service Locator)。
重要信息 封装在组合根(Composition Root)中的DI容器(DI Container)不是服务定位器(Service Locator),而是基础结构组件。
服务定位器(Service Locator)看似无害,但可能导致各种讨厌的运行时错误。您如何避免这些问题? 当您决定摆脱服务定位器(Service Locator)时,您需要找到一种方法来做到这一点。与往常一样,默认方法应为构造函数注入(Constructor Injection),除非第4章中的其他DI模式之一提供了更好的拟合。
从服务定位器重构为DI(Refactoring from Service Locator toward DI)
因为构造函数注入(Constructor Injection)会静态声明类的依赖关系,所以假设您练习Pure DI,它会使代码在编译时失败。 另一方面,当您使用DI容器(DI Container)时,您将失去在编译时验证正确性的能力。但是,静态声明类的依赖关系仍然可以确保您可以通过要求容器为您创建所有对象图来验证应用程序对象图的正确性。您可以在应用程序启动时或作为单元/集成测试的一部分来执行此操作。
一些DI容器(DI Container)甚至更进一步,并允许对DI配置进行更复杂的分析。这样可以检测各种常见的陷阱。另一方面,服务定位器(Service Locator)对于DI容器(DI Container)将是完全不可见的,从而使它无法代表您进行这些类型的验证。
在许多情况下,使用服务定位器(Service Locator)的类可能会对其调用扩展到整个代码库。在这种情况下,它可以代替新语句。在这种情况下,第一个重构步骤是将单个依赖项的创建合并到一个方法中。
如果您没有成员字段来保存依赖项的实例,则可以引入这样的字段,并确保其余代码在使用依赖时使用此字段。将该字段标记为只读,以确保无法在构造函数之外对其进行修改。这样做会强制您使用服务定位器从构造函数分配字段。现在,您可以引入一个构造函数参数来分配该字段,而不是分配服务定位器(Service Locator),然后可以将其删除。
注意 向构造函数引入依赖参数可能会破坏现有使用者,因此最好从最顶层的类开始,然后逐步处理依赖图。
重构使用服务定位器(Service Locator)的类类似于重构使用控制怪物(Control Freak)的类。 第5.1.4节包含有关重构控制怪物(Control Freak)实现以使用DI的更多说明。
乍一看,服务定位器(Service Locator)看起来像是正确的DI模式,但不要被愚弄:它可以显式地解决松散耦合(Loose Coupling),但在此过程中会牺牲其他方面的顾虑。 第4章介绍的DI模式提供了更好的选择,缺点更少。 对于服务定位器(Service Locator)反模式(anti-pattern)以及本章中介绍的其他反模式(anti-pattern)而言,都是如此。 尽管它们有所不同,但它们都具有共同的特征,即可以通过第4章中的一种DI模式来解决。
环境上下文(Ambient Context)
与服务定位器相关的是环境上下文反模式(Ambient Context anti-pattern)。 在服务定位器(Service Locator)允许全局访问不受限制的一组依赖关系的情况下,环境上下文(Ambient Context)通过静态访问器使单个强类型的依赖关系可用。
定义 环境上下文(Ambient Context)通过使用静态类成员为聚合根(Composition Root)外部的应用程序代码提供对过度性依赖项(Volatile Dependency)或其行为的全局访问。
以下清单显示了运行中的环境上下文反模式(anti-pattern)。
清单5.9 使用环境上下文反模式(anti-pattern)(坏代码)
public string GetWelcomeMessage()
{
ITimeProvider provider = TimeProvider.Current; <--Current静态属性表示环境上下文,它允许访问ITimeProvider实例。 这将隐藏ITimeProvider依赖关系并使测试复杂化。
DateTime now = provider.Now;
string partOfDay = now.Hour < 6 ? "night" : "day";
return string.Format("Good {0}.", partOfDay);
}
在此示例中,ITimeProvider
呈现了一个抽象,该抽象允许检索系统的当前时间。 因为您可能想影响应用程序对时间的感知方式(例如,用于测试),所以您不想直接调用DateTime.Now
。一个好的解决方案不是让消费者直接调用DateTime.Now
,而是将对DateTime.Now
的访问隐藏在抽象之后。 然而,这太诱人了,以至于消费者无法通过静态属性或方法访问默认实现。 在清单5.9中,Current
属性允许访问默认的ITimeProvider
实现。
环境上下文(Ambient Context)的结构类似于单例模式(Singleton Pattern)。两者都允许使用静态类成员来访问依赖项。 区别在于环境上下文允许更改其依赖关系,而单例模式(Singleton Pattern)可确保其单个实例永不更改。
注 单例模式只能在组合根(Composition Root)内部或从属关系稳定时使用。 另一方面,当滥用单例模式(Singleton Pattern)为应用程序提供对过度性依赖(Volatile Dependency)的全局访问时,其影响与环境上下文(Ambient Context)的影响相同,如第5.3.3节所述。
通常需要访问系统的当前时间。 让我们更深入地研究ITimeProvider
示例。
示例:通过环境上下文访问时间
人们有很多原因需要对时间进行控制。 许多应用程序都具有依赖于时间或其进度的业务逻辑。 在前面的示例中,您看到了一个简单的案例,其中我们根据当前时间显示了欢迎消息。 其他两个示例包括:
- 费用是根据星期几计算的(Cost calculations based on day of the week.)。 在某些企业中,客户通常会在周末支付更多服务费用。
- 根据一天中的不同时间使用不同的通信渠道向用户发送通知(Sending notifications to users using different communication channels based on the time of day.)。 例如,企业可能希望在工作时间内通过短信或传呼机发送电子邮件通知
由于需要与时间一起工作是一个广泛的需求,因此开发人员经常感到有通过使用环境上下文来简化对此类过度性依赖(Volatile Dependency)的访问的冲动。 以下清单显示了一个示例ITimeProvider
抽象。
清单5.10
ITimeProvider
抽象
public interface ITimeProvider
{
DateTime Now { get; } <---允许消费者获取系统的当前时间
}
下面的清单显示了此ITimeProvider
抽象的TimeProvider
类的简化实现。
清单5.11
TimeProvider
环境上下文实现(坏代码)
public static class TimeProvider <---允许全局访问已配置的ITimeProvider实现的静态类
{
private static ITimeProvider current = new DefaultTimeProvider(); <--- 使用实际系统时钟的本地默认值的初始化
public static ITimeProvider Current <--允许对ITimeProvider过度性依赖关系进行全局读/写访问的静态属性
{
get { return current; }
set { current = value; }
}
private class DefaultTimeProvider : ITimeProvider <---使用实际系统时钟的默认实现
{
public DateTime Now { get { return DateTime.Now; } }
}
}
使用TimeProvider
实现,可以对先前定义的GetWelcomeMessage
方法进行单元测试(Unit testing)。 以下清单显示了这样的测试。
清单5.12 取决于环境的单元测试(Unit testing) (坏代码)
[Fact]
public void SaysGoodDayDuringDayTime()
{
// Arrange
DateTime dayTime = DateTime.Parse("2019-01-01 6:00");
var stub = new TimeProviderStub { Now = dayTime };
TimeProvider.Current = stub; <---用始终返回指定的dayTime的Stub替换默认实现
var sut = new WelcomeMessageGenerator(); <--WelcomeMessageGenerator的API是不诚实的,因为其构造函数掩盖了ITimeProvider是必需的Dependency的事实。
// Act
string actualMessage = sut.GetWelcomeMessage(); <--TimeProvider.Current和GetWelcomeMessage之间存在时间耦合。
// Assert
Assert.Equal(expected: "Good day.", actual: actualMessage);
}
这是环境上下文反模式(anti-pattern)的一种变体。 您可能会遇到的其他常见变化是:
- 一个环境上下文(Ambient Context),它允许使用者使用全局配置的依赖项的行为。考虑到前面的示例,
TimeProvider
可以为消费者提供静态的GetCurrentTime
方法,该方法通过内部调用来隐藏使用的依赖性。 - 一个环境上下文(Ambient Context),它将静态访问器和接口合并为一个抽象类。对于前面的示例,这意味着您有一个同时包含
Now
实例属性和静态Current
属性的TimeProvider
基类。 - 使用委托而不是自定义抽象的环境上下文(Ambient Context)。您可以使用
Func<DateTime>
委托来实现相同的目的,而不是使用具有相当描述性的ITimeProvider
接口。
环境上下文(Ambient Context)可以有多种形式和实现。 同样,有关环境上下文(Ambient Context)的警告是,它通过某些静态类成员提供对挥发性依赖的直接或间接访问。 在进行分析和评估解决环境上下文(Ambient Context)引起的问题的可能方法之前,让我们看一下环境上下文(Ambient Context)的另一个常见示例。
示例:通过环境上下文进行记录(Example: Logging through Ambient Context)
开发人员倾向于走捷径并进入环境上下文(Ambient Context)陷阱的另一种常见情况是将日志记录应用到他们的应用程序时。任何实际的应用程序都需要能够将有关错误和其他不常见情况的信息写入文件或其他源,以供以后分析。 许多开发人员认为日志记录是一项特殊的活动,值得“打破常规”。 即使在非常熟悉DI的开发人员的代码库中,您也可能会发现与下一个清单所示的代码相似的代码。
清单5.13 登录时的环境上下文(Ambient Context)(坏代码)
public class MessageGenerator
{
private static readonly ILog Logger =
LogManager.GetLogger(typeof(MessageGenerator)); <---通过静态LogManager环境上下文获取ILog过度性依赖关系并将其存储在私有静态字段中。 这隐藏了依赖关系,并使测试MessageGenerator变得困难。
public string GetWelcomeMessage()
{
Logger.Info("GetWelcomeMessage called."); <--每次调用方法时,使用Logger字段记录日志
return string.Format(
"Hello. Current time is: {0}.", DateTime.Now);
}
}
在日志记录中,环境上下文(Ambient Context)在许多应用程序中如此普遍的原因有很多。 首先,清单5.13之类的代码通常是日志记录库在其文档中显示的第一个示例。开发人员出于无知而复制了这些示例。 我们不能责怪他们; 开发人员通常假设库设计人员知道并交流最佳做法。 不幸的是,情况并非总是如此。 文档示例通常是为了简化而不是最佳实践而编写的,即使他们的设计师了解这些最佳实践也是如此。
除此之外,开发人员倾向于将环境上下文(Ambient Context)应用于记录器,因为他们需要登录其应用程序中的几乎每个类。 将其注入到构造函数中可能很容易导致构造函数具有过多的依赖关系。 实际上,这是一种称为“构造函数过度注入(Constructor Over-injection)”的代码的味道,我们将在第6章中进行讨论。
杰夫·阿特伍德(Jeff Atwood)在2008年写了一篇很棒的博客文章,内容涉及伐木的危险。 他的一些论点如下:
- 日志记录意味着更多的代码,这会使您的应用程序代码变得晦涩难懂
- 记录不是免费的,并且记录很多意味着不断写入磁盘。
- 您记录的越多,找到的内容就越少。
- 如果值得保存到日志文件,则值得在用户界面中显示。
在处理Stack Overflow时,Jeff删除了大部分日志记录,完全依赖于未处理异常的日志记录。 如果出错,则应引发异常。
我们完全同意Jeff的分析,但也希望从设计角度来解决这个问题。 我们发现,通过良好的应用程序设计,您将能够跨通用组件应用日志记录,而不会污染整个代码库。 第10章详细介绍了如何设计这样的应用程序。
注 我们绝不表示您不应该登录。 日志记录是任何应用程序的关键部分,就像在我们构建的应用程序中一样。 但是,我们的意思是,您应该以这样的方式设计应用程序:系统中只有少数几个类会受到日志记录的影响。 如果大多数应用程序组件负责日志记录,则代码将变得难以维护。
周围环境还有许多其他示例,但是这两个示例是如此普遍且广泛,以至于我们在咨询过的公司中看到了无数次。 (过去我们自己介绍过环境上下文(Ambient Context)实现,对此我感到内疚。)既然您已经看到了环境上下文(Ambient Context)的两个最常见的示例,那么下一节将讨论为什么会出现问题以及如何解决它。
环境上下文(Ambient Context)分析
当开发人员将交叉切割问题作为过度性依赖(Volatile Dependency)时,通常会遇到环境上下文(Ambient Context),这种情况被普遍使用。 这种无处不在的性质使开发人员认为它证明了脱离构造函数注入(Constructor Injection)的合理性。它允许他们隐藏依赖关系,并避免在应用程序中向许多构造函数添加依赖关系的必要性。
环境上下文反模式(anti-pattern)的负面影响
环境上下文的问题与服务定位器的问题有关。以下是主要问题:
- 依赖关系是隐藏的。
- 测试变得更加困难。
- 根据上下文更改依赖关系变得很困难。
- 依赖关系的初始化与其用法之间存在时间耦合。
当您通过允许通过环境上下文(Ambient Context)全局访问它来隐藏一个依赖项时,隐藏一个类具有太多依赖项这一事实变得更加容易。 这与构造函数过度注入代码的味道有关,通常表示您违反了单一责任原则(Single Responsibility Principle)。
当一个班级有很多依赖性时,表明它在做应有的工作。 从理论上讲,可以有一个具有许多依赖性的类,而同时仍然只有“一个改变的理由”。但是,类越大,遵守该指导的可能性就越小。 使用环境上下文隐藏了这样一个事实,即类可能变得太复杂了,需要重构。
环境上下文(Ambient Context)也使测试更加困难,因为它呈现了全局状态。 如清单5.12所示,当测试更改全局状态时,它可能会影响其他测试。 测试并行运行时就是这种情况,但是当测试忘记将其更改作为拆卸的一部分还原时,即使是顺序执行的测试也会受到影响。尽管可以缓解这些与测试相关的问题,但这意味着构建特制的环境上下文(Ambient Context)以及全局或特定于测试的拆卸逻辑。这增加了复杂性,而替代方法却没有。
使用环境上下文(Ambient Context)很难为不同的使用者提供不同的依赖实现。 例如,假设您需要系统的一部分工作在当前请求开始时确定的时间,而其他可能长时间运行的操作应该获得实时更新的依赖关系。 如清单5.13所示,为消费者提供了有关依赖项的不同实现。
private static readonly ILog Logger = LogManager.GetLogger(typeof(MessageGenerator));
为了能够为使用者提供不同的实现,GetLogger
API
要求使用者传递其适当的类型信息。 这不必要地使消费者复杂化。
使用环境上下文(Ambient Context)会导致在时间级别上使用其依赖关系。 除非您在组合根(Composition Root)目录中初始化环境上下文,否则当类首次开始使用依赖关系时,应用程序将失败。 我们宁愿希望我们的应用程序快速失败。
我正在使用抽象; 有什么问题吗?
我(Steven)曾经为一个拥有大量代码库的客户端工作,该客户端使用类似于清单5.13的方式使用日志记录。 曾经有日志记录。 因为开发人员希望避免直接依赖所涉及的日志库
log4net
,所以他们使用了另一个第三方库来为他们提供日志库上的抽象。 该库称为Common.Logging
。但无济于事的是,Common.Logging
库模仿了log4net
的API
,这掩盖了一个事实,即他们的项目经常不小心包含对这两个库的依赖。 这导致许多类仍依赖于log4net
。更重要的是,即使应用程序设计人员将Log4net
的使用隐藏在了抽象的后面,但仍然依赖于第三方库,因此现在每个类都依赖于Common.Logging
提供的环境上下文(Ambient Context)(类似于清单5.13)。当我们在
Common.Logging
中发现一个错误时,该问题开始浮出水面,该错误导致某些开发人员机器上的静态GetLogger
方法调用在IIS
中运行时失败。 在这些开发人员机器上,无法启动应用程序,因为对LogManager.GetLogge
r的第一次调用将失败。 不幸的是,对我来说,我是遇到此问题的两个开发人员之一。该组织中的许多开发人员都帮助我们尝试找到一种解决方案,并花费了无数的时间试图弄清正在发生的事情,但是没有人找到解决方案或解决方法。最后,我注释掉了所有环境上下文(Ambient Context)调用,这些调用需要为特定功能在本地运行的代码路径。 不幸的是,在那个时候对DI进行重构是不可行的。
我并不是要选择
Common.Logging
或log4net
,但这是您让应用程序代码依赖第三方库时要承担的风险。 当您依赖图书馆的环境信息时,这种风险会被夸大。这个故事的寓意是,如果开发人员使用了正确的DI模式而不是环境上下文,那么对于我来说,很容易就可以将配置的记录器本地替换为组合根(Composition Root)中不需要通用的伪造记录器。 要加载的日志。几分钟的工作将为组织节省无数浪费的时间。
尽管环境上下文不像服务定位器那样具有破坏性,但由于它仅隐藏与任意数量的依赖关系相对的单个过度性依赖关系,因此它在精心设计的代码库中没有位置。 总会有更好的选择,这是我们在下一节中描述的。
从环境上下文向DI重构(Refactoring from Ambient Context toward DI)
即使在开发人员对DI和服务定位器(Service Locator)带来的危害有相当了解的代码库中看到环境上下文(Ambient Context),也不要感到惊讶。 很难说服开发人员脱离环境上下文(Ambient Context),因为他们已经习惯了使用环境上下文(Ambient Context)。 最重要的是,尽管将单个类重构为DI并不难,但是诸如无效和有害的日志记录策略之类的潜在问题很难改变。 通常,很多代码会因为并非总是很清楚的原因而记录下来。 当原始开发人员早已不在时,找出这些日志记录语句是否可以删除还是应该变成异常通常是一个缓慢的过程。 仍然,假设代码库已经应用了DI,从环境上下文向DI重构就很简单。
消耗环境上下文(Ambient Context)的类通常包含一个或几个对其的调用,可能会散布在多个方法上。因为重构的第一步是将对环境上下文(Ambient Context)的调用集中化,所以构造函数是执行此操作的好地方。
创建一个私有只读字段,其中可以包含对依赖关系的引用,并为它分配环境上下文(Ambient Context)的依赖关系。 该课程的其余代码现在可以使用此新的私有字段。 现在,可以使用分配给该字段的构造函数参数和确保该构造函数参数不为null
的防御性语句(Guard Clause)来替换对环境上下文(Ambient Context)的调用。这个新的构造函数参数可能会导致使用者中断。 但是,如果已经应用了DI,这只会导致更改组合根(Composition Root)和类测试。下面的清单显示了重构的结果(毫不奇怪),当将其应用于WelcomeMessageGenerator
时。
清单5.14 从环境上下文(Ambient Context)重构为构造函数注入(Constructor Injection) (好代码)
public class WelcomeMessageGenerator
{
private readonly ITimeProvider timeProvider;
public WelcomeMessageGenerator(ITimeProvider timeProvider)
{
if (timeProvider == null)
throw new ArgumentNullException("timeProvider");
this.timeProvider = timeProvider;
}
public string GetWelcomeMessage()
{
DateTime now = this.timeProvider.Now;
...
}
}
重构环境上下文(Refactoring Ambient Context)相对简单,因为在大多数情况下,您将在已经应用了DI的应用程序中进行重构。对于没有的应用程序,最好先解决控制怪物(Control Freak)和服务定位器(Service Locator)问题,然后再处理重构环境上下文(Refactoring Ambient Context)。
环境上下文(Ambient Context)听起来是访问常用的横切关注点(Cross-Cutting Concerns)的一种好方法,但是看起来很诱人。 尽管环境问题比控制怪物(Control Freak)和服务定位器(Service Locator)少,但环境上下文通常掩盖了应用程序中较大的设计问题。 第4章中介绍的模式提供了更好的解决方案,第10章中,我们将展示如何设计应用程序,以便可以在整个应用程序中更轻松,更透明地应用日志记录和其他横切关注点(Cross-Cutting Concerns)。
本章中考虑的最后一个反模式(anti-pattern)是约束构造(Constrained Construction)。这通常源于获得后期绑定(Late binding)的愿望。
约束构造(Constrained Construction)
正确实现DI的最大挑战是将所有具有依赖关系的类移到一个组合根(Composition Root)中。当你做到这一点,你已经走了很长的路。即便如此,仍有一些陷阱需要注意。
一个常见的错误是要求依赖项有一个具有特定签名的构造函数。这通常源于希望实现后期绑定(Late binding),以便可以在外部配置文件中定义依赖项,从而在不重新编译应用程序的情况下进行更改。
定义 约束构造(Constrained Construction)强制某个抽象类(Abstraction)的所有实现要求其构造函数具有相同的签名,以实现后期绑定(Late binding)。
请注意,本节仅适用于需要后期绑定(Late binding)的情况。 在您直接从应用程序的根目录引用所有依赖项的情况下,将不会出现此问题。 但是话又说回来,您也将无法替换依赖项而无需重新编译启动项目。 以下清单显示了正在使用的约束构造反模式(Constrained Construction anti-pattern)。
清单5.15 约束构造反模式(anti-pattern)示例 (坏代码)
public class SqlProductRepository : IProductRepository
{
public SqlProductRepository(string connectionStr)
{
}
}
public class AzureProductRepository : IProductRepository
{
public AzureProductRepository(string connectionStr)
{
}
}
IProductRepository
抽象的所有实现都必须具有一个具有相同签名的构造函数。 在此示例中,构造函数应仅具有一个字符串类型的参数。 尽管类具有字符串类型的依赖性非常好,但是强制这些实现具有相同的构造函数签名是一个问题。 在1.2.2节中,我们简要地谈到了这个问题。 本节将对其进行更仔细的研究。
示例:后期绑定(Late binding) ProductRepository
在示例电子商务应用程序中,某些类依赖于IProductRepository
接口。这意味着要创建这些类,首先需要创建一个IProductRepository
实现。 至此,您已经知道组合根(Composition Root)是执行此操作的正确位置。 在ASP.NET Core
应用程序中,这通常表示启动。 以下清单显示了创建IProductRepository
实例的相关部分。
清单5.16 隐式约束
ProductRepository
构造函数(坏代码)
string connectionString = this.Configuration.GetConnectionString("CommerceConnectionString"); <--从应用程序的配置文件中读取连接字符串
var settings =
this.Configuration.GetSection("AppSettings");
string productRepositoryTypeName = settings.GetValue<string>("ProductRepositoryType"); <--3-6rows 从配置文件的AppSettings部分读取要创建的存储库类型的名称
var productRepositoryType =
Type.GetType( <-- 加载存储库类型的Type对象
typeName: productRepositoryTypeName,
throwOnError: true);
var constructorArguments =new object[] { connectionString };
IProductRepository repository =
(IProductRepository)Activator.CreateInstance(
productRepositoryType, constructorArguments); <-- 创建存储库类型的实例,同时要求特定的签名。 对于需要不同构造函数签名的组件,此调用将失败。
以下代码显示了相应的配置文件:
{
"ConnectionStrings": {
"CommerceConnectionString":
"Server=.;Database=MaryCommerce;Trusted_Connection=True;"
},
"AppSettings": {
"ProductRepositoryType": "SqlProductRepository, Commerce.SqlDataAccess"
},
}
首先应该引起怀疑的是从配置文件中读取连接字符串。 如果计划将ProductRepository
视为抽象,为什么需要连接字符串?
尽管可能不太可能,但您可以选择使用内存数据库或XML
文件来实现ProductRepository
。基于REST
的存储服务(例如Windows Azure表存储服务)提供了更现实的选择,尽管今年再次最受欢迎的选择似乎是关系数据库。数据库的普遍性使人们很容易忘记连接字符串隐式表示一种实现选择。
要后期绑定(Late binding)IProductRepository
,还需要确定已选择哪种类型作为实现。这可以通过从配置中读取程序集限定的类型名称并从该名称创建Type
实例来完成。 这本身没有问题。 当您需要创建该类型的实例时,就会出现困难。给定类型,您可以使用Activator
类创建实例。 CreateInstance
方法调用该类型的构造函数,因此您必须提供正确的构造函数参数,以防止引发异常。 在这种情况下,您将提供一个连接字符串。
如果除了清单5.16中的代码之外,您对应用程序一无所知,您现在应该想知道为什么将连接字符串作为构造函数参数传递给未知类型。 如果实施是基于基于REST
的网络服务或XML
文件,则没有任何意义。
确实,这没有任何意义,因为这表示对依赖构造函数的偶然约束。在这种情况下,您有一个隐含的要求,即IProductRepository
的任何实现都应具有一个将单个字符串作为输入的构造函数。这是对类必须从IProductRepository
派生的显式约束的补充。
注意 构造函数应采用单个字符串的隐式约束仍然给您很大的灵活性,因为您可以在字符串中编码不同的信息以供以后解码。相反,假设约束是一个采用
TimeSpan
和一个数字的构造函数,您可以开始想象这将有多大的限制。
您可能会争辩说,基于XML
文件的IProductRepository
也将需要一个字符串作为构造函数参数,尽管该字符串将是文件名而不是连接字符串。 但是,从概念上讲,它仍然很奇怪,因为您必须在配置的connectionStrings
元素中定义该文件名。(无论如何,我们认为这样的假设XmlProductRepository
应该使用XmlReader
作为构造函数参数而不是文件名。)
提示 仅在显式约束(接口或基类)上对依赖关系构造进行建模是一种更好,更灵活的选择。
约束构造(Constrained Construction)分析
在前面的示例中,隐式约束要求实现者具有一个带有单个字符串参数的构造函数。一个更常见的约束是所有实现都应具有无参数构造函数,以便最简单的Activator.CreateInstance
形式可以起作用:
IProductRepository repository = (IProductRepository)Activator.CreateInstance(productRepositoryType);
尽管可以说这是最低的公分母,但是灵活性方面的成本却很高。 无论如何约束对象构造,都将失去灵活性。
约束构造(Constrained Construction)反模式(anti-pattern)的负面影响
声明所有依赖(Dependency)实现都应具有无参数构造函数可能很诱人。毕竟,他们可以在内部执行初始化。例如,直接从配置文件中读取诸如连接字符串之类的配置数据。但这会以其他方式限制您,因为您可能希望将应用程序组成为封装其他实例的实例层。例如,在某些情况下,您可能想在不同使用者之间共享一个实例,如图5.6所示。
图5.6 您想创建CommerceContext 类的单个实例并将该实例注入两个存储库中。 |
---|
当您有多个需要相同依赖项的类时,您可能希望在所有这些类中共享一个实例。仅当您可以从外部注入该实例时,才有可能。尽管您可以在每个类中编写代码以从配置文件中读取类型信息并使用Activator.CreateInstance
创建正确的实例类型,实际上需要通过这种方式共享一个实例。 取而代之的是,您将有多个同一个类的实例占用更多的内存。
注意 DI允许您在多个使用者之间共享一个实例这一事实并不意味着您应该始终这样做。 共享实例可以节省内存,但可能会引入与交互相关的问题,例如线程问题。是否共享一个实例与对象生存期的概念密切相关,在第8章中将对此进行讨论。
而不是对应如何构造对象施加隐式约束,您应该实现您的组合根(Composition Root),以便它可以处理您可能会扔给它的任何类型的构造函数或工厂方法。 现在,让我们看一下如何重构为DI。
从约束构造(Constrained Construction)向DI重构
当需要后期绑定(Late binding)时,如何处理对组件的构造函数没有约束的问题? 引入一个抽象工厂可能会很吸引人,该工厂可以创建所需抽象的实例,然后要求这些抽象工厂的实现具有特定的构造函数签名。但是,这样做可能会导致其自身的复杂性。 让我们研究一下这种方法。
想象一下,为IProductRepository
抽象使用一个抽象工厂。抽象工厂方案规定您还需要一个IProductRepositoryFactory
接口。 图5.7说明了这种结构。
图5.7 尝试使用抽象工厂(Abstract Factory)结构来解决后期绑定(Late binding)挑战 |
---|
在此图中,IProductRepository
表示实际的依赖性。 但是为了使实现者不受隐式约束的影响,您尝试通过引入IProductRepositoryFactory
来解决后期绑定(Late binding)难题。 这将用于创建IProductRepository
的实例。 进一步的要求是,任何工厂都必须具有特定的构造函数签名。
现在假设您要使用IProductRepository
的实现,该实现需要IUserContext
的实例才能正常工作,如下面的清单所示。
清单5.17 需要
IUserContext
的SqlProductRepository
public class SqlProductRepository : IProductRepository
{
private readonly IUserContext userContext;
private readonly CommerceContext dbContext;
public SqlProductRepository(
IUserContext userContext, CommerceContext dbContext)
{
if (userContext == null)
throw new ArgumentNullException("userContext");
if (dbContext == null)
throw new ArgumentNullException("dbContext");
this.userContext = userContext;
this.dbContext = dbContext;
}
}
SqlProductRepository
类实现IProductRepository
接口,但需要IUserContext
的实例。因为唯一的构造函数不是无参数的构造函数,所以IProductRepositoryFactory
将派上用场。
当前,您要使用基于ASP.NET Core
的IUserContext
实现。您将此实现称为AspNetUserContextAdapter
(如清单3.12所述)。 由于实施取决于ASP.NET Core
,因此未在与SqlProductRepository
相同的程序集中定义。 并且,因为您不想将对包含AspNetUserContextAdapter
和SqlProductRepository
的库的引用拖到其他地方,所以唯一的解决方案是在与SqlProductRepository
不同的程序集中实现SqlProductRepositoryFactory
,如图5.8所示。
图5.8 SqlProductRepositoryFactory 的依赖关系图在单独的程序集中实现 |
---|
翻译
To prevent coupling the
SQL
data access library to theUI
library, the Factory class must be implemented in an assembly other thanSqlProductRepository
.为了防止将
SQL
数据访问库耦合到UI
库,必须在SqlProductRepository
以外的程序集中实现Factory
类。
下面的清单显示了SqlProductRepositoryFactory
的可能实现。
清单5.18 创建
SqlProductRepository
实例的工厂 (坏代码)
public class SqlProductRepositoryFactory
: IProductRepositoryFactory
{
private readonly string connectionString;
public SqlProductRepositoryFactory(
IConfigurationRoot configuration) <--具有所有IProductRepositoryFactory实现必须具有的特定签名的构造函数。 通过接受Microsoft的IConfigurationRoot,工厂将加载其所需的配置值。 如果缺少该值,在构造过程中也会引发错误。
{
this.connectionString =
configuration.GetConnectionString(
"CommerceConnectionString"); <---从配置文件中加载连接字符串并存储以备后用
}
public IProductRepository Create()
{
return new SqlProductRepository( <---使用位于不同程序集中的依赖关系创建一个新的SqlProductRepository
new AspNetUserContextAdapter(),
new CommerceContext(this.connectionString));
}
}
即使IProductRepository
和IProductRepositoryFactory
看起来像是一个内聚对,但在两个不同的程序集中实现它们也很重要。 这是因为工厂必须引用所有依赖项才能正确地将它们连接在一起。 按照约定,IProductRepositoryFactory
实现必须再次使用约束构造,以便您可以在配置文件中写入程序集限定的类型名称,并使用Activator.CreateInstance
创建实例。
每次需要将新的依赖关系组合在一起时,都必须实现一个新工厂来精确地组合该组合,然后将应用程序配置为使用该工厂而不是先前的工厂。这意味着您不能在不编写和编译代码的情况下定义依赖关系的任意组合,但是可以在不重新编译应用程序本身的情况下进行操作。 这样的抽象工厂(Abstract Factory)成为在与核心应用程序分开的程序集中定义的抽象组合根(Abstract Composition Root)。 尽管这是可行的,但是当您尝试应用它时,您会注意到它引起的不灵活性。
灵活性之所以受到损害,是因为抽象组合根(Abstract Composition Root)直接依赖于其他库中的具体类型来满足其构建的对象图的需求。在SqlProductRepositoryFactory
示例中,工厂需要创建一个AspNetUserContextAdapter
实例以传递给SqlProductRepository
。 但是,如果核心应用程序想要替换或拦截IUserContext
实现该怎么办?这迫使更改核心应用程序和SqlProductRepositoryFactory
项目。 另一个问题是这些抽象工厂管理对象生存期变得非常困难。 如图5.5所示,这是相同的问题。
为了克服这种灵活性,唯一可行的解决方案是使用通用DI容器(DI Container)。因为DI容器(DI Container)使用反射来分析构造函数签名,所以抽象组合根(Abstract Composition Root)不需要知道用于构造其组件的依赖项。 抽象组合根(Abstract Composition Root)唯一需要做的就是指定抽象与实现之间的映射。换句话说,SQL
数据访问组合根(Composition Root)需要指定在应用程序需要IProductRepository
的情况下,应创建SqlProductRepository
的实例。
提示 使用DI容器(DI Container)可以是防止约束构造(Constrained Construction)的有效解决方案。 在第4部分中,我们将详细介绍DI容器(DI Container)如何工作以及如何使用它们。
仅当您确实需要能够插入新程序集而不必重新编译现有应用程序的任何部分时,才需要抽象组合根(Abstract Composition Root)。大多数应用程序不需要这种灵活性。 尽管您可能希望能够用Azure
数据访问层替换SQL
数据访问层而不必重新编译领域层,但是如果这意味着您仍然必须对启动项目进行更改,通常就可以了。
注意 约束构造(Constrained Construction)反模式(anti-pattern)仅在您采用后期绑定(Late binding)时才适用。 使用早期绑定(early binding)时,编译器确保您绝不会对组件的构造方式引入隐式约束。如果可以避免重新编译启动项目,则应将组合根(Composition Root)集中在启动项目中。 后期绑定(Late binding)会带来额外的复杂性,并且复杂性会增加维护成本。
由于DI是一组模式和技术,因此没有任何一种工具可以机械地验证您是否正确应用了它。 在第4章中,我们研究了描述如何正确使用DI的模式,但这只是硬币的一面。 即使有最好的意图,研究失败的可能性也是很重要的。 您可以从失败中学到重要的教训,但不必总是从自己的错误中学习-有时您可以从其他人的错误中学习。
在本章中,我们以反模式(anti-pattern)的形式描述了最常见的DI错误。我们已经不只一次地看到现实生活中的所有这些错误,并且我们承认所有这些错误都是有罪的。 到现在为止,您应该知道应该避免什么以及理想情况下应该做什么。 但是,仍然存在看起来难以解决的问题。 下一章将讨论此类挑战及其解决方法。
总结
- 反模式(anti-pattern)是对常见问题的解决方案的描述,该问题会产生明确的负面影响。
- 控制怪物(Control Freak)是本章中介绍的最主要的反模式(anti-pattern)。 它有效地阻止了您应用任何适当的DI。 每当您在除组合根(Composition Root)以外的任何其他地方过度性依赖项(Volatile Dependency)时,就会发生这种情况。
- 尽管涉及过度性依赖(Volatile Dependency)时,
new
关键字是一种代码的味道,但是您无需担心将其用于稳定的依赖项(Stable Dependencies)。 通常,新关键字并不是突然非法的,但是您应该避免使用它来获取过度性依赖(Volatile Dependency)实例。 - 控制怪物(Control Freak)违反了依赖倒置原则(Dependency Inversion Principle)。
- 控制怪物(Control Freak)是大多数编程语言中创建实例的默认方式,因此即使在开发人员从未考虑过DI的应用程序中也可以观察到。 这种创建新对象的方法自然而根深蒂固,许多开发人员发现很难丢弃它。
- 外部默认设置与本地默认设置相反。 它是依赖关系的实现,即使它是在与使用者不同的模块中定义的,它也被用作默认值。 沿不需要的模块拖动会失去许多松散耦合(Loose Coupling)的好处。
- 服务定位器(Service Locator)是本章介绍的最危险的反模式(anti-pattern),因为它看起来像是在解决问题。 它为组合根(Composition Root)外部的应用程序组件提供了对无限制的过度性依赖(Volatile Dependency)的访问权限。
- 服务定位器(Service Locator)会影响使用它的组件的可重用性。 它使组件消费者对它的依赖关系一无所知,使这样的组件对其复杂性级别不诚实,并导致其消耗组件拖延服务定位器作为冗余依赖关系。
- 服务定位器(Service Locator)阻止验证类之间的关系的配置。 构造函数注入(Constructor Injection)与Pure DI结合使用可在编译时进行验证; 构造函数注入(Constructor Injection)与DI容器(DI Container)结合使用,可以在应用程序启动时进行验证,也可以作为简单自动化测试的一部分进行验证。
- 静态服务定位器(Service Locator)会导致相互依赖的测试,因为执行下一个测试用例时它将保留在内存中。
- 将其确定为服务定位器(Service Locator)的不是
API
的机械结构,而是API
在应用程序中扮演的角色。 因此,封装在组合根(Composition Root)中的DI容器(DI Container)不是服务定位器(Service Locator),而是基础结构组件。 - 环境上下文(Ambient Context)通过使用静态类成员为组合根(Composition Root)外部的应用程序代码提供对过度性依赖(Volatile Dependency)或其行为的全局访问。
- 除了环境上下文(Ambient Context)允许更改其依赖关系外,环境上下文(Ambient Context)在结构上与Singleton模式相似。单例模式可确保创建的单个实例永远不会改变。
- 当开发人员普遍使用横切关注点(Cross-Cutting Concerns)作为依赖项时,通常会遇到环境上下文,这使他们认为这证明脱离构造函数注入(Constructor Injection)是合理的。
- 环境上下文(Ambient Context)会导致过度性依赖(Volatile Dependency)被隐藏起来,使测试复杂化,并使其难以根据其上下文更改依赖关系。
- 约束构造(Constrained Construction)强制某个抽象的所有实现具有特定的构造函数签名,以实现后期绑定(Late binding)。它限制了灵活性,并可能迫使实现在内部进行初始化。
- 通过使用通用的DI容器(DI Container)可以防止构造受限,因为DI容器(DI Container)使用反射来分析构造函数签名。
- 如果您可以避免重新编译启动项目,则应将组合根(Composition Root)集中在启动项目中,并避免使用后期绑定(Late binding)。 后期绑定(Late binding)会带来额外的复杂性,并且复杂性会增加维护成本。