Loading

第四章-DI模式

DI类型

第1部分概述了DI,讨论了DI的目的和好处。即使第3章包含了一个广泛的示例,我们也可以肯定,第1章仍会给您留下一些未解决的问题。 在第2部分中,我们将更深入地回答一些问题。

顾名思义,第2部分介绍了模式,反模式(anti-pattern)和代码气味的完整目录。 有些人不喜欢设计模式,因为他们发现它们过于干燥或过于抽象。 就个人而言,我们喜欢模式,因为它们为我们提供了高级语言,使我们在讨论软件设计时更加高效和简洁。 我们的目的是使用此目录为DI提供一种模式语言。 尽管模式说明必须包含一些概括,但我们通过示例将每个模式具体化。您可以按顺序阅读所有三章,但目录中的每个项目也都被编写,以便您可以单独阅读。

第4章包含DI设计模式的微型目录。从某种意义上说,这些模式构成了如何实施DI的规范性指导,但是您应该意识到我们并不认为它们具有同等的重要性。 到目前为止,构造函数注入(Constructor Injection)和组合根(Composition Root)是最重要的设计模式,而所有其他模式都应视为可以在特殊情况下应用的附带条件。

第4章为您提供了一套通用的解决方案,而第5章则提供了要避免的情况的目录。 这些反模式(anti-pattern)描述了解决典型的DI挑战的常见但不正确的方法。 在每种情况下,反模式(anti-pattern)都描述了如何识别事件以及如何解决问题。 重要的是要了解和理解这些反模式(anti-pattern),以避免它们代表的陷阱,并且,正如第4章介绍了两个最重要的模式一样,最重要的反模式(anti-pattern)是服务定位器,即DI的对立面。

在将DI应用于现实生活中的编程任务时,您会遇到一些挑战。我们认为所有人都在怀疑自己了解工具或技术的时刻,但是我们认为,“从理论上讲,这可能行得通,但我的情况很特殊。” 当我们发现自己这样思考时,很明显我们还有很多东西要学习。

在我们的职业生涯中,我们看到了一系列特定的问题一次又一次地出现。这些问题中的每一个都有一个通用的解决方案,您可以将其应用到第4章的DI模式之一。第6章包含这些常见问题或代码气味及其对应解决方案的目录。

我们希望这是本书中最有用的部分,因为它是最持久的。希望您在第一次阅读它们后的几个月甚至几年都能回到这些章节。

DI模式 (DI patterns)

像所有专业人士一样,厨师也有自己的行话,使他们可以用一种对我们其他人来说往往很深奥的语言来交流复杂的食物制备方法。 它们使用的大多数术语都基于法语(除非您已经说法语),这无济于事。 酱汁是厨师使用其专业术语的一个很好的例子。 在第1章中,我们简要讨论了酱汁蛋黄酱,但是没有详细介绍它周围的分类法。

蛋黄酱实际上是一种蛋黄酱,其中柠檬汁被减少的醋,青葱,山罗卜和龙蒿代替。 其他调味料都基于荷兰酱,包括马克最喜欢的调味酱蛋黄酱,它是通过将鲜奶油折叠到荷兰酱中制成的。

您注意到行话了吗? 我们没有使用“折叠”一词,而是说“将打好的奶油小心地混入酱汁中,注意不要将其倒塌”。 与其说“增稠和增强醋的味道”,不如说是减少。行话可让您简洁有效地进行交流。

在软件开发中,我们拥有自己的复杂且难以理解的行话。您可能不知道bain-marie的烹饪术语是什么意思,但是我们可以肯定,如果您告诉他们“字符串是不可变的类,代表Unicode字符的序列”,那么大多数厨师将完全迷路。当谈到如何构造代码以解决特定类型的问题时,我们拥有设计模式,这些模式为通用解决方案命名。 就像酱蛋黄酱和折叠这两个术语可以帮助我们简洁地传达如何制作蛋黄酱蛋黄酱一样,设计模式也可以帮助我们讨论代码的结构。

在前面的章节中,我们已经命名了许多软件设计模式。例如,在第1章中,我们讨论了模式:Abstract Factory(抽象工厂模式), Null Object(空对象模式),Decorator(装饰器模式), Composite(组合模式), Adapter(适配器模式), Guard Clause(防御式模式), Stub(存根), Mock(模拟), and Fake(伪造).。尽管此时您可能无法回忆起它们中的每一个,但是如果我们谈论设计模式,您可能不会感到不舒服。 我们人类喜欢命名重复出现的模式,即使它们很简单。

一般而言,如果您对设计模式只有有限的了解,请不要担心。设计模式的主要目的是对实现目标的特定方法(如果需要的话)进行详细,独立的描述。 此外,您已经看到了本章将介绍的四种基本DI设计模式中的三种示例:

  • 组合根(Composition Root)—描述应在何处以及如何组成应用程序的对象图。
  • 构造函数注入(Constructor Injection)—允许类静态声明其所需的依赖关系。
  • 方法注入(Method Injection)—使您可以在每个操作的依赖关系或使用方可能更改时向使用方提供依赖关系。
  • 属性注入(Property Injection)—允许客户端选择覆盖某些类的默认行为,该默认行为在本地默认值(Local Default)中实现。

本章的结构旨在提供模式目录。对于每种模式,我们将提供简短说明,代码示例,优缺点等。 您可以依次阅读本章介绍的所有四种模式,也可以只阅读您感兴趣的模式。 最重要的模式是组合根(Composition Root)构造函数注入(Constructor Injection),您应在大多数情况下使用它们-随着本章的进行,其他模式将变得更加专业。

组合根(Composition Root)

​ 我们应该在哪里组成对象图?

​ 尽可能靠近应用程序的入口点。

当您从许多松散耦合(Loose Coupling)的类中创建应用程序时,撰写应尽可能靠近应用程序的入口点进行。Main方法是大多数应用程序类型的入口点。 组合根(Composition Root)组成对象图(object graph),该对象图随后执行应用程序的实际工作。

定义 组合根(Composition Root)是应用程序中模块组合在一起的单个逻辑位置。

图4.1 在应用程序的入口点附近,组合根(Composition Root)负责组成松散耦合(Loose Coupling)类的对象图。 组合根(Composition Root)直接依赖于系统中的所有模块。
image

在上一章中,您看到了大多数类都使用了构造函数注入(Constructor Injection)。通过这样做,他们将建立依赖关系的责任推给了消费者。 但是,此类消费者也将建立依赖关系的责任推给了消费者。

您不能无限期地延迟对象的创建。必须有一个创建对象图的位置。您应该将创建的内容集中到应用程序的单个区域中。 这个地方称为组合根(Composition Root)

警告 如果使用DI容器(DI Container),则组合根(Composition Root)应该是唯一使用DI容器(DI Container)的地方。在组合根(Composition Root)外部使用DI容器(DI Container)会导致服务定位器反模式(Service Locator anti-pattern),我们将在下一章中进行讨论。

在上一章中,这产生了在清单3.13(图4.1)中看到的对象图。此清单还显示了来自所有应用程序层的所有组件都是在组合根(Composition Root)中构造的。

清单4.1 第3章中的应用程序的对象图

new HomeController(   <--- UI组件
    new ProductService(    <---- 领域组件
        new SqlProductRepository(
            new CommerceContext(connectionString)),  <----  数据访问组件
        new AspNetUserContextAdapter()));   <---- UI组件

如果要编写一个可在此特定对象图上运行的控制台应用程序,则其外观可能如下表所示。

清单4.2 应用程序的对象图作为控制台应用程序的一部分

public static class Program
{
    public static void Main(string[] args)    <---- 应用程序的入口点
    {
        string connectionString = args[0];    <----- 从提供的命令行参数中提取连接字符串
        HomeController controller =
            CreateController(connectionString);   <---请求应用程序的组合根构建一个新的控制器实例
        var result = controller.Index();
        var vm = (FeaturedProductsViewModel)result.Model;
        Console.WriteLine("Featured products:");
        foreach (var product in vm.Products)
        {
            Console.WriteLine(product.SummaryText);
        }
    }
    private static HomeController CreateController(  <---- 充当应用程序的组合根
        string connectionString)
    {
        var userContext = new ConsoleUserContext();  <--- IUserContext实现,允许ProductService起作用并计算折扣
        return
            new HomeController(
            new ProductService(
                new SqlProductRepository(
                    new CommerceContext(
                        connectionString)),    <-- 21-25rows 构建应用程序的对象图
                userContext));
    }
}

在此示例中,组合根(Composition Root)Main方法分开。 但是,这不是必需的-组合根(Composition Root)不是方法或类,而是概念。它可以是Main方法的一部分,也可以跨越多个类,只要它们都驻留在单个模块中即可。将其分为自己的方法有助于确保结构被合并,并且不会散布在后续的应用程序逻辑中。

组合根(Composition Root)的工作原理 (How Composition Root works)

当编写松散耦合(Loose Coupling)的代码时,会创建许多类来创建应用程序。为了创建小型子系统,可能会在许多不同的位置组合这些类,但这会限制您拦截(Intercept)这些系统以修改其行为的能力。相反,您应该在应用程序的一个区域中组成类。

当您单独查看构造函数注入(Constructor Injection)时,您可能会怀疑,这是否将选择依赖项的决定推迟到另一个地方?是的,确实如此,这是一件好事。这意味着您将获得一个可以连接协作类的中心位置

组合根(Composition Root)充当将消费者与其服务联系起来的第三方。您推迟有关如何连接类的决定的时间越长,可以保持选择打开的时间就越多。因此,组合根(Composition Root)应放置在尽可能靠近应用程序入口点的位置。

甚至使用松散耦合(Loose Coupling)后期绑定(Late binding)来构成自身的模块化应用程序,其根都包含应用程序的入口点。示例如下:

  • .NET Core控制台应用程序是一个库(.dll),其中包含带有Main方法的Program类。
  • ASP.NET Core Web应用程序也是一个库,其中包含带有Main方法的Program类。
  • UWPWPF应用程序是带有App.xaml.cs文件的可执行文件(.exe)。

存在许多其他技术,但是它们有一个共同点:一个模块包含应用程序的入口点-这是应用程序的根。不要误以为组合根(Composition Root)是您的UI层的一部分。 即使您将组合根(Composition Root)与UI层放在同一程序集中,正如我们在下一个示例中所做的那样,组合根(Composition Root)也不是该层的一部分。

程序集是一个部署工件(deployment artifact):您将代码拆分为多个程序集,以允许代码分别进行部署。另一方面,体系结构层是逻辑工件(logical artifact):您可以将多个逻辑工件分组在一个部署工件中。即使同时拥有组合根(Composition Root)UI层的程序集也依赖于系统中的所有其他模块,但UI层本身却没有。

重要 组合根(Composition Root)不是UI层的一部分,即使它可以放在同一程序集中也是如此。

无需将组合根(Composition Root)UI层放置在同一项目中。 您可以将UI层移出应用程序的根项目。这样做的好处是,您可以防止拥有UI层的项目具有依赖关系(例如,第3章中的数据访问层项目)。这使得UI类不可能意外地依赖于数据访问类。但是,这种方法的缺点是并不总是很容易做到。 例如,使用ASP.NET Core MVC,将控制器和视图模型移动到一个单独的项目中很简单,但是要对视图和客户端资源进行相同的操作可能会非常具有挑战性。

将表示技术与组合根(Composition Root)分开可能也不是那么有益,因为组合根(Composition Root)特定于应用程序。组合根(Composition Root)不会重复使用。

您不应尝试在其他任何模块中编写类,因为这种方法会限制您的选择。 应用程序模块中的所有类都应使用构造函数注入(Constructor Injection)(或者在极少数情况下,使用本章的其他两种模式之一),然后将其留给组合根(Composition Root)组成应用程序的对象图。任何使用中的DI容器(DI Container)都应限于组合根(Composition Root)。

注意 将类的构成从组合根(Composition Root)移出会导致 控制怪物 (Control Freak) 或服务定位器(Service Locator)反模式(anti-pattern),我们将在下一章中进行讨论。

在应用程序中,控制怪物应该是唯一了解构造的对象图的结构的地方。应用程序代码不仅放弃了对其依赖关系的控制,而且还放弃了有关其依赖关系的知识。集中这些知识可以简化开发。这也意味着应用程序代码无法将依赖项传递给与当前操作并行运行的其他线程,因为使用者无法知道这样做是否安全。相反,当拆分并发操作时,组合根(Composition Root)的工作就是为每个并发操作创建一个新的对象图。

清单4.2中的成分根显示了一个Pure DI的示例。 但是,组合根(Composition Root)模式适用于Pure DI(纯 DI)和DI容器(DI Container)。 在下一节中,我们将描述如何在组合根(Composition Root)中使用DI容器(DI Container)。

在组合根(Composition Root)中使用DI容器(DI Container)(Using a DI Container in a Composition Root)

如第3章所述,DI容器(DI Container)是一个软件库,可以自动执行与组成对象和管理对象生命周期有关的许多任务。但是它可能会被误用作服务定位器(Service Locator),而应仅用作组成对象图的引擎。从该角度考虑DI容器(DI Container)时,将其约束到组合根(Composition Root)是有意义的。 这也极大地有利于消除DI容器(DI Container)与应用程序其余代码库之间的任何耦合

注意 只有组合根(Composition Root)应具有对DI容器(DI Container)的引用,并且只能从组合根(Composition Root)中引用。(应用程序的其余部分没有引用容器,而是依赖于本章中描述的模式。)所有其他模块也不应引用容器。DI容器(DI Container)了解这些模式并使用它们来构成应用程序的对象图。

组合根(Composition Root)可以通过DI容器(DI Container)实现。这意味着您可以使用容器通过一次调用其Resolve方法来组成整个应用程序的对象图。当我们与开发人员讨论这样做时,我们总是可以说这使他们感到不舒服,因为他们担心这样做效率极低并且对性能不利。 您不必为此担心。几乎从来没有这种情况,在少数情况下,有一些解决问题的方法,我们将在8.4.2节中进行讨论。

不用担心使用DI容器(DI Container)组成大型对象图的性能开销。通常这不是问题。在第4部分中,我们将深入研究DI容器(DI Container),并展示如何在组合根(Composition Root)中使用DI容器(DI Container)。

对于基于请求的应用程序(例如网站和服务),只需配置一次容器,但可以为每个传入请求解析一个对象图。第3章中的电子商务Web应用程序就是一个示例。

示例:使用Pure DI实现组合根(Composition Root)(Example: Implementing a Composition Root using Pure DI)

示例电子商务Web应用程序必须具有组合根(Composition Root)才能为传入的HTTP请求组成对象图。与所有其他ASP.NET Core Web应用程序一样,入口点位于Main方法中。但是,默认情况下,ASP.NET Core应用程序的Main方法将大部分工作委托给Startup类。对于我们来说,该启动类已经足够接近应用程序的入口点,我们将其用作组合根(Composition Root)

与前面的控制台应用程序示例一样,我们使用Pure DI。这意味着您将使用普通的旧C#代码而不是DI容器(DI Container)来组成对象图,如下面的清单所示。

清单4.3 电子商务应用程序的Startup

public class Startup  <----- ASP.NET Core在应用程序启动时调用此构造函数。
{
    public Startup(IConfiguration configuration)  
    {
        this.Configuration = configuration;
    }
    
    public IConfiguration Configuration { get; }
    
    public void ConfigureServices(  <---按照约定,ASP.NET调用此方法。 提供的IServiceCollection实例使您可以影响ASP.NET知道的默认服务。
        IServiceCollection services)
    {
        services.AddMvc();
        services.AddHttpContextAccessor();   <--将服务添加到框架,该服务检索当前的HttpContext
            
        var connectionString =
            this.Configuration.GetConnectionString(
            "CommerceConnection");   <--从配置文件加载应用程序的数据库连接字符串
            
        services.AddSingleton<IControllerActivator>(
            new CommerceControllerActivator(
                connectionString));  <---将默认IControllerActivator替换为构建对象图的IControllerActivator
    }
    ...
}

如果您不熟悉ASP.NET Core,这里有一个简单的解释:Startup类是必需的;它是您应用所需管道的地方。有趣的部分是CommerceControllerActivator。该应用程序的整个设置都封装CommerceControllerActivator类中,我们将稍后显示。

要启用将MVC控制器连接到应用程序,必须在ASP.NET Core MVC中使用适当的缝隙(Seam),称为IControllerActivator(在7.3节中详细讨论)。现在,您已经足够了解要与ASP.NET Core MVC集成,您必须为您的组合根(Composition Root)创建一个适配器并将其告知框架。

任何设计良好的框架都会提供适当的缝隙(Seam),以拦截框架类型的创建。这些缝隙(Seam)通常是工厂抽象的状态,MVCIControllerActivator也是如此。

Startup.ConfigureServices方法仅运行一次。因此,您的CommerceControllerActivator类是一个仅初始化一次的实例。因为使用自定义IControllerActivator设置了ASP.NET Core MVC,所以MVC调用其Create方法为每个传入的HTTP请求创建一个新的控制器实例(您可以在7.3节中了解详细信息)。以下清单显示了CommerceControllerActivator

清单4.4 应用程序的IControllerActivator实现

public class CommerceControllerActivator : IControllerActivator
{
    private readonly string connectionString;
    
    public CommerceControllerActivator(string connectionString)
    {
        this.connectionString = connectionString;
    }
    
    public object Create(ControllerContext ctx)  <---ASP.NET Core MVC为每个请求调用此方法。
    {
        Type type = ctx.ActionDescriptor.ControllerTypeInfo.AsType();
        if (type == typeof(HomeController))  <---如果MVC要求HomeController,则构建适当的对象图
        {
            return
                new HomeController(
                new ProductService(
                    new SqlProductRepository(
                        new CommerceContext( this.connectionString)),
                    new AspNetUserContextAdapter()));
        }
        else
        {
            throw new Exception("Unknown controller.");  <---电子商务应用程序当前仅具有一个控制器。 您添加的每个新控制器将具有自己的if块。
        }
    }
}

请注意,此示例中的HomeController的创建与清单4.1中显示的第3章中的应用程序的对象图几乎完全相同。 当MVC调用Create时,您将确定控制器类型并根据该类型创建正确的对象图。

图4.2 组合根(Composition Root)分布在两个类中,但是它们是在同一模块中定义的。
image

在2.3.3节中,我们讨论了仅组合根(Composition Root)应该如何依赖配置文件,因为可重用库可以更加灵活地由其调用者强制性地配置。您还应该将配置值的加载与执行对象组合(Object Composition)的方法分开(如清单4.3和4.4所示)。清单4.3的Startup类加载配置,而清单4.4的CommerceControllerActivator仅取决于配置值,而不取决于配置系统。 这种分离的一个重要优点是:它可以将对象组合(Object Composition)与使用中的配置系统脱钩,从而可以在不存在(有效)配置文件的情况下进行测试。

本示例中的组合根(Composition Root)分布在两个类中,如图4.2所示。这是预期的,重要的是所有类都包含在同一模块中,在这种情况下,该模块是应用程序的根目录。

该图中最重要的一点是,这两个类是整个示例应用程序中组成对象图的唯一类。 其余的应用程序代码仅使用构造函数注入(Constructor Injection)模式

明显的依赖项激增(The apparent dependency explosion)

开发人员经常听到的抱怨是,组合根(Composition Root)导致应用程序的入口点依赖于应用程序中的所有其他程序集。在旧的紧密耦合(tightly coupled)的代码库中,它们的入口点仅取决于直接在其下的层。这似乎是反作用了,因为DI是为了减少所需的依赖关系数。他们认为使用DI会导致应用程序入口点的依赖关系激增(看来如此)。

这种抱怨来自于以下事实:开发人员误解了项目依赖项的工作方式。为了更好地了解他们担心的问题,让我们看一下第2章中对Mary应用程序的依赖关系图,并将其与第3章中松散耦合(Loose Coupling)应用程序的依赖关系图进行比较(图4.3)。

图4.3 将Mary应用程序的依赖关系图与松耦合应用程序的依赖关系图进行比较
image

翻译

Because Mary didn't apply the COMPOSITION ROOT pattern in her application, you can visualize her dependency graph with the entry point as a module.

由于Mary并未在其应用程序中应用组合根(Composition Root)模式,因此您可以将入口点作为模块来可视化其依赖关系图。

The dependency graph of the loosely coupled application contains five dependencies.

松散耦合(Loose Coupling)应用程序的依赖关系图包含五个依赖关系。

Mary's dependency graph seems to have just three dependencies. This, however, is deceptive, as you’ll see shortly.

Mary的依赖关系图似乎只有三个依存关系。不过,您很快就会发现,这具有欺骗性。

乍一看,与Mary "仅有" 三个依赖关系的应用程序相比,在松散耦合(Loose Coupling)应用程序中确实确实存在两个依赖关系。但是,该图具有误导性。

数据访问层的更改也遍及UI层,正如我们在上一章中讨论的那样,没有数据访问层就无法部署UI层。 即使该图未显示,UI和数据访问层之间也存在依赖关系。实际上,程序集依赖项是可传递的。

笔记 传递性(Transitivity)是一个数学概念,它指出当元素a与元素b相关且b与元素c相关时,则a也与c相关。

这种可传递的关系意味着,由于Mary的UI依赖于域,并且域依赖于数据访问,因此UI也依赖于数据访问,这正是您在部署应用程序时会遇到的行为。如果您查看Mary应用程序中项目之间的依赖关系,将会看到一些不同的东西(图4.4)。

图4.4 Mary应用程序中库之间的依赖关系
image

翻译

The total number of dependencies between all modules in Mary's application is actually six. That's one more than the loosely coupled application.

实际上,Mary应用程序中所有模块之间的依赖性总数为6。 这比松散耦合(Loose Coupling)的应用程序还要多。

如您所见,即使在Mary的应用程序中,入口点也取决于所有库。Mary的入口点和松耦合应用程序的组合根(Composition Root)都具有相同数量的依赖关系。不过请记住,依赖性不是由模块的数量定义的,而是每个模块依赖于另一个模块的次数。结果,Mary应用程序中所有模块之间的依存关系总数实际上为六个。 这比松散耦合(Loose Coupling)的应用程序还要多。

现在想象一个包含数十个项目的应用程序。不难想象,与松散耦合(Loose Coupling)的代码库相比,紧密耦合(tightly coupled)的代码库中的依赖关系数量激增。 但是,通过编写应用组合根(Composition Root)模式的松散耦合(Loose Coupling)代码,可以减少依赖项的数量。 正如您在上一章中所看到的那样,这使您可以用不同的模块替换完整的模块,而在紧密耦合(tightly coupled)的代码库中则很难。

组合根(Composition Root)模式适用于使用DI开发的所有应用程序,但只有启动项目才具有组合根(Composition Root)组合根(Composition Root)是从消费者消除创建依赖关系的责任的结果。为此,可以应用两种模式:构造函数注入(Constructor Injection)属性注入(Property Injection)构造函数注入(Constructor Injection)是最常见的方法,几乎应仅使用它。 由于构造函数注入(Constructor Injection)是最常用的模式,因此我们将在后面讨论。

构造函数注入(Constructor Injection)

​ 我们如何保证当前正在开发的课程始终可以使用必要的过度性依赖项(Volatile Dependency)

​ 通过要求所有调用者向类的构造函数提供过度性依赖项(Volatile Dependency)作为参数。

当类需要依赖项的实例时,您可以通过类的构造函数提供该依赖项,从而使其能够存储引用以供将来使用。

定义 构造函数注入(Constructor Injection)是通过将所需依赖项指定为类的构造函数的参数来静态定义所需依赖项列表的操作。

构造函数签名使用该类型进行编译,可供所有人查看。它清楚地证明该类需要通过其构造函数请求的依赖项。图4.5对此进行了演示该图显示了使用类HomeController需要IProductService 依赖的实例才能工作,因此它需要组合根(Composition Root)(客户端)通过其构造函数提供实例。这样可以确保实例在需要时可供HomeController使用。

图4.5 使用构造函数注入(Constructor Injection)构造具有所需IProductService依赖关系的HomeController实例
image
构造函数注入(Constructor Injection)的工作方式(How Constructor Injection works)

需要依赖(Dependency)的类必须公开一个公共构造函数,该构造函数将所需依赖的一个实例作为构造函数参数。这应该是唯一公开可用的构造函数。如果需要多个依赖关系,则可以将其他构造函数参数添加到同一构造函数中。清单4.5显示了图4.5的HomeController类的定义。

**重要 ** 将设计约束到单个(公共)构造函数。因为构造函数是类的依赖关系的定义,所以拥有多个定义几乎没有任何意义。重载的构造函数会导致模棱两可:调用者(或DI容器(DI Container))应使用哪个构造函数?

清单4.5 使用构造函数注入(Constructor Injection)注入依赖项

public class HomeController
{
    private readonly IProductService service;  <--- 私有实例字段,用于存储提供的依赖项
        
    public HomeController(  <--- 静态定义其依赖项的构造方法
        IProductService service)  <---提供所需依赖关系的论点
    {
        if (service == null)   <-----防御性语句(Guard Clause),以防止客户端传入null
            throw new ArgumentNullException("service");
        this.service = service;  <----将相关性存储在私有字段中,以备后用。 构造函数除验证和存储其传入依赖关系外不包含其他逻辑
    }
}

IProductService依赖关系是HomeController的必需构造函数参数。没有提供IProductService实例的任何客户端都无法编译。但是,因为接口是引用类型,所以调用者可以传入null作为参数,以使调用代码得以编译。您需要使用防御性语句(Guard Clause)保护此类免于滥用。因为编译器和防御性语句(Guard Clause)的共同努力保证了如果不抛出异常,则构造函数参数是有效的,因此构造函数可以在不知道的情况下存储依赖项以供将来使用关于真正的实现的任何事情。

最好将保留依赖项(Dependencies)的字段标记为只读这样可以确保一旦执行了构造函数的初始化逻辑,就不能修改该字段。从DI的角度来看,这并不是严格要求的,但是它可以防止您在依赖类代码中的其他地方意外修改字段(例如,将其设置为null

**重要 ** 保持构造函数不受任何其他逻辑影响,以防止其在依赖项(Dependencies)上执行任何工作。单一责任原则(Single Responsibility Principle)意味着成员只能做一件事。现在,您正在使用构造函数来注入依赖项(Dependencies),那么就应该使其免受其他问题的困扰。这样可以使您的类构建变得快速而可靠。

当构造函数返回时,该类的新实例处于一致状态,并且已将其依赖项的适当实例注入到该状态中。因为构造的类拥有对该依赖关系的引用,所以它可以根据需要从其任何其他成员中频繁使用依赖关系。它的成员不需要测试是否为空,因为可以保证实例存在。

何时使用构造函数注入(Constructor Injection)(When to use Constructor Injection)

构造函数注入(Constructor Injection)应该是DI的默认选择。它解决了类需要一个或多个依赖项且没有合理的本地默认值(Local Default)的最常见情况。

定义 本地默认值(Local Default)是依赖关系的默认实现,该依赖项起源于同一模块或层。

本地默认值(Local Default)

在开发具有依赖关系的类时,您可能会想到该依赖关系的特定实现。如果您要编写访问存储库的领域服务,则最有可能计划开发使用关系数据库的存储库的实现。

使该实现成为正在开发的类所使用的默认值是很诱人的。但是,当在不同的程序集中实现这种预期的默认设置时,将其用作默认设置意味着对该另一个程序集进行硬引用,从而有效地违反了第1章中描述的松散耦合(Loose Coupling)的许多好处。本地默认值(Local Default)-这是外部默认值(Foreign Default)。严格引用外部默认值(Foreign Default)的类正在应用控制怪物反模式(Control Freak anti-pattern)。我们将在第5章中讨论控制怪物(Control Freak)。

相反,如果在与消费类相同的库中定义了预期的默认实现,则不会出现此问题。存储库不太可能是这种情况,但是这种本地默认值(Local Default)通常是在实施策略模式(Strategy pattern)时出现的。

警告 带有依赖关系的本地默认值(Local Default)在其依赖项之一为外部默认值(Foreign Default)时变为外部默认值(Foreign Default)。 传递性再次出现。

构造函数注入(Constructor Injection)解决了需要依赖关系且没有合理的本地默认值(Local Default)的对象的常见情况,因为它保证必须提供依赖关系。如果依赖类在没有依赖的情况下绝对无法运行,那么这种保证很有价值。表4.1总结了构造函数注入(Constructor Injection)的优缺点。

表4.1 构造函数注入(Constructor Injection)的优缺点

优点 缺点
注入保证(Injection guaranteed)
易于实施(Easy to implement)
静态声明一个类的依赖关系(Statically declares a class's Dependencies)
应用约束构造(Constrained Construction)反模式(anti-pattern)的框架可能会使构造器注入变得困难。

在本地库可以提供良好的默认实现的情况下,属性注入(Property Injection)也很合适,但是通常不是这种情况。在前面的章节中,我们展示了许多作为依赖项的存储库示例。这些是很好的依赖关系示例,其中本地库无法提供良好的默认实现,因为正确的实现属于专用的数据访问库。 除了已经讨论过的保证注入之外,使用清单4.5中所示的结构也易于实现此模式。

构造函数注入(Constructor Injection)的主要缺点是:如果要构建的类是由当前的应用程序框架调用的,则可能需要自定义该框架以对其进行支持。 某些框架,尤其是较旧的框架,假定您的类将具有无参数的构造函数。(这称为约束构造反模式(Constrained Construction anti-pattern),我们将在下一章中对此进行详细讨论。)在这种情况下,该框架当无参数构造函数不可用时,将需要特殊的帮助来创建实例。在第7章中,我们将说明如何为常见的应用程序框架启用构造函数注入(Constructor Injection)

如之前在4.1节中讨论的那样,构造函数注入(Constructor Injection)的明显缺点是它要求立即对整个依赖图进行初始化。尽管这听起来效率低下,但这很少成为问题。毕竟,即使对于复杂的对象图,我们通常也要谈论创建几十个新的对象实例,而创建对象实例是.NET Framework可以非常快速地完成的事情。您的应用程序可能遇到的任何性能瓶颈都会在其他地方出现,因此请不必担心。

注意 如前所述,组件构造函数应没有所有逻辑(保护检查和存储传入的依赖项除外)。这样可以加快构建速度并避免出现大多数性能问题。

极大的对象图(Extremely big object graphs)

我(Steven)曾经与一名开发人员进行过交谈,该开发人员在使用旧容器遇到一些严重的性能问题后切换了DI容器(DI Container)。 切换后,他报告每个Web请求的加速时间为300到400毫秒,这非常令人印象深刻。 但是,在对他的应用程序进行了一些分析之后,我发现,在某些情况下,创建了一个对象图,该对象图由19000多个对象实例组成。 难怪对于某些DI容器(DI Container),它的执行效果如此之差。

我很难想象这个对象图的大小。我从来没有见过这么大的东西。系统中的许多类都很庞大,并且依赖项太多。 二十个或更多的依赖关系很常见。即使是常用的类,也具有如此多的依赖关系,这导致对象图中的对象实例数量呈螺旋形失控,或者,正如开发人员本人所说,“现实世界有时超出了幻想。”

尽管这个故事似乎证明了性能可能是一个问题,但故事的寓意是精心设计的系统几乎没有这个问题。 在一个设计良好的系统中,类只有几个依赖项(最多四个或五个),这使得对象图非常狭窄。由于可以轻松应用多层装饰器,因此在设计良好的系统中对象图趋于深入。但是,最后,设计良好的系统图中的对象数将保持在几百之内最多。 这意味着在正常条件下,使用设计良好的系统,即使是速度较慢的DI容器(DI Container),也通常不会造成性能问题。

既然您知道构造函数注入(Constructor Injection)是应用DI的首选方法,那么让我们看看一些已知的示例。为此,我们接下来将在.NET中讨论构造函数注入(Constructor Injection)。

构造函数注入(Constructor Injection)的已知用法(Known use of Constructor Injection)

尽管构造函数注入(Constructor Injection)在使用DI的应用程序中普遍存在,但在BCL中并不多见。这主要是因为BCL是一组可重用的库,而不是一个成熟的应用程序。有两个相关的例子,您可以看到BCL中的构造函数注入(Constructor Injection)系统IO.StreamReader以及系统IO.StreamWriter类。两人都要休息系统IO流实例。以下是StreamWriter的所有与流相关的构造函数;StreamReader构造函数类似:

public StreamWriter(Stream stream);
public StreamWriter(Stream stream, Encoding encoding);
public StreamWriter(Stream stream, Encoding encoding, int bufferSize);

Stream是一个抽象类,它充当StreamWriterStreamReader在其上执行其职责的抽象。您可以在它们的构造函数中提供任何流实现,它们也会使用它,但是如果您尝试向它们提供空流,它们会抛出ArgumentNullExceptions

注意 对于可重用的类库(例如BCL)中的类,通常具有多个构造函数是有意义的。但是,对于您的应用程序组件而言,尽管BCL提供了一些示例,您可以在其中看到正在使用的构造函数注入(Constructor Injection),但是查看可行的示例总是更有启发性的。 下一节将向您介绍完整的实现示例。

示例:将货币转换添加到特色产品中

Mary的老板说她的应用运行良好,但是现在一些使用该应用的客户希望以不同的货币付款。她可以编写一些新代码来使应用程序以不同货币显示和计算成本吗?Mary叹了口气,意识到仅用几种不同的货币换算来硬编码是不够的。她将需要编写足够灵活的代码,以便随着时间的推移适应任何货币。DI再次调用。

Mary所需要的既是表示货币及其货币的对象,又是一个将货币从一种货币转换为另一种货币的抽象。 她将命名为ICurrencyConverter抽象类。 为简单起见,Currency将仅具有货币 CodeMoneyCurrencyAmount组成,如图4.6所示。

图4.6 使用ICurrencyConverter兑换货币
image

翻译

Definition of some "type" of money, such as Dollar, Euro, Pound, etc.

某种“类型”的货币的定义,例如美元,欧元,英镑等。

Presents an actual amount for a given currency.

显示给定货币的实际金额。

Allows converting money from one currency to another.

允许将货币从一种货币转换为另一种货币。

下面的清单显示了CurrencyMoney类以及ICurrencyConverter接口,如图4.6所示。

清单4.6 CurrencyMoneyICurrencyConverter接口

public interface ICurrencyConverter
{
    Money Exchange(Money money, Currency targetCurrency);
}

public class Currency
{
    public readonly string Code;
    public Currency(string code)
    {
        if (code == null) throw new ArgumentNullException("code");
        this.Code = code;
    }
}

public class Money
{
    public readonly decimal Amount;
    public readonly Currency Currency;
    
    public Money(decimal amount, Currency currency)
    {
        if (currency == null) throw new ArgumentNullException("currency");
        this.Amount = amount;
        this.Currency = currency;
    }
}

ICurrencyConverter可能表示进程外资源,例如提供转换率的Web服务或数据库。这意味着在单独的项目(例如数据访问层)中实现具体的ICurrencyConverter是合适的。因此,没有合理的本地默认设置。

同时,ProductService类将需要一个ICurrencyConverter构造函数注入(Constructor Injection)非常适合。 以下清单显示了如何将ICurrencyConverter依赖关系注入到ProductService中。

清单4.7 将ICurrencyConverter注入ProductService

public class ProductService : IProductService
{
    private readonly IProductRepository repository;
    private readonly IUserContext userContext;
    private readonly ICurrencyConverter converter;
    
    public ProductService(
        IProductRepository repository,
        IUserContext userContext,
        ICurrencyConverter converter)
    {
        if (repository == null)
            throw new ArgumentNullException("repository");
        if (userContext == null)
            throw new ArgumentNullException("userContext");
        if (converter == null)
            throw new ArgumentNullException("converter");
        this.repository = repository;
        this.userContext = userContext;
        this.converter = converter;
    }
}

因为ProductService类已经具有对IProductRepositoryIUserContext的依赖关系,所以我们添加了新的ICurrencyConverter依赖关系作为第三个构造函数参数,然后遵循清单4.5中概述的相同顺序。防御性语句(Guard Clause)可确保相关性不为空,这意味着可以安全地存储它们以便以后在只读字段中使用。 因为ICurrencyConverter被保证存在于ProductService中,所以可以在任何地方使用它。 例如,在如下所示的GetFeaturedProducts方法中。

清单4.8 使用ICurrencyConverterProductService

public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
    Currency userCurrency = this.userContext.Currency;  <---将货币属性添加到IUserContext以获取用户的首选货币
        
    var products = this.repository.GetFeaturedProducts();
    
    return
        from product in products
        let unitPrice = product.UnitPrice           <---现在,产品的单价类型为Money。
        let amount = this.converter.Exchange(  <---给定一些钱和新货币,调用ICurrencyConverter为新货币提供一定金额
            money: unitPrice, 
            targetCurrency: userCurrency)
        select product
        .WithUnitPrice(amount)
        .ApplyDiscountFor(this.userContext);
}

请注意,您可以使用转换器字段,而无需事先检查其可用性。 那是因为它肯定会存在。

装配起来(Wrap-up)

构造函数注入(Constructor Injection)是最可用的可用DI模式,也是最容易正确实现的模式。它在需要依赖项时适用。如果需要使依赖项为可选,如果属性注入(Property Injection)具有适当的本地默认值(Local Default),则可以将其更改为属性注入(Property Injection)

警告 依赖项几乎永远不是可选的。可选依赖项(Optional Dependencies)通过空检查使消费组件复杂化。相反,使依赖项成为必需的,并在没有合理的实现可用于所需依赖项的情况下创建和注入空对象(Null Object)实现。

空对象模式(Null Object pattern)

空对象模式(Null Object pattern)允许使用者的依赖关系始终可用,即使在没有任何实际实现的情况下也是如此。通过注入不包含任何行为的实现(空对象(Null Object)),使用者可以透明地处理依赖关系,而无需复杂地进行空检查。

空对象模式的一般结构
image

翻译

Client uses a target ABSTRACTION, while calling its Request method.

客户机在调用其请求方法时使用目标抽象类。

The null object implements the target ABSTRACTION. Its Request method, however, is an empty stub.

空对象实现目标抽象类。然而它的请求方法是一个空存根。

空对象模式(Null Object pattern)的实现通常是空的,除非空对象必须返回值。在这种情况下,通常会返回最简单的正确值。

有时,应用程序需要生成允许开发人员或操作人员分析问题的输出。日志抽象(Logging Abstractions)是一种常用的方法。即使类可以设计为支持日志记录,但它运行的应用程序可能不需要记录特定的类。尽管您可以让这样的类检查记录器的可用性(例如,使用空检查),但更健壮的解决方案是注入空对象(Null Object)实现。

一个具体的示例,其中客户机使用由NullLogger实现的ILogger抽象类
image

翻译

Client uses the ILogger ABSTRACTION, while calling its Log method.

客户端使用ILogger抽象,同时调用其日志方法。

NullLogger implements ILogger. Any call to Log returns immediately, without doing anything.

NullLogger实现ILogger。任何对Log的调用都会立即返回,而不做任何操作。

FileLogger implements ILogger and writes log entries to a file.

FileLogger实现ILogger并将日志条目写入文件。

本章的下一个模式是方法注入(Method Injection),它采用了一种稍微不同的方法。它倾向于更多地应用于您已经有了一个依赖项的情况,您希望将该依赖项传递给您调用的协作者。

方法注入(Method Injection)

​ 当每个操作的依赖项不同时,我们如何向类中注入依赖项?

​ 将其作为方法参数提供。

如果依赖项随每个方法调用而变化,或者依赖项的使用者随每个调用而变化,则可以通过方法参数提供依赖项。

定义 方法注入(Method Injection)通过将依赖项作为方法参数传递给在组合根(Composition Root)外部调用的方法,为使用者提供依赖项。

图4.7 使用方法注入,ProductService创建了一个Product实例,并将一个IUserContext实例注入到Product.applyDiscount每次方法调用。
image

翻译

ProductService creates an instance of Product.

ProductService创建Product的实例。

Using METHOD INJECTION, ProductService injects an instance of IUserContext into Product.ApplyDiscountFor with each method call.

使用方法注入(Method Injection),ProductServiceIUserContext的实例注入到Product.ApplyDiscountFor每次方法调用。

Product uses IUserContext to calculate the discount.

Product 使用IUserContext计算折扣。

方法注入的工作原理(How Method Injection works)

调用方在每个方法调用中提供依赖项作为方法参数。Mary的电子商务应用程序中的这种方法的一个示例是Product类,其中ApplyDiscountFor方法使用方法注入(Method Injection)接受IUserContext依赖关系:

image

IUserContext为要运行的操作提供上下文信息,这是方法注入(Method Injection)的常见场景。通常,这个上下文会与一个“适当”的值一起提供给一个方法,如清单4.9所示。

清单4.9 将依赖项与适当的值一起传递

public decimal CalculateDiscountPrice(decimal price, IUserContext context)
{
    if (context == null) throw new ArgumentNullException("context");
    decimal discount = context.IsInRole(Role.PreferredCustomer) ? .95m : 1;
    return price * discount;
}

price 值参数表示方法应该操作的值,而context包含关于操作的当前上下文的信息;在本例中,是关于当前用户的信息。调用者向方法提供依赖关系。正如您以前多次看到的,防御性语句(Guard Clause)保证上下文对方法体的其余部分可用。

何时使用方法注入(When to use Method Injection)

方法注入(Method Injection)不同于其他类型的DI模式,因为注入不是在组合根(Composition Root)中发生的,而是在调用时动态发生的。这允许调用者提供特定于操作的上下文,这是.NET BCL 中使用的常见扩展机制。表4.2总结了 方法注入(Method Injection)的优点和缺点。

表4.2 方法注入(Method Injection)优缺点

优点 缺点
允许调用者提供特定于操作的上下文 适用范围有限
允许将依赖项注入到以数据为中心的对象中,而这些对象不是在组合根(Composition Root)中创建的 使依赖关系成为类或其抽象的公共API的一部分

应用方法注入(Method Injection)有两个典型的用例:

  • 当注入的依赖项的使用者在每次调用时发生变化时
  • 当注入的依赖项随每次对使用者的调用而变化时

以下各节分别给出了一个示例。清单4.9是消费者如何变化的一个示例。这是最常见的形式,因此我们将从提供另一个示例开始。

示例:在每个方法调用上改变依赖项的使用者(Example: Varying the Dependency's consumer on each method call)

在实践域驱动设计(Domain-Driven Design DDD)时,通常会创建包含域逻辑的领域实体(domain Entities),有效地将运行时数据与同一类中的行为混合在一起。但是,实体通常不会在组合根(Composition Root)中创建。以下面的客户实体为例。

清单4.10 包含领域逻辑但没有依赖关系的实体(尚未)

public class Customer   <---- 领域实体
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }  <---实体的数据成员。 这是应用程序的运行时数据。
    
    public Customer(Guid id, string name) <---构造函数要求提供实体的数据。 通过这种方式,构造函数可以确保始终以有效状态创建实体。
    {
        ...
    }
    public void RedeemVoucher(Voucher voucher) ...  <---让客户兑换凭证
    public void MakePreferred() ...  <--将客户提升为“首选”状态
}

清单4.10中的RedeemVoucherMakePreferred方法是领域方法。 RedeemVoucher实现了领域逻辑,该逻辑使客户可以兑换凭证。(购买本书时,您可能已经兑换了优惠券以获得折扣。)优惠券是该方法使用的值对象(value object)。 另一方面,MakePreferred实现了提升客户的领域逻辑。普通客户可以升级成为首选客户,这可能会带来某些优势和折扣,类似于成为飞行常客的客户。

除了通常的数据成员集之外,包含行为的实体(Entities)将很容易获得范围广泛的方法,每个方法都需要自己的依赖关系。尽管您可能会尝试使用构造函数注入(Constructor Injection)来注入这样的依赖关系,但这会导致这样一种情况,即每个这样的实体都需要用它的所有依赖关系来创建,即使对于给定的用例来说可能只需要几个。这使测试实体的逻辑变得复杂,因为所有依赖项都需要提供给构造函数,即使测试可能只对少数依赖项感兴趣。方法注入(Method Injection)如下一个清单所示,提供了一个更好的选择。

清单4.11 使用方法注入(Method Injection)的实体

public class Customer
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    
    public Customer(Guid id, string name)
    {
        ...
    }
    public void RedeemVoucher(
        Voucher voucher,
        IVoucherRedemptionService service)
    {
        if (voucher == null)
            throw new ArgumentNullException("voucher");
        if (service == null)
            throw new ArgumentNullException("service");
        service.ApplyRedemptionForCustomer(
            voucher,
            this.Id);
    }
    public void MakePreferred(IEventHandler handler)
    {
        if (handler == null)
            throw new ArgumentNullException("handler");
        handler.Publish(new CustomerMadePreferred(this.Id));
    }
}

<--------- 10,22row  使用方法注入,实体的两个领域方法RedeemVoucher和MakePreferred都接受所需的依赖项-IVoucherRedemptionService和IEventHandler。 他们验证参数并使用提供的依赖关系。

CustomerServices组件内部,可以在通过调用传递IVoucherRedemptionService 依赖项的同时调用CustomerRedeemVoucher方法,如下所示。

清单4.12 使用方法注入(Method Injection)传递依赖项的组件

public class CustomerServices : ICustomerServices
{
    private readonly ICustomerRepository repository;
    private readonly IVoucherRedemptionService service;
    
    public CustomerServices(
        ICustomerRepository repository,
        IVoucherRedemptionService service)  <---CustomerServices类使用构造函数注入来静态定义其所需的依赖项。 IVoucherRedemptionService是这些依赖项之一。
    {
        this.repository = repository;
        this.service = service;
    }
    
    public void RedeemVoucher(
        Guid customerId, Voucher voucher)  <----使用方法注入将IVoucherRedemptionService依赖关系传递给已构造的客户实体。 在ICustomerRepository实现内部创建客户.
    {
        var customer = this.repository.GetById(customerId);
        customer.RedeemVoucher(voucher, this.service);
        this.repository.Save(customer);
    }
}

在清单4.12中,仅从ICustomerRepository请求了一个Customer实例。 但是,可以使用多个客户和凭证来一次又一次地调用单个CustomerServices实例,从而将相同的IVoucherRedemptionService提供给许多不同的Customer实例。 客户是IVoucherRedemptionService 依赖项的使用者,并且在您重新使用依赖项时,您正在改变使用者。

这类似于清单4.9中所示的第一个方法注入(Method Injection)示例和清单3.8中讨论的ApplyDiscountFor方法。相反的情况是,当您在保持其使用者的同时改变依赖关系时。

示例:更改每个方法调用的注入依赖项(Example: Varying the injected Dependency on each method call)

想象一下一个图形绘图应用程序的外接程序系统,您希望每个人都能够插入自己的图像效果。外部图像效果可能需要有关运行时上下文的信息,应用程序可以将这些信息传递给图像效果。这是应用方法注入(Method Injection)的典型用例。您可以定义以下界面来应用这些效果:

public interface IImageEffectAddIn  <---代表图像效果的外接程序的抽象。 通过实现此抽象类,可以将图像效果插入应用程序中。
{
    Bitmap Apply(  <---允许加载项将其效果应用到源,然后返回应用了该效果的新位图
        Bitmap source,
        IApplicationContext context); <----使用方法注入通过图形应用程序为图像效果提供上下文信息
}

IImageEffectAddInIApplicationContext依赖关系可以随对Apply方法的每次调用而变化,从而为效果提供有关调用操作的上下文的信息。实现此接口的任何类都可以用作外接程序。一些实现可能根本不关心上下文,而其他实现则会。
客户机可以通过使用源位图和上下文调用每个外接程序来使用外接程序列表,以返回聚合结果,如下一个清单所示。

清单4.13 一个示例外接程序客户端

public Bitmap ApplyEffects(Bitmap source)
{
    if (source == null) throw new ArgumentNullException("source");
    Bitmap result = source;
    foreach (IImageEffectAddIn effect in this.effects)
    {
        result = effect.Apply(result, this.context);
    }
    return result;
}

私有 effects字段是IImageEffectAddIn实例的列表,允许客户端在列表中循环以调用每个加载项的Apply方法。 每次在外接程序上调用Apply方法时,该操作的上下文(由context字段表示)都作为方法参数传递:

result = effect.Apply(result, this.context);

有时,值和操作上下文封装在一个单独的抽象类中,该抽象类与两者结合使用。需要注意的重要一点是:正如您在两个示例中都已经看到的那样,通过方法注入注入的依赖关系成为抽象定义的一部分。如果依赖项包含直接调用方提供的运行时信息,通常这是理想的选择。

如果依赖项(Dependency)是调用方的实现细节,则应尝试防止抽象类(Abstraction)被“污染”。因此,构造函数注入是一个更好的选择。否则,您很容易最终将依赖关系从应用程序对象图的顶部一直向下传递,从而引起大范围的更改。

前面的示例都显示了在组合根(Composition Root)之外使用方法注入(Method Injection)。这是故意的。当在组合根(Composition Root)中使用时,方法注入(Method Injection)是不合适的。在组合根(Composition Root)中,方法注入(Method Injection)可以使用其依赖项初始化以前构造的类。但是,这样做会导致时间耦合(Temporal Coupling),因此不鼓励这样做。

时间耦合的代码味道(The Temporal Coupling code smell)

时间耦合(Temporal Coupling)是API设计中的一个常见问题。当一个类的两个或多个成员之间存在隐式关系,要求客户机先调用一个成员,再调用另一个成员时,就会发生这种情况。这使得成员在时间维度上紧密耦合。最典型的例子是Initialize方法的使用,尽管可以找到大量的其他例子——甚至在BCL中。例如,这个用法System.ServiceModel.EndpointAddressBuilder编译但在运行时失败:

var builder = new EndpointAddressBuilder();
var address = builder.ToEndpointAddress();

事实证明,在创建EndpointAddress之前需要一个URI。以下代码在运行时编译并成功:

var builder = new EndpointAddressBuilder();
builder.Uri = new UriBuilder().Uri;
var address = builder.ToEndpointAddress();

API没有提示这是必要的,但是Uri属性和ToEndpointAddress方法之间存在时间耦合(Temporal Coupling)。

当在组合根(Composition Root)中应用时,循环模式是使用一些初始化方法,如清单4.14所示。

清单4.14 时间耦合示例 (坏代码)

public class Component
{
    private ISomeInterface dependency;
    public void Initialize(
        ISomeInterface dependency)  <--- 需要以特定的顺序调用Initialize和DoSomething方法,但是这种关系是隐式的。 这导致时间耦合
    {
        this.dependency = dependency;
    }
    
    public void DoSomething()
    {
        if (this.dependency == null)
            throw new InvalidOperationException( 
            "Call Initialize first."); <--在Initialize之前调用DoSomething的可能性会强制添加此额外的防御性语句(Guard Clause),这是此类的每个公共方法所必需的。
        this.dependency.DoStuff();
    }
}

从语义上讲,Initialize方法的名称是一个线索,但是在结构层次上,这个API没有显示时间耦合(Temporal Coupling)。因此,这样的代码可以编译,但在运行时抛出异常:

var c = new Component(); 
c.DoSomething();

这个问题的解决方案现在应该是显而易见的—您应该应用构造函数注入(Constructor Injection):

public class Component
{
    private readonly ISomeInterface dependency;
    
    public Component(ISomeInterface dependency)
    {
        if (dependency == null)
            throw new ArgumentNullException("dependency");
        this.dependency = dependency;
    }
    
    public void DoSomething()
    {
        this.dependency.DoStuff();
    }
}

警告 不要存储注入的方法依赖项。这会导致时间耦合(Temporal Coupling)、捕获依赖(Captive Dependencies)或隐藏的副作用(hidden side effects)。方法应该使用依赖或传递依赖,并且应该避免存储这种依赖。方法注入(Method Injection)的使用在.NET BCL 中非常常见,所以我们接下来将看一个示例。

方法注入的已知用法(Known use of Method Injection)

.NET BCL提供了许多方法注入的示例,特别是在System.ComponentModel命名空间中。你用System.ComponentModel.Design.IDesigner,用于实现组件的自定义设计时功能。它有一个Initialize方法,该方法接受一个IComponent实例,以便它知道当前正在帮助设计哪个组件。(请注意,此初始化方法会导致时间耦合(Temporal Coupling)。)设计器是由IDesignerHost实现创建的,IDesignerHost实现也将IComponent实例作为参数来创建设计器:

IDesigner GetDesigner(IComponent component);

这是参数本身携带信息的一个很好的例子。组件可以携带有关要创建哪个IDesigner的信息,但同时,它也是设计器随后必须对其进行操作的组件。

另一个例子是System.ComponentModel命名空间由TypeConverter类提供。它的一些方法采用ITypeDescriptorContext的一个实例,顾名思义,该实例传递有关当前操作上下文的信息,例如有关类型属性的信息。因为有很多这样的方法,我们不想全部列出,但这里有一个代表性的例子

public virtual object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)

在该方法中,操作的上下文由context参数显式传递,而要转换的值和目标类型作为单独的参数发送。实现者可以在他们认为合适的时候使用或忽略上下文参数。

ASP.NET Core MVC还包含几个方法注入的示例。例如,您可以使用IValidationAttributeAdapterProvider接口来提供IAttributeAdapter实例。唯一的方法是:

IAttributeAdapter GetAttributeAdapter( ValidationAttribute attribute, IStringLocalizer stringLocalizer)

ASP.NET Core 允许用ValidationAttribute标记视图模型的属性。应用描述视图模型中封装的属性的有效性的元数据是一种方便的方法。

基于ValidationAttributeGetAttributeAdapter方法允许返回 IAttributeAdapter,从而允许在网页中显示相关的错误消息。在GetAttributeAdapter方法中,属性参数是应该为其创建IAttributeAdapter的对象,而stringLocalizer是通过方法注入传递的依赖项。

注意 当我们建议构造函数注入(Constructor Injection)应该是您首选的DI模式时,我们假设您通常构建应用程序。另一方面,如果您正在构建一个框架,方法注入(Method Injection)可能很有用,因为它允许框架将有关上下文的信息传递给插件。这就是为什么注入法在BCL中大量使用的原因之一。但即使在应用程序代码中,方法注入(Method Injection)也是有用的。

接下来,我们将看到Mary如何使用方法注入(Method Injection)来防止代码重复。当我们上次看到Mary时(在第4.2节中),她正在使用ICurrencyConverter:她使用构造函数注入(Constructor Injection)将其注入到ProductService类中。

示例:向产品实体添加货币转换(Example: Adding currency conversions to the Product Entity)

清单4.8展示了GetFeaturedProducts方法如何调用ICurrencyConverter。在Mary的应用程序中使用产品单价和用户首选货币的交换方法。下面是GetFeaturedProducts方法:

public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
    Currency currency = this.userContext.Currency;
    return
        from product in this.repository.GetFeaturedProducts()
        let amount = this.converter.Exchange(product.UnitPrice, currency)
        select product
            .WithUnitPrice(amount)
            .ApplyDiscountFor(this.userContext);
}

在她的应用程序的许多部分中,将多个Product 实体从一种Currency转换为另一种Currency将是一项经常性的任务。因此,Mary喜欢将Product转换的逻辑从ProductService中移出,并将其作为Product实体的一部分进行集中。这样可以防止系统的其他部分重复此代码。方法注入(Method Injection)是一个很好的选择。Mary在Product中创建了一个新的ConvertTo方法,如下一个清单所示。

清单4.15 使用ConvertTo方法的 Product 实体

public class Product
{
    public string Name { get; set; }
    public Money UnitPrice { get; set; }
    public bool IsFeatured { get; set; }
    
    public Product ConvertTo(
        Currency currency,   <------ ConvertTo方法接受Currency。
        ICurrencyConverter converter)  <-----现在使用方法注入来注入ICurrencyConverter依赖关系。
    {
        if (currency == null)
            throw new ArgumentNullException("currency");
        if (converter == null)
            throw new ArgumentNullException("converter");
        var newUnitPrice =
            converter.Exchange(  <--- 通过致电Exchange确定新的单价。
            this.UnitPrice,
            currency);
        return this.WithUnitPrice(newUnitPrice);  <--- 基于原始产品创建一个新的Product实例,其中UnitPrice被新构建的单价替换。
    }
    
    public Product WithUnitPrice(Money unitPrice)
    {
        return new Product
        {
            Name = this.Name,
            UnitPrice = unitPrice,
            IsFeatured = this.IsFeatured
        };
    }
    ...
}

使用新的ConvertTo方法,Mary重构GetFeaturedProducts方法。

清单4.16 使用ConvertTo方法的GetFeaturedProducts

public IEnumerable<DiscountedProduct> GetFeaturedProducts()
{
    Currency currency = this.userContext.Currency;
    return
        from product in this.repository.GetFeaturedProducts()
        select product
            .ConvertTo(currency, this.converter)  <---现在通过方法注入提供了ICurrencyConverter。
            .ApplyDiscountFor(this.userContext);
}

而不是打电话给ICurrencyConverter.Exchange方法,正如您之前看到的,GetFeaturedProducts现在使用方法注入(Method Injection)将ICurrencyConverter传递给ConvertTo方法。这简化了GetFeaturedProducts方法,并在Mary需要在其代码库的其他地方转换产品时防止任何代码重复。通过使用方法注入(Method Injection)而不是构造函数注入(Constructor Injection),她避免了用所有依赖关系构建Product实体。这简化了构造和测试。

注意 尽管我们在4.2节中定义了ICurrencyConverter,但是我们还没有讨论ICurrencyConverter类是如何实现的,因为无论从方法注入还是构造函数注入(Constructor Injection)的角度来看,它都不是那么重要。如果您有兴趣了解它是如何实现的,可以在本书附带的源代码中找到它。

与本章中的其他DI模式不同,当您希望向已经存在的使用者提供依赖项时,主要使用方法注入(Method Injection)。另一方面,通过构造函数注入(Constructor Injection)和属性注入(Property Injection),可以在创建使用者时向其提供依赖项。

本章的最后一个模式是属性注入(Property Injection),它允许您重写类的本地默认值(Local Default)。在方法注入仅在组合根外部应用的情况下,属性注入(Property Injection)与构造函数注入(Constructor Injection)一样,是从组合根内部应用的。

属性注入(Property Injection)

​ 当我们有一个好的本地默认值时,如何在类中启用DI作为选项?

​ 通过公开一个可写属性,如果调用方希望重写默认行为,该属性允许调用方提供依赖项。

当一个类有一个良好的本地默认值(Local Default),但您仍然希望将其保持为开放的可扩展性时,您可以公开一个可写属性,该属性允许客户机提供与默认值不同的类依赖性实现。如图4.8所示,希望按原样使用Consumer类的客户机可以创建一个类的实例并使用它,而不必多想,而希望修改类的行为的客户机可以通过将依赖属性设置为IDEDependency的不同实现来实现。

定义 属性注入(Property Injection)允许通过公共可设置属性替换本地默认值(Local Default)。属性注入(Property Injection)也称为Setter注入。

图4.8 属性注入
image

翻译

Consumer has a dependency on IDependency.

Consumer 对依赖于IDependency

Instead of requiring callers to supply an instance, it gives callers an option to define it via a property.

它不要求调用方提供实例,而是给调用方一个通过属性定义实例的选项。

In case a caller doesn’t set the property, Consumer uses Local Default as its default implementation.

如果调用者没有设置属性,使用者将使用本地默认值(Local Default)作为其默认实现。

属性注入的工作原理(How Property Injection works)

使用依赖项(Dependency)的类必须公开依赖项类型的公共可写属性。在基本实现中,这可以像下面的清单一样简单。

清单4.17 属性注入

public class Consumer
{
    public IDependency Dependency { get; set; }
}

Consumer依赖于IDependency。客户机可以通过设置Dependency属性来提供IDependency的实现。

注意 与构造函数注入(Constructor Injection)不同,您不能将Dependency属性的backing字段标记为readonly,因为您允许调用方在使用者生命周期内的任何给定时间修改该属性。

依赖类的其他成员可以使用注入的Dependency来执行其职责,如下所示:

public void DoSomething()
{
    this.Dependency.DoStuff();
}

不幸的是,这种执行是脆弱的。这是因为Dependency属性不能保证返回IDependency的实例。如果Dependency属性的值为null,这样的代码将引发NullReferenceException

var instance = new Consumer();
instance.DoSomething();  <---  此调用导致异常,因为我们忘记了设置instance.Dependency。

这个问题可以通过让构造函数在属性上设置一个默认实例,并在属性的setter中设置一个适当的防御性语句(Guard Clause)来解决。如果客户机在类的生命周期中间切换依赖关系,则会出现另一个复杂情况:

var instance = new Consumer();

instance.Dependency = new SomeImplementation(); <--- 使用有效的实现设置Dependency属性

instance.DoSomething();

instance.Dependency = new SomeOtherImplementation(); <-- 在课程生命周期的中间更改Dependency属性。 这可能会给消费者带来问题。

instance.DoSomething();

这可以通过引入一个只允许客户机在初始化期间设置依赖关系的内部标志来解决。

第4.4.4节中的示例说明了如何处理这些复杂问题。但在此之前,我们想解释一下什么时候使用属性注入(Property Injection)是合适的。

何时使用属性注入(When to use Property Injection)

属性注入(Property Injection)只应在您正在开发的类具有良好的本地默认值(Local Default)时使用,并且您仍然希望使调用方能够提供类的依赖关系的不同实现。需要注意的是,当依赖项是可选的时,最好使用属性注入(Property Injection)。如果需要依赖关系,构造函数注入(Constructor Injection)总是一个更好的选择。

在第1章中,我们讨论了编写松散耦合(Loose Coupling)代码的原因,从而将模块彼此隔离开来。但松散耦合(Loose Coupling)也可以应用于单个模块中的类,并取得了巨大成功。这通常是通过在模块中引入抽象并让模块中的类通过抽象进行通信来实现的,而不是彼此紧密耦合(tightly coupled)。在模块边界内应用松散耦合(Loose Coupling)的主要原因是为了扩展性和易于测试而打开类。

注意 为可扩展性打开一个类的概念是由开放/封闭原则(Open/Closed Principle)捕获的,简单地说,该原则声明一个类应该为可扩展性而打开,但是为修改而关闭。当您按照开放/封闭原则(Open/Closed Principle)实现类时,您可能会想到一个本地默认值,但是您仍然为客户机提供了一种通过用其他内容替换依赖项来扩展类的方法。

开放/封闭原则(Open/Closed Principle)

符合开放/封闭原则(Open/Closed Principle)的软件实体(类、模块、函数等)有两个主要属性:

  • 对扩展开放。这意味着您可以更改或扩展此类实体的行为。假设您的团队拥有整个应用程序,这条语句本身就有点乏味,因为您总是可以更改系统某个部分的行为。你转到它的源代码,然后修改它。然而,这个属性在下一个属性的上下文中变得有趣。
  • 对封闭修改。这意味着在扩展系统时,必须能够在不接触任何现有源代码的情况下进行扩展。这看起来很奇怪;如果你不能改变系统的源代码,你怎么能改变它呢?

DI为这个明显的冲突提供了一个重要的答案。它允许您替换或拦截类来添加或更改行为,而使用类及其依赖项都不知道这一点。开放/封闭原则(Open/Closed Principle)将您推向一种设计,在这种设计中,可以通过创建一个或多个新类或模块来解决每个新特性请求,而不必接触任何现有类或模块。

当您能够在不接触现有部分的情况下向系统添加新的功能性和非功能性需求时,这意味着手头的问题与系统的其他部分是隔离的。这将导致代码更易于理解和测试,因此也更易于维护。也就是说,虽然能够扩展一个系统而不必更改任何现有的代码是一个值得努力的理想,但这是一个无法实现的理想。在某些情况下,您必须更改系统的现有部分。

作为一名开发人员,您的工作是找出应用程序中最有可能发生的更改。基于对特定应用程序或系统如何发展的理解,您应该以最大化可维护性的方式对其进行建模。实现这一理想的一个重要方面是防止系统的全面变化定期发生。

使用抽象类是本书的主要主题之一,还有更多的内容。我们将在第9章和第10章中探讨一些技术,这些技术可以帮助您使应用程序打开以进行扩展,但关闭以进行修改。

开放/封闭原则与DRY原理密切相关。

提示 有时您只想提供一个扩展点,而将本地默认值(Local Default)设置保留为no-op。在这种情况下,您可以使用空对象模式(Null Object pattern)来实现本地默认设置。

到目前为止,我们还没有向您展示任何真正的属性注入(Property Injection)示例,因为这种模式的适用性比较有限,特别是在应用程序开发的上下文中。表4.3总结了其优缺点。

表4.3 属性注入(Property Injection)优缺点

优点 缺点
易于理解 实施起来并不简单
适用范围有限
仅适用于可重用库
导致时间耦合

属性注入(Property Injection)的主要优点是易于理解。当人们决定采用DI时,我们经常看到这种模式作为第一次尝试。

不过,外表可能具有欺骗性,属性注入(Property Injection)也充满了困难。以健壮的方式实现它是一个挑战。由于前面讨论的时间耦合问题,客户机可能会忘记提供依赖关系。此外,如果客户机在类的生存期中间尝试更改依赖关系,会发生什么情况?这可能会导致不一致或意外的行为,因此您可能希望保护自己免受该事件的影响。

尽管有缺点,但在构建可重用库时使用属性注入(Property Injection)是有意义的。它允许组件定义合理的默认值,这简化了使用库的API的工作。

注意 另一方面,在构建应用程序时,我们从不使用属性注入,您应该谨慎地这样做。即使您可能有一个依赖项的外部默认值(Foreign Default),构造函数注入(Constructor Injection)仍然为您提供了一个更好的选择。构造函数注入(Constructor Injection)更简单、更健壮。您可能认为需要外部默认值(Foreign Default)来解决循环依赖(cyclic Dependency),但这是一种代码味道(code smell),我们将在第6章中解释。

在开发应用程序时,可以将类连接到组合根(Composition Root)中。构造函数注入(Constructor Injection)可以防止您忘记提供依赖关系。即使在存在本地默认值(Local Default)的情况下,这样的实例也可以由组合根(Composition Root)提供给构造函数。这简化了类,并允许组合根(Composition Root)控制所有使用者获得的值。这甚至可能是空对象实现。

提示 防止使用属性注入(Property Injection)作为解决构造函数过度注入的方法。具有许多依赖项的类是一种代码味道,属性注入不会降低类的复杂性。我们将在第6.1节讨论构造函数过度注入。

良好的本地默认值的存在在一定程度上取决于模块的粒度。BCL作为一个相当大的包提供;只要默认值保持在BCL内,就可以认为它也是本地的。在下一节中,我们将简要讨论这个问题。

属性注入的已知用途(Known uses of Property Injection)

.NET BCL中,属性注入(Property Injection)比构造函数注入(Constructor Injection)更为常见,这可能是因为在许多地方都定义了良好的本地默认值,也因为这简化了大多数类的默认实例化。例如,System.ComponentModel.IComponent具有允许您定义ISite实例的可写站点属性。这主要用于设计时场景(例如,Visual Studio))中,以在组件托管在设计器中时更改或增强组件。有了这个BCL示例作为开胃菜,让我们转到一个使用和实现属性注入(Property Injection)的更重要的示例。

示例:将属性注入作为可重用库的可扩展性模型

本章前面的示例扩展了上一章的示例应用程序。 尽管我们可以使用示例应用程序向您展示属性注入的示例,但这会产生误导,因为在构建应用程序时,属性注入很难适应。 构造函数注入(Constructor Injection)几乎总是一个更好的选择。相反,我们想向您展示一个可重用库的示例。 在这种情况下,我们正在查看来自Simple Injector的一些代码。

Simple Injector是第4部分中讨论的DI容器(DI Container)之一。它可以帮助您构建应用程序的对象图。 第14章将对Simple Injector进行广泛的讨论,因此在这里我们不会对其进行详细介绍。 从属性注入的角度来看,简单注入器的工作方式并不重要。

作为可重用的库,Simple Injector广泛使用了属性注入(Property Injection)。 可以扩展其许多行为,而完成此行为的方法是提供其行为的默认实现。 Simple Injector公开允许用户更改默认实现的属性。Simple Injector允许替换的行为之一是库如何选择正确的构造函数以进行构造函数注入(Constructor Injection)。

正如我们在4.2节中讨论的那样,您的类应仅具有一个构造函数。 因此,默认情况下,Simple Injector仅允许创建仅具有一个公共构造函数的类。 在任何其他情况下,Simple Injector都会引发异常。 但是,使用简单注入器可以覆盖此行为。 这对于某些狭窄的集成方案可能很有用。为此,Simple Injector定义了IConstructorResolutionBehavior接口。15用户可以定义自定义实现,并且可以通过设置ConstructorResolutionBehavior属性来替换库提供的默认值,如下所示:

var container = new Container();

container.Options.ConstructorResolutionBehavior = new CustomConstructorResolutionBehavior();

容器(Container)是Simple Injector API中的中心外观模式。 它用于指定抽象和实现之间的关系,并构建这些实现的对象图。 该类包括类型为ContainerOptionsOptions属性。 它包括许多属性和方法,这些属性和方法允许更改库的默认行为。 这些属性之一是ConstructorResolutionBehavior。 这是ContainerOptions类的简化版本,带有其ConstructorResolutionBehavior属性:

public class ContainerOptions
{
    IConstructorResolutionBehavior resolutionBehavior =new DefaultConstructorResolutionBehavior();  
    <--使用DefaultConstructorResolutionBehavior本地默认值分配专用的resolutionBehavior字段
        
    public IConstructorResolutionBehavior ConstructorResolutionBehavior
    {
        get
        {
            return this.resolutionBehavior;
        }
        set
        {
            if (value == null)  <--带空检查的防御性条件
                throw new ArgumentNullException("value");
            if (this.Container.HasRegistrations) <---带有经过讨论的内部标志的变体的防御性条件,可确保Popsicle的不变性
            {
                throw new InvalidOperationException(
                    "The ConstructorResolutionBehav" +
                    "ior property cannot be changed" +
                    " after the first registration " +
                    "has been made to the container.";
                    }
                    this.resolutionBehavior = value;  <--将传入的依赖项存储在私有字段中,从而覆盖“本地默认值”
             }
        }
}

只要容器中没有进行任何注册,就可以多次更改ConstructorResolutionBehavior属性。 这很重要,因为在进行注册时,Simple Injector使用指定的ConstructorResolutionBehavior来分析类的构造函数,以验证是否能够构造这种类型。 如果用户能够在注册后更改构造函数解析行为,则可能会影响早期注册的正确性。这是因为,否则,Simple Injector可能最终为该组件使用与其在注册期间被认可为正确的组件不同的构造函数。 这意味着应该重新评估所有先前的注册,或者应防止用户在进行注册后更改其行为。 由于重新评估可能会隐藏性能成本,并且难以实施,因此Simple Injector会实施后一种方法。

与构造函数注入(Constructor Injection)相比,属性注入(Property Injection)更为复杂。 它的原始形式可能看起来很简单(如清单4.19所示),但是如果正确实现,它往往会变得更加复杂。

您可以在可重用的库中使用属性注入(Property Injection),在该库中,依赖关系是可选的,并且具有良好的本地默认值(Local Default)。 如果存在需要依赖的短暂对象,则应使用方法注入(Method Injection)。 在其他情况下,应使用构造函数注入(Constructor Injection)。

这样就完成了本章的最后一个模式。 以下部分提供了简短的回顾,并说明了如何为您的工作选择正确的模式。

选择要使用的模式(Choosing which pattern to use)

本章介绍的模式是DI的核心部分。有了组合根(Composition Root)和适当的混合注入模式,您可以实现Pure DI或使用DI容器(DI Container)。 在应用DI时,需要学习许多细微差别和更详细的细节,但是这些模式涵盖了回答“我如何注入依赖项?”这一问题的核心机制。

这些模式不可互换。 在大多数情况下,您的默认选择应该是构造函数注入(Constructor Injection),但是在某些情况下,其他模式之一可以提供更好的选择。 图4.9显示了一个决策过程,可以帮助您确定合适的模式,但是如果有疑问,请选择构造函数注入(Constructor Injection)。 这种选择绝对不会出错。

图4.9 模式决策过程。 在大多数情况下,应该选择构造器注入,但是在某些情况下,其他DI模式之一更合适。
image

首先要检查的是,依赖关系是您需要的东西还是已经存在但想要与其他协作者交流的东西。在大多数情况下,您可能需要依赖关系。 但是在加载项方案中,您可能需要将当前上下文传达给加载项。 每当依赖关系因操作而异时,方法注入(Method Injection)都是实现的理想选择。

其次,您需要知道哪种类需要依赖关系。如果您要混合运行时数据和同一类中的行为(如您在领域实体中所做的那样),则方法注入(Method Injection)非常适合。 在其他情况下,当您编写应用程序代码时,与编写可重用的库相反,构造函数注入(Constructor Injection)会自动应用。

在编写应用程序代码时,甚至应避免使用本地默认值(Local Default),而应将这些默认值设置在应用程序的一个中心位置—组合根(Composition Root)。 另一方面,在编写可重用的库时,本地默认值(Local Default)是决定因素,因为它可以使依赖项显式分配为可选-如果未指定任何重写实现,则默认值将接管。通过属性注入(Property Injection)可以有效地实现此方案。

构造函数注入(Constructor Injection)应该是DI的默认选择。 与其他任何DI模式相比,它更易于理解并且更易于实现。 您可以仅使用构造函数注入(Constructor Injection)来构建整个应用程序,但是了解其他模式可以在某些情况下非常明智地帮助您明智地选择。
下一章将从相反的方向着手研究DI,并探讨不正确使用DI的方法。

总结

  • 组合根(Composition Root)是应用程序中模块组合在一起的单个逻辑位置。应用程序组件的结构应集中在应用程序的这一单个区域中。
  • 只有启动项目将具有组合根(Composition Root)。
  • 尽管组合根(Composition Root)可以分布在多个类中,但是它们应该在一个模块中。
  • 组合根(Composition Root)直接依赖于系统中的所有其他模块。 与紧密耦合的代码相比,应用组合根(Composition Root)模式的松散耦合(Loose Coupling)的代码降低了模块,子系统和层之间的依赖关系的总数。
  • 即使您可以将组合根(Composition Root)与UI或表示层放置在同一程序集中,但组合根(Composition Root)也不是这些层的一部分。 程序集是部署工件,而层是逻辑工件。
  • 在使用DI容器(DI Container)的情况下,只能从组合根(Composition Root)引用它。 所有其他模块都应忽略DI容器(DI Container)的存在。
  • 在组合根(Composition Root)目录之外使用DI容器(DI Container)会导致服务定位器反模式(Service Locator anti-pattern)。
  • 在设计良好的系统中,使用DI容器(DI Container)组成大型对象图的性能开销通常不是问题。
  • 组合根(Composition Root)应该是整个应用程序中唯一了解构造的对象图的结构的地方。 这意味着应用程序代码无法将依赖关系传递给与当前操作并行运行的其他线程,因为使用者无法知道这样做是否安全。 相反,当拆分并发操作时,组合根(Composition Root)的工作是为每个并发操作创建一个新的对象图。
  • 构造函数注入(Constructor Injection)是通过将所需的依赖项指定为类的构造函数的参数来静态定义所需依赖项列表的行为。
  • 用于构造函数注入(Constructor Injection)的构造函数只应应用保护子句并存储接收依赖项。其他逻辑应保留在构造函数之外。 这使构建对象图变得快速而可靠。
  • 构造函数注入(Constructor Injection)应该是DI的默认选择,因为它最可靠,最容易正确应用。
  • 需要依赖项时,构造函数注入(Constructor Injection)非常适合。需要特别注意的是,依赖关系几乎永远都不是可选的。可选的依赖项(Optional Dependencies)使该组件具有null检查的复杂性。在无合理实现的情况下,应在组合根(Composition Root)内部注入空对象(Null Object)实现。
  • 应用程序组件应该只有一个构造函数。重载的构造函数导致模棱两可。对于可重用的类库(例如BCL),通常有多个构造函数是有意义的。 对于应用程序组件,则不是。
  • 方法注入(Method Injection)是传递对方法调用的依赖关系的行为.
  • 如果每个操作的依赖项或依赖项的使用者可能不同,则可以应用方法注入(Method Injection)。 这对于需要将某些运行时上下文传递到外接程序的公共API的外接程序场景或以数据为中心的对象需要某个操作的依赖关系(如域实体通常是这样)的外接程序场景很有用。
  • 方法注入(Method Injection)不适合在组合根(Composition Root)内部使用,因为它会导致时间耦合(Temporal Coupling)。
  • 通过方法注入(Method Injection)接受依赖关系的方法不应存储该依赖关系。 这会导致时间耦合(Temporal Coupling),专属依赖关系或隐藏的副作用。 依赖项仅应与构造函数注入(Constructor Injection)和属性注入(Property Injection)一起存储。
  • 本地默认值(Local Default)是源自同一模块或层的依赖关系的默认实现。
  • 通过属性注入(Property Injection),可以打开类库以进行扩展,因为它允许调用者更改该库的默认行为。
  • 属性注入(Property Injection)可能看起来很简单,但是如果正确实现,与构造函数注入(Constructor Injection)相比,它会变得更加复杂.
  • 除了可重用库中的可选依赖项(optional Dependencies)之外,属性注入(Property Injection)的适用性受到限制,并且构造函数注入(Constructor Injection)通常更适合。 构造函数注入(Constructor Injection)简化了类,允许组合根(Composition Root)控制所有使用者获得的价值,并防止时间耦合(Temporal Coupling)。
posted @ 2022-08-27 19:10  F(x)_King  阅读(180)  评论(0编辑  收藏  举报