七、深入研究依赖注入

在本章中,我们将探讨 ASP.NET Core 5 依赖项注入系统,以及如何有效地利用它、它的限制和它的功能。我们还将介绍如何使用依赖项注入组合对象、控制反转的含义以及如何使用内置的依赖项注入容器。我们还将介绍依赖注入背后的概念。我们还将使用依赖项注入重新讨论前三个 GoF 设计模式。本章对您进入现代应用设计的旅程至关重要。

本章将介绍以下主题:

  • 什么是依赖注入?
  • 重新审视战略模式
  • 重温单身模式
  • 了解服务定位器模式
  • 重温工厂模式

什么是依赖注入?

依赖注入DI是应用控制反转IoC原理的一种方式。我们可以把 IoC 看作是依赖倒置原理的一个更广泛的版本(实数形式的 D)。

DI 背后的思想是将依赖项的创建从对象本身移动到程序的入口点(合成根)。通过这种方式,我们可以将依赖项的管理委托给一个 IoC 容器(也称为 DI 容器),该容器完成繁重的工作。

例如,对象A不应该知道它正在使用的对象BA应该使用B实现的接口I,并且B应该在运行时解析和注入。

让我们分解一下:

  • 对象A应该依赖于接口I而不是具体化B
  • 注入到A中的实例B应该在运行时由 IoC 容器解析。
  • A不应意识到B的存在。
  • A不应控制B的寿命。

为了全力打造乐高®,我们可以将 IoC 视为绘制一个城堡的计划:您绘制、制作或购买积木,然后按下开始按钮,积木按照您的计划自行组装。按照这一逻辑,您可以创建一个侧面画有独角兽的新 4x4 块,更新您的计划,然后按下重新启动按钮,用插入的新块重建城堡,替换旧块,而不会影响城堡的结构完整性。通过遵守 4x4 区块合同,所有内容都应该是可更新的,而不会影响城堡的其他部分。

按照这个想法,如果我们需要一个接一个地管理每个乐高积木,它会变得非常复杂,非常快!因此,在一个项目中手动管理所有依赖项将是非常繁琐和容易出错的,即使是在最小的程序中也是如此。为了帮助我们解决这个问题,IoC 容器开始发挥作用。

笔记

DI 容器或 IoC 容器是同一件事——它们只是人们使用的不同词汇。在现实生活中,我可以交替使用这两种方法,但我会尽最大努力坚持在本书中使用 IoC 容器。

我选择术语“IoC 容器”是因为它似乎比“DI 容器”更准确。IoC 是概念(原则),而 DI 是一种反转控制流的方法(应用 IoC)。例如,您可以通过使用容器在运行时注入依赖项(执行 DI)来应用 IoC 原则(反转流)。

IoC 容器的作用是为您管理对象。您配置它们,然后当您请求一些抽象时,相关的实现由容器解决。此外,依赖项的生存期也由容器管理,让您的类只做一件事:设计它们的工作,而不考虑它们的依赖项、实现或生存期!

归根结底,IoC 容器是一个 DI 框架,它为您进行自动布线。我们可以看到依赖注入如下所示:

  1. 依赖项的使用者陈述其对一个或多个依赖项的需求。
  2. IoC 容器在创建使用者时注入该依赖关系(实现),在运行时满足其需求。

接下来,我们将探索不同的 DI 领域:在哪里配置容器、可用的选项,以及一种通用的面向对象技术(现在是一种代码味道)。

成分根

DI 背后的第一个概念之一是合成根。组合根是您告诉容器您的依赖关系的地方:您组合依赖关系树的地方。合成根应尽可能接近程序的起点。

在 ASP.NET Core 5 中,它位于Program.csStartup.cs或两者中。

笔记

作为 LEGO®的类比,构图根可以是您绘制计划的纸页。

ASP.NET Core 5 应用的起点是Program类。在这里,您可以使用IHostBuilder接口上提供的ConfigureServices扩展方法来配置您的服务,如下所示:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices(services =>
            {
                // This could be the composition root
            })
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            });
}

使用IHostBuilder配置服务在某些场景中非常有用,包括配置测试主机或小型微服务。

但是,您通常希望在Program类中构建主机并运行程序,同时将 ASP.NET Core 5 的组成委托给Startup类,后者是距离入口点第二近的位置。该类通常在创建新项目时为您生成。也就是说,这两个位置都是有效的合成根:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // This could be the composition root 
    }
    // ...
}

Startup类是大多数应用的合成根,您最有可能在这里配置 DI 容器和 ASP.NET Core 5 管道。也可以同时使用这两种方法,如下所示:

Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddSingleton<Dependency1>();
    })
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
    });
//...
public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<Dependency2>();
    services.AddSingleton<Dependency3>();
}

笔记

Startup类中,您可以为每个环境(DevelopmentStagingProduction创建特定的方法来有效地组合应用。您甚至可以创建多个启动类。有关此强大功能的更多信息,请参阅官方文档。

务必记住,您的程序的合成应该在合成根目录中完成。这样就不需要在你的代码库中散布那些讨厌的new关键字,也不需要承担它们带来的所有责任。它将应用的组成集中到该位置(即,创建组装 LEGO®积木的计划)。

扩展 IServiceCollection

正如我们刚才看到的,您应该在组合根目录中注册依赖项,但您仍然可以组织您的注册代码。例如,您可以将应用的合成拆分为多个方法或类,然后从合成根调用它们。您还可以使用自动发现机制来自动注册某些服务;我们将在后面的章节中使用这样做的包。

笔记

关键部分仍然是集中程序组成。

例如,ASP.NET Core 5 和其他流行库的大多数功能都提供了一个或多个Add[Feature name]()扩展方法来管理其依赖项的注册,允许您通过一个方法调用注册“依赖项包”。这对于将程序组合组织成更小、更具凝聚力的单元(如按功能)非常有用。

旁注

只要特性保持内聚性,它就具有适当的大小。如果您的功能变得太大,做了太多的事情,或者开始与其他功能共享依赖项,那么在失去对它的控制之前,可能是重新设计的时候了。这通常是非期望耦合的良好指示器。

通过使用扩展方法,它相当容易实现。根据经验,您应该执行以下操作:

  1. 创建一个名为[subject]Extensions的静态类。
  2. 根据微软的建议,在Microsoft.Extensions.DependencyInjection名称空间中创建该类(与IServiceCollection相同)。
  3. 从那里,创建您的IServiceCollection扩展方法。除非您需要归还其他物品,请务必归还扩展的IServiceCollection;这允许链接方法调用。

例如,如果我的特性被命名为Demo Feature,我会编写以下扩展方法:

using CompositionRoot.DemoFeature;
namespace Microsoft.Extensions.DependencyInjection
{
    public static class DemoFeatureExtensions
    {
        public static IServiceCollection AddDemoFeature(this IServiceCollection services)
        {
            return services
                .AddSingleton<MyFeature>()
                .AddSingleton<IMyFeatureDependency, MyFeatureDependency>()
            ;
        }
    }
}

然后,要使用它,我们可以在合成根中输入以下内容:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDemoFeature();
}

如果您不熟悉扩展方法,它们可以方便地扩展现有代码(我们刚刚做的)。例如,您可以构建一个复杂的库和一组易于使用的扩展方法,允许消费者轻松地学习和使用您的库,同时最大限度地保留高级选项和定制机会;想想 ASP.NET Core 5 MVC 或System.Linq

对象生命周期

我已经谈过几次了:没有了new;这段时间结束了!从现在起,DI 容器应该为我们完成与实例化和管理对象相关的大部分工作。

然而,在尝试这一点之前,我们需要讨论最后一件事:对象生存期。当您使用new关键字手动创建实例时,您将在该对象上创建一个保持;你知道什么时候创造它们,什么时候毁灭它们。这样就没有机会从外部控制这些对象,增强它们,拦截它们,或者将它们替换为另一个实现。这被称为控制怪胎反模式或代码气味,在代码气味:控制怪胎一节中解释。

在使用 DI 时,您需要忘记控制对象,开始考虑使用依赖项——更明确地说,使用它们的接口。在 ASP.NET Core 5 中,有三种可能的生存期供选择:

从现在起,我们将使用这三个作用域中的一个来管理大多数对象。以下是一些问题,可帮助您选择:

  • 我需要依赖项的单个实例吗?对使用单子寿命。
  • 我是否需要通过 HTTP 请求共享依赖项的单个实例?对使用在范围内的寿命。
  • 我每次都需要一个新的依赖实例吗?对使用瞬态寿命。

如果您需要更复杂的生存期,您可能需要将内置容器交换到第三方容器(请参见使用外部 IoC 容器部分)或在组合根目录中手动创建依赖关系树。

笔记

一种更通用的对象生存期方法是将组件设计为单例。如果你不能,那就选择范围内的。如果范围内的也不可能,则选择瞬态。通过这种方式,可以最大限度地重用实例,降低创建对象的开销,降低将这些对象保留在内存中的内存成本,并降低删除未使用实例所需的垃圾收集量。

例如,长寿命的对象每隔一段时间只被垃圾收集器检查一次,而长寿命的对象通常被扫描和处理。

前面的三个示例有多种变体,但生命周期仍然存在。我们在整本书中使用内置容器及其许多注册方法,因此您应该在最后熟悉它。该系统提供了良好的可发现性,因此您可以使用 IntelliSense 或通过阅读文档来探索各种可能性。

代码气味:控制狂

我们已经说过使用new关键字是一种代码气味甚至是反模式。但是暂时不要禁止new关键字。相反,每次使用它时,都要问问自己,使用new关键字实例化的对象是否是可以由容器管理并注入的依赖项。为了帮助解决这个问题,我从 MarkSeemann 的书中借用了两个术语,即.NET中的依赖注入;名字控制狂也来自那本书。他描述了以下两类依赖关系:

  • 稳定依赖
  • 易失性依赖

稳定依赖项是依赖项,当发布新版本的应用时,它不应破坏应用。他们应该使用确定性算法(输入 X 应该总是产生输出 Y;也就是说,关于 LSP),并且你不应该期望在将来用其他东西来改变它们。我要说的是,大多数数据结构都可以归入这一类:DTO、ViewModels、List<T>等等。当对象属于此类别时,仍然可以使用new关键字实例化对象;这是可以接受的,因为他们不太可能打破任何东西,也不会改变。但是要小心,因为预见依赖关系是否可能改变是非常困难的,甚至是不可能的,因为我们无法确定未来会提供什么。例如,作为.NET 5 一部分的元素可以被视为稳定的依赖项。

易失性依赖项是可以更改的依赖项、可以交换的行为或您可能想要扩展的元素。基本上,您为程序创建的大多数类,如数据访问和业务逻辑类。这些是您不应该再使用new关键字实例化的依赖项。打破实现之间紧密耦合的主要方法是依赖接口。

结束这段插曲:不要再做一个控制狂了,那些日子已经过去了!

提示

当有疑问时,注入依赖项,而不是使用new关键字。

接下来,我们将简要地探讨 ASP.NET Core 扩展点,然后再回顾三种设计模式,但这次是通过依赖注入。

使用外部 IoC 容器

ASP.NET Core 5 提供了一个现成的可扩展内置 IoC 容器。它不是最强大的 IoC 容器,因为它缺少一些高级功能,但它可以为大多数应用完成这项工作。放心吧,如果没有,你可以换另一个。如果您习惯于使用另一个 IoC 容器,并且希望坚持使用它,或者需要缺少高级功能,那么您可能会希望这样做。

从今天起,微软建议首先使用内置容器。如果您不能提前知道您将需要的所有 DI 功能,我将采用以下策略:

  1. 使用内置容器。
  2. 当无法使用它时,请查看您的设计,看看是否可以重新设计功能以使用内置容器。这有助于简化设计,同时有助于长期维护软件。
  3. 如果无法实现您的目标,则将其替换为另一个 IoC 容器。

假设容器支持它,交换就非常简单。您需要更新Startup类的ConfigureServices方法返回IServiceProvider(而不是void,如下所示:

public IServiceProvider ConfigureServices(IServiceCollection services)
{
    // Build and return the custom IServiceProvider here
}

然后,在方法内部,可以构建服务提供者,编写应用,然后返回它。正如我所感觉到的,您还不想实现自己的 IoC 容器(甚至永远都不想实现),不用担心,已经存在多种类型的第三方集成。以下是一个非详尽的列表:

  • 自动传真
  • 德赖奥
  • 优美
  • 光注入
  • 拉马尔
  • 储藏箱
  • 团结一致

一些库扩展默认容器并向其添加功能,这是我们在第 9 章结构模式中探讨的一个选项。

接下来,我们将重新讨论 Strategy 模式,它将成为组成应用和增加系统灵活性的主要工具。

重新审视战略模式

在本节中,我们将利用策略模式组合复杂的对象树,并使用 DI 动态创建这些实例,而无需使用new关键字,从而摆脱控制怪胎,转而编写 DI 就绪代码。

Strategy 模式是一种行为设计模式,我们可以在运行时使用它来组合对象树,从而允许额外的灵活性和对对象行为的控制。使用 Strategy 模式组合对象应该使类更易于测试和维护,并使我们走上一条坚实的道路。

从现在起,我们希望组合对象并将继承量降至最低。我们称之为原则组合优于继承。目标是将依赖项(组合)注入到当前类中,而不是依赖于基类特性(继承)。此外,这允许在外部类(SRP/ISP)中提取行为,然后通过接口(DIP)在一个或多个其他类(组合)中重用。

以下列表介绍了将依赖项注入对象的最常用方法:

  • 构造函数注入
  • 属性注入
  • 方法注入

我们还可以直接要求容器解析依赖关系,即服务定位器(反)模式。我们将在本章后面探讨服务定位器模式。

让我们看看一些理论,然后跳转到代码中,看看 DI 在起作用。

构造函数注入

构造函数注入包括将依赖项注入构造函数,作为参数。这是迄今为止最流行和最受欢迎的技术。构造函数注入有助于注入所需依赖项;您可以添加空检查以确保,也被称为保护条款(请参阅向 HomeController添加保护条款一节)。

财产注入

内置 IoC 容器不支持属性注入开箱即用。其概念是将可选依赖项注入属性。大多数情况下,您希望避免这样做,因此 ASP.NETCore 将此项从内置容器中删除也不错。您通常可以通过稍微修改设计来删除属性注入需求,从而获得更好的设计。如果无法避免使用属性注入,则必须使用第三方容器。

然而,从高层次的角度来看,容器将执行以下操作:

  1. 创建类的新实例,并将所有必需的依赖项注入构造函数。
  2. 通过扫描属性(可以是属性、上下文绑定或其他内容)查找扩展点。
  3. 对于每个扩展点,注入(设置)一个依赖项,保持未配置的属性不变,从而定义可选依赖项。

对于之前关于缺乏支持的声明,有几个例外:

  • Razor 组件(Blazor)通过使用[Inject]属性支持属性注入。
  • Razor 包含@inject指令,该指令生成一个属性来保存依赖项(ASP.NET Core 管理注入依赖项)。

我们不能称之为属性注入本身,因为它们不是可选的,而是必需的,@inject指令更多的是生成代码,而不是执行 DI。它们更多的是内部解决方案,而不是“不动产”注入。也就是说,这与.NET5 的属性注入非常接近。

提示

我建议以构造函数注入为目标。没有财产注入应该不会给你带来任何问题。

方法注射

ASP.NET Core 仅在少数位置支持方法注入,如控制器的动作(方法)Startup类、中间件的InvokeInvokeAsync方法。如果您不做一些工作,就不能在类中自由地使用方法注入。

方法注入还用于将可选依赖项注入类中。我们还可以在运行时使用空检查或任何其他必需的逻辑来验证它们。

提示

我建议尽可能瞄准构造函数注入。只有当方法注入是唯一的方法或者它添加了一些东西时,才依赖它。例如,在控制器中,在唯一需要临时服务的操作(而不是构造函数)中注入临时服务可以节省大量无用的对象实例化,并通过这样做提高性能(更少的实例化和更少的垃圾收集)。

项目:战略

在 Strategy 项目中,我们使用策略模式和构造函数注入向HomeController类添加(组合)一个IHomeService依赖项。

目标是将类型为IHomeService的依赖项注入HomeController类。然后将视图模型发送到视图以呈现页面。

服务是这样的:

namespace Strategy.Services
{
    public interface IHomeService
    {
        IEnumerable<string> GetHomePageData();
    }
    public class HomeService : IHomeService
    {
        public IEnumerable<string> GetHomePageData()
        {
            yield return "Lorem";
            yield return "ipsum";
            yield return "dolor";
            yield return "sit";
            yield return "amet";
        }
    }
}

IHomeService接口是我们希望HomeController类具有的依赖项。HomeService类是我们在实例化HomeController时想要注入的实现,它反转了依赖流。

为此,我们使用构造函数注入将注入到控制器中。在文本上,我们做了以下工作:

  1. HomeController类中创建一个私有IHomeService字段。
  2. 创建一个参数类型为IHomeServiceHomeController构造函数。
  3. 将参数指定给字段。

在代码中,它如下所示:

using Strategy.Services;
namespace Strategy.Controllers
{
    public class HomeController : Controller
    {
        private readonly IHomeService _homeService;
        public HomeController(IHomeService homeService)
        {
            _homeService = homeService;
        }
        // ...
    }
}

使用private readonly字段有两个好处:

  • 它们是private,因此您不会将依赖项暴露在类之外(封装)。
  • 它们是readonly,所以只能设置一次。在构造函数注入的情况下,这确保了由private字段引用的注入依赖项不会被类的其他部分更改。

如果我们现在运行应用,我们会得到以下错误:

InvalidOperationException: Unable to resolve service for type 'Strategy.Services.IHomeService' while attempting to activate 'Strategy.Controllers.HomeController'.

这个错误告诉我们,我们忘记了一些重要的事情:告诉容器依赖关系。

为此,我们需要将IHomeService的注入映射到HomeService的实例。由于类的性质,我们可以安全地使用单例生存期(一个实例)。使用提供的扩展方法,在 composition 根目录中,我们只需要添加以下行:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHomeService, HomeService>();
    //...
}

现在,如果我们重新运行应用,主页应该加载。这告诉 ASP.NET 在类依赖于IHomeService接口时注入HomeService实例。

我们刚刚使用 ASP.NET Core 5 完成了构造函数注入的第一个实现——就这么简单。

要回顾构造函数注入,我们需要执行以下操作:

  1. 创建依赖项及其接口。
  2. 通过构造函数将该依赖项注入到另一个类中。
  3. Create a binding that tells the container how to handle the dependency.

    笔记

    我们也可以直接注入类,但在您掌握了坚实的原则之前,我建议您坚持注入接口。

添加视图模型

现在我们已经注入了包含要在HomeController类中显示的数据的服务,我们需要显示它。为了实现这一点,我们决定使用视图模型模式。视图模型的目标是创建以视图为中心的模型,然后使用该模型渲染该视图。

以下是我们需要做的:

  1. 创建一个视图模型类(HomePageViewModel
  2. 更新Home/Index视图以使用视图模型并显示其包含的信息。
  3. 创建HomePageViewModel实例并从控制器发送到视图。

HomePageViewModel类公开了SomeData属性,并期望在实例化时注入数据;代码如下所示:

namespace Strategy.Models
{
    public class HomePageViewModel
    {
        public IEnumerable<string> SomeData { get; }
        public HomePageViewModel(IEnumerable<string> someData)
        {
            SomeData = someData;
        }
    }
}

这是构造函数注入的另一个例子。

然后,经过几次更新,Views/Home/Index.cshtml视图如下所示:

@model HomePageViewModel
@{
    ViewData["Title"] = "Home Page";
}
<div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Here are your data:</p>
    <ul class="list-group">
    @foreach (var item in Model.SomeData)
    {
        <li class="list-group-item">@item</li>
    }
    </ul>
</div>

现在我们需要将HomePageViewModel的一个实例传递给视图。我们在Index行动中就是这样做的:

public IActionResult Index()
{
    var data = _homeService.GetHomePageData();
    var viewModel = new HomePageViewModel(data);
    return View(viewModel);
}

在该代码中,我们使用_homeService字段通过IHomeService接口检索数据。重要的是要注意,在这一点上,控制器不知道实现;它仅取决于合同(接口)。然后我们使用该数据创建HomePageViewModel类。最后,我们将HomePageViewModel的实例发送到视图进行渲染。

笔记

您可能已经注意到,我在这里使用了new关键字。在这种情况下,我发现在控制器的操作中实例化视图模型是可以接受的。然而,我们可以使用方法注入或任何其他技术来帮助创建对象,例如工厂。

向 HomeController 添加保护子句

我们已经说明了构造函数注入是可靠的,用于注入所需的依赖项。然而,上一个代码示例中有一件事让我感到困扰:没有任何东西可以保证homeService不是空的。

我们可以在Index方法中检查空值,如下所示:

public IActionResult Index()
{
    var data = _homeService?.GetHomePageData();
    var viewModel = new HomePageViewModel(data);
    return View(viewModel);
}

但随着控制器的增长,我们可能会在多个位置多次为该依赖项编写空检查。然后我们应该在视图中执行相同的空检查。否则,我们将循环一个null值,这是不好的。

为了避免逻辑的重复,以及同时可能出现的错误数量,我们可以添加一个保护子句

guard 子句的作用正如其名称所暗示的那样:它保护无效值。大多数情况下,它防止空。当您将一个null依赖项传递给一个对象时,测试该参数的 guard 子句应该抛出一个ArgumentNullException

通过使用 C#7 中的throw表达式,我们可以简单地写下:

public HomeController(IHomeService homeService)
{
    _homeService = homeService ?? throw new ArgumentNullException(nameof(homeService));
}

homeServicenull时抛出ArgumentNullException;否则,将homeService参数值赋给_homeService字段。

重要提示

如果内置容器在类的实例化过程中能够满足所有依赖项,则会自动抛出异常(如HomeController。也就是说,这并不意味着所有第三方容器的行为都是一样的。此外,这并不能防止您将null传递给手动实例化的实例(即使我们应该使用 DI,也不意味着它不会发生)。作为一个优先事项,我喜欢添加它们,但它们不是必需的。

抛出表达式(C#7)

这个特性允许我们将throw语句用作表达式,从而使我们能够在 null 合并运算符的右侧抛出异常。

throw表达式之前,写 guard 子句的好方法如下:

public HomeController(IHomeService homeService)
{
    if (homeService == null) 
    { 
        throw new ArgumentNullException (nameof(homeService));
    }
    _homeService = homeService;
}

首先,我们检查null,如果homeServicenull,我们抛出一个ArgumentNullException;否则,我们将值指定给字段。

现在,使用throw表达式,我们可以编写前面的代码,这里再次概述:

public HomeController(IHomeService homeService)
{
    _homeService = homeService ?? throw new ArgumentNullException(nameof(homeService));
}

??运算符是一个二进制运算符。在result = left ?? right块中,??操作符表示如果left值为null,则使用right值。如果left值为not null,则使用left值。

过去,我们不能从右侧抛出异常(它是一个语句),但现在我们可以(它是一个表达式)。

在其他情况下,如果您还不熟悉空合并运算符,我们也可以使用??如下:

public string ValueOrDefault(string value, string defaultValue)
{
    return value ?? defaultValue;
}

valuenull时,该方法返回defaultValue;否则返回value——非常方便。

向 HomePageViewModel 添加保护子句

现在让我们在HomePageViewModel类中添加一个 guard 子句作为:

public HomePageViewModel(IEnumerable<string> someData)
{
    SomeData = someData ?? throw new
 ArgumentNullException(nameof(someData));
}

瞧!我们现在拥有了呈现主页所需的一切。更重要的是,我们在不直接将HomeController类与HomeService耦合的情况下实现了这一点。相反,我们只依赖于IHomeService接口,一个合同。通过将合成集中到合成根目录中,我们可以通过交换Startup类中的IHomeService实现来更改生成的主页,而不会影响控制器或视图。

我邀请您创建另一个实现了IHomeService的类,并将Startup类中的映射从HomeService更改为新的类,看看更改主页列表有多容易。更进一步说,您可以将您的实现连接到数据库、Azure 表、Redis、JSON 文件或您可以想到的任何其他数据源。

接下来,我们将重新讨论一个现在是反模式的设计模式,同时探索取代它的单例生命周期。

重温单身模式

单身模式已经过时,违背了坚实的原则,我们用一生来取代它,正如我们已经看到的那样。本节将探讨该生存期并重新创建良好的旧应用状态,它只不过是一个单例范围的字典。

这里我们将探讨两个例子;一个是关于应用状态的,以防您想知道该功能消失在哪里。然后,Wishlist 项目还使用单例生存期来提供应用级功能。还有一些单元测试可以利用可测试性并允许安全重构。

申请状态

如果您使用.NET Framework 编写了 ASP.NET,或者使用 VBScript 编写了“好”的经典 ASP,您可能还记得应用的状态。如果没有,则应用状态是一个键/值字典,允许您在应用中全局存储数据,并在所有会话和请求之间共享。这是 ASP 一直拥有的东西之一,而其他语言,如 PHP,没有(或不容易允许)。

例如,我记得用经典的 ASP/VBScript 设计了一个通用的可重用类型的购物车系统。VBScript 不是强类型语言,面向对象的能力有限。购物车字段和类型是在应用级别定义的(每个应用一次),然后每个用户都有自己的“实例”,其中包含其“私人购物车”中的产品(每个会话创建一次)。

在 ASP.NET Core 5 中,没有更多的Application字典。为了实现相同的目标,可以使用静态类或静态成员,这不是最好的方法;请记住,全局对象(static使您的应用更难测试,灵活性也更低。我们还可以使用单例模式或创建环境上下文,这将允许我们创建对象的应用级实例。我们甚至可以将其与工厂混合,以创建最终用户购物车,但我们不会;这些也不是最好的解决方案。

另一种方法是使用 ASP.NET Core 5 缓存机制之一,内存缓存或分布式缓存,但这是一种延伸。

我们也可以保存数据库中的所有内容,以便在访问之间持久保存购物车,但这与应用状态无关,需要用户帐户,因此我们也不会这样做。

我们可以使用 cookies、本地存储或任何其他现代机制在客户端保存购物车,以将数据保存到用户的计算机上。然而,与使用数据库相比,我们可以从应用状态中获得更多信息。

对于大多数需要类似于应用状态的特性的情况,最好的方法是创建一个标准类和一个接口,然后在容器中以单例生存期注册绑定。最后,使用构造函数注入将它注入到需要它的组件中。这样做可以模拟依赖项并更改实现,而不必涉及代码,只需涉及组合根。

笔记

有时,最好的解决方案不是技术复杂的解决方案或面向设计模式的解决方案;最好的解决方案往往是最简单的。更少的代码意味着更少的维护和测试,从而使应用更简单。

项目:应用状态

让我们实现一个模拟应用状态的小程序。API 是一个具有两个实现的单一接口。该程序还通过 HTTP 公开部分 API,允许用户获取或设置与指定密钥关联的值。我们使用单例生存期来确保数据在所有请求之间共享。

界面如下所示:

public interface IApplicationState
{
    TItem Get<TItem>(string key);
    bool Has<TItem>(string key);
    void Set<TItem>(string key, TItem value);
}

我们可以获取与键关联的值,将值与键(set)关联,并验证键是否存在。

Startup类包含负责处理 HTTP 请求的代码。它不是使用 MVC,而是使用原始请求管理。我们对所有这些代码都不感兴趣,但如果您想尝试它,我邀请您运行它。这两个实现可以通过注释或取消注释Startup.cs文件的第一行#define USE_MEMORY_CACHE来交换,这改变了ConfigureServices方法中编译代码的方式:

    public void ConfigureServices(IServiceCollection services)
    {
#if USE_MEMORY_CACHE
        services.AddMemoryCache();
        services.AddSingleton<IApplicationState, ApplicationMemoryCache>();
#else
        services.AddSingleton<IApplicationState, ApplicationDictionary>();
#endif
    }

第一个实现使用内存缓存系统。首先,我觉得向你们展示这一点很有教育意义。第二,我们将缓存系统隐藏在我们的实现后面,这也是有教育意义的。最后,我们需要两个实现,使用缓存系统是一个非常简单的实现。

以下是ApplicationMemoryCache课程:

public class ApplicationMemoryCache : IApplicationState
{
    private readonly IMemoryCache _memoryCache;
    public ApplicationMemoryCache(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache));
    }
    public TItem Get<TItem>(string key)
    {
        return _memoryCache.Get<TItem>(key);
    }
    public bool Has<TItem>(string key)
    {
        return _memoryCache.TryGetValue<TItem>(key, out _);
    }
    public void Set<TItem>(string key, TItem value)
    {
        _memoryCache.Set(key, value);
    }
}

笔记

ApplicationMemoryCache类是IMemoryCache之上的一个薄层,隐藏了实现细节。这种类型的类称为 façade。我们将在第 9 章结构模式中详细介绍立面设计模式。

第二个实现是使用Dictionary<string, object>存储应用状态数据。ApplicationDictionary类几乎和ApplicationMemoryCache一样简单:

public class ApplicationDictionary : IApplicationState
{
    private readonly Dictionary<string, object> _memoryCache = new Dictionary<string, object>();
    public TItem Get<TItem>(string key)
    {
        if (!Has<TItem>(key))
        {
            return default;
        }
        return (TItem)_memoryCache[key];
    }
    public bool Has<TItem>(string key)
    {
        return _memoryCache.ContainsKey(key) && _memoryCache[key] is TItem;
    }
    public void Set<TItem>(string key, TItem value)
    {
        _memoryCache[key] = value;
    }
}

我们现在可以使用两个实现中的任何一个,而不会影响程序的其余部分。这证明了 DI 在依赖关系管理方面的优势。此外,我们从组合根控制依赖项的生存期。

如果我们将IApplicationState接口用于另一个类,比如SomeConsumer,其用法可能类似于以下内容:

namespace ApplicationState
{
    public class SomeConsumer
    {
        private readonly IApplicationState _myApplicationWideService;
        public SomeConsumer(IApplicationState myApplicationWideService)
        {
            _myApplicationWideService = myApplicationWideService ?? throw new ArgumentNullException(nameof(myApplicationWideService));
        }
        public void Execute()
        {
            if (_myApplicationWideService.Has <string>("some-key"))
            {
                var someValue = _myApplicationWideService.Get <string>("some-key");
                // Do something with someValue
            }
            // Do something else like:
            _myApplicationWideService.Set("some-key", "some-value");
            // More logic here
        }
    }
}

在该代码中,SomeConsumer仅依赖于IApplicationState接口,而不依赖于IMemoryCacheDictionary<string, object>。使用 DI 允许我们通过反转依赖项的控制来隐藏实现。它还打破了混凝土之间的直接耦合,针对接口编程(DIP)。

下面是一个图表,说明了我们的应用状态系统,使我们更容易在视觉上注意到它是如何打破耦合的:

Figure 7.1 – DI-oriented diagram representing the application state system

图 7.1–表示应用状态系统的面向 DI 的图

从这个示例中,让我们记住,单例生存期允许我们在请求之间重用对象,并在应用范围内共享它们。此外,在接口后面隐藏实现细节可以提高设计的灵活性。

默认文字表达式(C#7.1)

在上一个示例中,我们以 C#7.1 的新方式使用了default操作符。default运算符允许我们将变量初始化为其默认值,通常为null。在过去,我们需要将一个参数传递给默认运算符,如:int someVariable = default(int);,它将相当于int someVariable = 0;,因为0int的默认值。

从 C#7.1 开始,我们可以使用默认的文字表达式,这允许我们执行以下操作:

  • 将变量初始化为其默认值。
  • 设置可选方法参数的默认值。
  • 为方法调用提供默认参数值。
  • return语句或表达式体成员(C#6 和 7 中引入的箭头=>运算符)中返回默认值。

下面是一个涵盖这些用例的示例:

public class DefaultLiteralExpression<T>
{
    public void Main()
    {
        // Initialize a variable to its default value
        T myVariable = default;
        var defaultResult1 = SomeMethod();
        // Provide a default argument value to a method call
        var defaultResult2 = SomeOtherMethod(myVariable, default);
    }
    // Set the default value of an optional method parameter
    public object SomeMethod(T input = default)
    {
        // Return a default value in a return statement 
        return default;
    }
    // Return a default value in an expression-bodied member
    public object SomeOtherMethod(T input, int i) => default;
}

我们在示例中使用了泛型T类型参数,但它可以是任何类型。对于复杂的泛型类型,例如Func<T>Func<T1, T2>或元组,默认的文本表达式变得非常方便。

由于我们将在下一个代码示例后讨论元组,因此我们将不深入讨论元组的更多细节,但下面是一个很好的示例,说明使用默认文字表达式返回元组并默认其三个组件是多么简单:

public (object, string, bool) MethodThatReturnATuple()
{
    return default;
}

项目:愿望清单

让我们进入另一个示例来说明单例生存期和依赖注入的使用。看到 DI 的运行应该有助于理解它,然后利用它来创建可靠的软件。

上下文:该应用是一个站点范围的愿望列表,用户可以在其中添加项目。项目应每 30 秒过期一次。当用户添加现有项目时,系统应增加计数并重置项目的过期时间。这样一来,热门商品在榜单上停留的时间就更长了,从而名列前茅。系统显示时应按计数(最高优先)对项目进行排序。

笔记

30 秒是非常快的,但我确信在运行应用时,您不希望在项目过期之前等待数天。

该程序是一个小型 web API,公开了两个端点:

  • 将项目添加到愿望列表中(POST
  • 阅读愿望清单(GET

愿望列表应该在 singleton 范围内,因此所有用户共享同一个实例。为了简单起见,愿望列表只存储项目的名称。

我们的愿望列表界面如下所示:

public interface IWishList
{
    Task<WishListItem> AddOrRefreshAsync(string itemName);
    Task<IEnumerable<WishListItem>> AllAsync();
}
public class WishListItem
{
    public int Count { get; set; }
    public DateTimeOffset Expiration { get; set; }
    public string Name { get; set; }
}

我们有两个操作,通过使它们异步(返回一个Task<T>),我们可以实现另一个依赖远程系统的版本,例如数据库,而不是内存存储。

笔记

试图预见未来通常不是一个好主意,但设计可等待的 API 通常是一个安全的选择。除此之外,我建议您遵守规范和用例。当您试图解决尚不存在的问题时,通常会编写大量无用的代码,导致额外的不必要的维护和测试时间。

WishListItem类为IWishList合同的部分;这就是模型。

最后一个管道是WishListController,它处理 HTTP 请求:

[Route("/")]
public class WishListController : ControllerBase
{
    private readonly IWishList _wishList;
    public WishListController(IWishList wishList)
    {
        _wishList = wishList ?? throw new ArgumentNullException(nameof(wishList));
    }
    [HttpGet]
    public async Task<IActionResult> GetAsync()
    {
        var items = await _wishList.AllAsync();
        return Ok(items);
    }
    [HttpPost]
    public async Task<IActionResult> PostAsync([FromBody,Required]CreateItem newItem)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        var item = await _wishList.AddOrRefreshAsync(newItem.Name);
        return Created("/", item);
    }
    public class CreateItem
    {
        [Required]
        public string Name { get; set; }
    }
}

控制器主要将逻辑委托给注入的IWishList实现。它还验证了定义为嵌套类的POST模型,以保持其简单性。

为了帮助我们实现类InMemoryWishList类,我们开始编写一些测试来支持我们的规范。由于在测试中很难配置静态成员(还记得 globals 吗?),我们从 ASP.NET Core 内存缓存中借用了一个概念,并创建了一个ISystemClock接口,将静态调用抽象为DateTimeOffset.UtcNow。这样,我们可以在测试中编程UtcNow的值,以创建过期项目。

单元测试文件相当长,因此下面是大纲:

namespace Wishlist
{
    public class InMemoryWishListTest
    {
        // ...
        public class AddOrRefreshAsync : InMemoryWishListTest
        {
            [Fact]
            public async Task Should_create_new_item() { /* ... */ }
            [Fact]
            public async Task Should_increment_Count_of_an_existing_item() { /* ... */ }
            [Fact]
            public async Task Should_set_the_new_Expiration_date_of_an_existing_item() { /* ... */ }
            [Fact]
            public async Task Should_set_the_Count
            _of_expired_items_to_1() { /* ... */ }
            [Fact]
            public async Task Should_remove_expired_items() { /* ... */ }
        }
        public class AllAsync : InMemoryWishListTest
        {
            [Fact]
            public async Task Should_return_items_ordered_by_Count_Descending() { /* ... */ }
            [Fact]
            public async Task Should_not_return_expired_items() { /* ... */ }
        }
        // ...
    }
}

让我们分析一下代码(参见 GitHub 上的源代码:https://net5.link/code 。我们模拟了ISystemClock接口,并根据每个测试用例对其进行编程,以获得所需的结果。我们还编写了一些辅助方法,使测试更易于阅读。这些助手使用元组返回多个值;有关元组的更多信息,请参见元组(C#7+)部分。

既然我们有了那些未通过测试的人,我们就可以开始实施InMemoryWishList。以下是使所有测试通过的实现概要:

public class InMemoryWishList : IWishList
{
    private readonly InMemoryWishListOptions _options; 
    private readonly Dictionary<string, InternalItem> _items;
    public InMemoryWishList(InMemoryWishListOptions options)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _items = new Dictionary<string, InternalItem>();
    }
    public Task<WishListItem> AddOrRefreshAsync(string itemName)
    {
        // ...
    }
    public Task<IEnumerable<WishListItem>> AllAsync()
    {
        // ...
    }
    private class InternalItem
    {
        public int Count { get; set; }
        public DateTimeOffset Expiration { get; set; }
    }
}

笔记

请参考 GitHub 中的完整代码以了解省略的两个方法,因为它们很长:https://net5.link/code

InMemoryWishList在内部使用Dictionary<string, InternalItem>来存储项目。AllAsync方法过滤过期项目,AddOrRefreshAsync方法删除过期项目。该实现不是线程安全的,多个同时AddOrRefreshAsync调用可能会产生错误结果。

代码不一定是所有代码中最优雅的,因此我们可以使用我们的测试来重构它。

运动

我邀请您重构InMemoryWishList类的两个方法;你有测试来帮助你。我自己花了几分钟重构它,并将其保存为InMemoryWishListRefactored。您还可以取消注释InMemoryWishListTest.cs的第一行来测试该类,而不是主类。我的重构只是一种使代码更干净的方法;我把它放在那里是为了给你一些想法。这不是唯一的方法,也不是写那门课的最佳方法(“最佳方法”是主观的)。它也不是线程安全的。

回到依赖注入,使用户之间共享愿望列表的行位于合成根目录中。是的,只有高亮显示的行才能在创建多个实例和单个共享实例之间产生差异:

public void ConfigureServices(IServiceCollection services)
{
    services
        .ConfigureOptions<InMemoryWishListOptions>()
        .AddTransient<IValidateOptions  <InMemoryWishListOptions>, InMemoryWishListOptions>()
        .AddSingleton(serviceProvider => serviceProvider.GetRequiredService<IOptions <InMemoryWishListOptions>>().Value)
    ;
    services.AddSingleton<IWishList, InMemoryWishList>();
    services.AddControllers();
}

通过将生存期设置为 singleton,您可以打开多个浏览器并共享愿望列表。对于POSTAPI,我建议使用 Postman 或任何其他比浏览器更合适的工具。从控制台,您可以使用curlInvoke-WebRequest,具体取决于您的操作系统。GitHub 存储库中有一些说明,还有一个指向 Postman 集合的链接,该集合包含测试 Wishlist API 的 HTTP 请求。

就这样!所有这些代码都是为了演示一行代码可以做什么,我们有一个工作程序,尽管它很小。

如果您想知道IConfigureOptionsIValidateOptionsIOptions来自何方,我们将在下一章更深入地介绍期权模式。

接下来,我们将探讨元组,以防您不熟悉该 C#特性。

元组(C#7+)

元组是一种类型,允许从方法返回多个值,或在变量中存储多个值,而无需声明类型和使用dynamic类型。自 C#7 以来,元组支持有了很大的改进。

笔记

在某些情况下,使用dynamic对象是可以的,但请注意,它可能会降低性能并增加由于缺少类型而引发的运行时异常数。编译时错误可以立即修复,而无需等待它们在运行时出现,或者更糟的是,由用户报告。此外,dynamic对象带来了有限的工具支持,使得发现对象可以做什么变得更加困难,并且更容易出错(没有类型可以自动完成)。

C#语言添加了关于元组的语法糖,使代码更清晰、更易于阅读。微软称为轻量级语法

如果您以前使用过Tuple类型,您知道Tuple成员是通过Item1Item2ItemN字段访问的。这种较新的语法允许我们从代码库中删除这些字段,并用用户定义的名称替换它们。如果您从未听说过元组,我们将立即探索它们。

让我们直接跳到几个样本中,编码为 xUnit 测试。第一个演示了如何创建一个未命名的元组,并使用前面提到的Item1Item2ItemN访问其字段:

[Fact]
public void Unnamed()
{
    var unnamed = ("some", "value", 322);
    Assert.Equal("some", unnamed.Item1);
    Assert.Equal("value", unnamed.Item2);
    Assert.Equal(322, unnamed.Item3);
}

然后,我们可以创建一个命名元组–当您不喜欢 1、2、3 字段时,它非常有用:

[Fact]
public void Named()
{
    var named = (name: "Foo", age: 23);
    Assert.Equal("Foo", named.name);
    Assert.Equal(23, named.age);
}

由于编译器完成了大部分命名,即使 IntelliSense 没有向您显示,我们仍然可以访问这些 1、2、3 字段:

[Fact]
public void Named_equals_Unnamed()
{
    var named = (name: "Foo", age: 23);
    Assert.Equal(named.name, named.Item1);
    Assert.Equal(named.age, named.Item2);
}

此外,我们可以使用名称“神奇地”跟随的变量创建命名元组:

[Fact]
public void ProjectionInitializers()
{
    var name = "Foo";
    var age = 23;
    var projected = (name, age);
    Assert.Equal("Foo", projected.name);
    Assert.Equal(23, projected.age);
}

由于值存储在这 1、2、3 个字段中,并且编译器生成了程序员友好的名称,因此相等性基于字段顺序,而不是字段名称。部分原因是,比较两个元组是否相等非常简单:

[Fact]
public void TuplesEquality()
{
    var named1 = (name: "Foo", age: 23);
    var named2 = (name: "Foo", age: 23);
    var namedDifferently = (Whatever: "Foo", bar: 23);
    var unnamed1 = ("Foo", 23);
    var unnamed2 = ("Foo", 23);
    Assert.Equal(named1, unnamed1);
    Assert.Equal(named1, named2);
    Assert.Equal(unnamed1, unnamed2);
    Assert.Equal(named1, namedDifferently);
}

如果您不想使用.符号访问元组的成员,我们还可以将它们分解为变量:

[Fact]
public void Deconstruction()
{
    var tuple = (name: "Foo", age: 23);
    var (name, age) = tuple;
    Assert.Equal("Foo", name);
    Assert.Equal(23, age);
}

方法还可以返回元组,使用方法与我们在前面示例中看到的相同:

[Fact]
public void MethodReturnValue()
{
    var tuple1 = CreateTuple1();
    var tuple2 = CreateTuple2();
    Assert.Equal(tuple1, tuple2);
    static (string name, int age) CreateTuple1()
    {
        return (name: "Foo", age: 23);
    }
    static (string name, int age) CreateTuple2() 
        => (name: "Foo", age: 23);
}

这些方法是内联的,但普通方法也是如此。

为了结束关于元组的这段插曲,我建议在导出的公共 API(例如,共享库)上避免使用元组。但是,它们可以在内部方便地为代码助手编写代码,而无需创建只保存数据并使用一次或几次的类。

我认为元组是对.NET 的一个很好的补充,但出于许多原因,我更喜欢公共 API 上完全定义的类型。第一个原因是封装;元组的成员是字段,这破坏了封装。然后,准确地命名作为 API(契约/接口)一部分的类是至关重要的。

提示

当您找不到类型的详尽名称时,可能会出现一些业务需求模糊、正在开发的内容不完全是所需的内容、或者领域语言不清楚的情况。有时候,你的想象力只是让你失望,但这一次没关系——它发生了。

接下来,我们将探索服务定位器反模式/代码气味。

了解服务定位器模式

服务定位器是一种反模式,它将 IoC 原则还原为其控制怪胎根源。唯一的区别是使用 IoC 容器来构建依赖关系树,而不是new关键字。

这种模式在 ASP.NET 中有一些用途,有些人可能会认为使用服务定位器模式是有原因的,但这种情况应该很少发生或永远不会发生。因此,在本书中,我们将服务定位器称为代码气味,而不是反模式

DI 容器在内部使用服务定位器模式来查找依赖项,这是使用它的正确方法。在您的应用中,您希望避免注入IServiceProvider以从中获得所需的依赖项,这将恢复到经典的控制流。

我强烈建议不要使用服务定位器,除非您知道自己在做什么,并且没有其他选择。

服务定位器的一个很好的用途是迁移一个太大而无法重写的遗留系统。因此,您可以使用 DI 构建新代码,并使用服务定位器模式更新遗留代码,从而允许两个系统共存或根据您的目标将一个系统迁移到另一个系统。

项目:ServiceLocator

避免某些事情的最好方法是了解它,所以让我们看看如何使用IServiceProvider来实现服务定位器模式以查找依赖项:

public class MyController : ControllerBase
{
    private readonly IServiceProvider _serviceProvider;
    public MyController(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
    }
    [Route("/")]
    public IActionResult Get()
    {
        var myService = _serviceProvider.GetService<IMyService>();
        myService.Execute();
        return Ok("Success!");
    }
}

在代码示例中,我们不是将IMyService注入构造函数,而是注入IServiceProvider。然后,我们使用它在Get()方法中找到IMyService。这样做会将创建对象的责任从容器转移到类(在本例中为MyController)。MyController不应意识到IServiceProvider,应让容器在不受干扰的情况下完成其工作。

会出什么问题?让我们从IMyService实现IDisposable,并在Get()方法中添加一条using语句。这说明了在容器之外控制依赖项的生命会产生什么样的问题。

MyServiceImplementation看起来像这样,模拟使用一些外部的、一次性的依赖项:

public class MyServiceImplementation : IMyService
{
    public bool IsDisposed { get; private set; }
    public void Dispose() => IsDisposed = true;
    public void Execute()
    {
        if (IsDisposed)
        {
            throw new NullReferenceException("Some dependencies has been disposed.");
        }
    }
}

然后,通过更新MyController来处置IMyService,我们制造了一些不稳定性:

[Route("/")]
public IActionResult Get()
{
    using (var myService = _serviceProvider.GetService<IMyService>())
    {
        myService.Execute();
        return Ok("Success!");
    }
}

如果我们运行应用并导航到/,一切都会按预期进行。如果我们重新加载页面,我们会得到一个错误,Execute()抛出,因为我们在上一次调用中调用了Dispose()MyController不应该控制其注入的依赖项,这是我在这里试图强调的一点:让容器控制依赖项的生存期,而不是试图成为一个控制怪胎。使用服务定位器模式打开了通向这些错误行为的途径,从长远来看,这些错误行为很可能造成弊大于利。

此外,尽管 ASP.NET Core 5 容器本身不支持此功能,但在使用服务定位器时,我们失去了在上下文中注入依赖项的能力,因为使用者控制其依赖项。我所说的语境是什么意思?可以将实例A注入一个类,但将实例B注入另一个类。

项目:服务定位固定

为了修复我们的控制器,我们需要离开服务定位器,转而注入依赖项。在示例中,我们解决了以下问题:

  • 方法注入
  • 构造函数注入

实现方法注入

让我们从开始使用方法注入来演示它的用法:

public class MethodInjectionController : ControllerBase
{
    [Route("/method-injection")]
    public IActionResult GetUsingMethodInjection([FromServices]IMyService myService)
    {
        if (myService == null) { throw new ArgumentNullException(nameof(myService)); }
        myService.Execute();
        return Ok("Success!");
    }
}

让我们分析一下代码:

  • FromServicesAttribute类告诉模型绑定器方法注入。我们可以在任何操作中注入零个或多个服务,方法是用[FromServices]修饰其参数。
  • 我们增加了保护条款,以保护我们免受nulls的影响。
  • Finally, we kept the original code.

    笔记

    这样的方法注入对于具有多个动作的控制器很有用,但只在其中一个动作中使用IMyService

实现构造函数注入

让我们继续使用构造函数注入实现相同的解决方案。我们的新控制器如下所示:

public class ConstructorInjectionController : ControllerBase
{
    private readonly IMyService _myService;
    public ConstructorInjectionController(IMyService myService)
    {
        _myService = myService ?? throw new ArgumentNullException(nameof(myService));
    }
    [Route("/constructor-injection")]
    public IActionResult GetUsingConstructorInjection()
    {
        _myService.Execute();
        return Ok("Success!"); 
    }
}

当使用构造函数注入时,在类实例化时,我们强制IMyService不为 null。因为它是一个类成员,所以在一个动作中调用它的Dispose()方法就不那么容易了,把责任留给容器(应该如此)。

这两种技术都是可接受的服务定位器反模式的替代品。让我们分析一下代码:

  • 我们实施了战略模式。
  • 我们使用构造函数注入。
  • 我们增加了一个保护条款。
  • 我们将操作简化为它应该做的事情,将原始代码简化到最低限度。

结论

大多数情况下,通过遵循服务定位器反模式,我们只会隐藏我们正在控制对象,而不是解耦我们的组件。最后一个示例演示了在处理对象时出现的问题,使用构造函数注入可能会出现这种情况。然而,当考虑它时,处理我们创建的对象比处理注入的对象更具诱惑力。

此外,服务定位器将控制权从容器移开,并将其移入使用者,这与 OCP 背道而驰。应该能够通过更新组合根的绑定来更新使用者。在这种情况下,我们可以更改绑定,它将起作用。在更高级的情况下,当需要上下文注入时,我们很难将两个实现绑定到同一个接口,每个接口对应一个上下文;这甚至可能是不可能的。

这种反模式也使测试复杂化;在对类进行单元测试时,需要模拟返回模拟服务的容器,而不是只模拟服务。

有一个地方我可以看到它的使用是合理的,那就是在组合根中,因为绑定是在那里定义的,有时候,特别是使用内置容器,我们无法避免它。另一个地方是向容器添加功能的库。除此之外,尽量离我远点!

当心

将服务定位器移到别处不会使其消失;它只会像任何依赖项一样移动它。

重温工厂模式

在策略模式示例中,我们实现了一个解决方案,该解决方案使用new关键字实例化了一个HomePageViewModel。虽然这样做是可以接受的,但我们可以使用方法注入,与工厂的使用混合使用。当构建对象时,工厂模式是方便的工具。让我们看看使用工厂的一些改写来探索一些可能性。

工厂混合注射法

我们的第一个选择是将视图模型直接注入到我们的方法中,而不是注入IHomeService。为了实现这一点,让我们像这样重写HomeController类:

public class HomeController : Controller
{
    public IActionResult Index([FromServices]HomePageViewModel viewModel)
    {
        return View(viewModel);
    }
    // Omitted Privacy() and Error()
}

FromServicesAttribute类告诉 ASP.NET Core 管道将HomePageViewModel的实例直接注入该方法。

现在我们已经对消费者进行了编码,我们需要告诉容器如何向我们提供该视图模型。为此,我们使用工厂而不是静态绑定,如下所示:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IHomeService, HomeService>();
    services.AddTransient(serviceProvider => {
 var homeService = serviceProvider.GetService<IHomeService>();
 var data = homeService.GetHomePageData();
 return new HomePageViewModel(data);
 });
}

在前面的代码中,我们使用了AddTransient()扩展方法的另一个重载,并将Func<IServiceProvider, TService> implementationFactory作为参数传递。突出显示的代码代表我们的工厂。该工厂被实现为一个服务定位器,使用IServiceProvider实例创建IHomeService依赖项,我们使用该依赖项实例化HomePageViewModel

我们在这里使用的是new关键字,但这是错误的吗?组合根是应该创建(或配置)元素的地方,因此可以在那里实例化对象,就像使用服务定位器模式一样。然而,你应该尽可能避免它。使用默认容器比使用功能齐全的第三方容器更难避免,但在许多情况下我们可以避免。

我们还可以创建一个 factory 类来保持我们的合成根目录干净(请参阅HomeViewModelFactory代码示例)。虽然这是真的,但我们只会移动代码,从而增加程序的复杂性。这就是为什么在控制器的操作中创建视图模型可以减少不必要的复杂性的原因。

此外,在大多数情况下,在动作中创建视图模型不应对程序的可维护性产生负面影响,因为视图模型绑定到单个视图,由单个动作控制,从而形成一对一的关系。

最后,它的实现成本更低。它也比在代码中漫游更容易理解,以找到绑定的作用。

最大的缩减是可测试性;从测试用例中注入我们想要的数据比处理硬编码对象创建更容易。

工厂的使用可以考虑多个场景,因此我们在下面的示例中介绍,一石二鸟。到目前为止,我们看到了以下情况:

  • 我们可以在 composition 根目录中创建一个工厂来管理依赖项的创建(aFunc<IServiceProviderTService>
  • 方法注入的一个例子。
  • 有时,应该使用new关键字,而不是试图实现更复杂的代码,最终只会解决问题,导致错误的解耦,增加复杂性。

HomeViewModelFactory

对于更复杂的场景,或者为了清理Startup类,我们可以创建一个类来处理工厂逻辑。我们还可以创建一个类和一个接口,以便在其他类中直接使用工厂。这种方法仅在您需要依赖项时才方便地创建依赖项;这也称为延迟加载。延迟加载意味着仅在需要时创建对象,推迟使用时(或首次使用时)创建对象的开销。

笔记

有一个现有的Lazy<T>类可以帮助延迟加载,但这不是本代码示例的重点。想法是一样的:我们只在第一次需要的时候创建对象。

除非您需要在多个位置重用该创建逻辑,否则创建工厂类可能不值得。然而,这是一个非常方便的模式,值得记住。下面是如何实现返回PrivacyViewModel实例的工厂:

public interface IHomeViewModelFactory
{
    PrivacyViewModel CreatePrivacyViewModel();
}
public class HomeViewModelFactory : IHomeViewModelFactory
{
    public PrivacyViewModel CreatePrivacyViewModel() => new PrivacyViewModel
    {
        Title = "Privacy Policy (from 
        IHomeViewModelFactory)",
        Content = new HtmlString("<p>Use this page to detail your site's privacy policy.</p>")
    };
}

前面的代码将PrivacyViewModel实例的创建封装到HomeViewModelFactory中。代码非常基本,它创建了一个PrivacyViewModel类的实例,并用硬编码的值填充其属性。

现在要使用新工厂,我们需要更新控制器。为此,我们使用构造函数注入将IHomeViewModelFactory注入HomeController,然后从Privacy()动作方法中使用,如下所示:

private readonly IHomeViewModelFactory _homeViewModelFactory;
public HomeController(IHomeViewModelFactory homeViewModelFactory)
{
    _homeViewModelFactory = homeViewModelFactory ?? throw new ArgumentNullException(nameof(homeViewModelFactory));
}
// ...
public IActionResult Privacy()
{
    var viewModel = _homeViewModelFactory.CreatePrivacyViewModel();
    return View(viewModel);
}

前面的代码清晰、简单且易于测试。

通过使用这种技术,我们不限于一种方法。我们可以在同一个工厂中编写多个方法,每个方法封装自己的创作逻辑。我们还可以将其他对象传递给Create[object to create]()方法,如下所示:

public HomePageViewModel CreateHomePageViewModel(IEnumerable<string> someData)
{
    return new HomePageViewModel(someData);
}

当你想到它的时候,可能性几乎是无限的,所以现在你已经看到了一些实际操作,当你需要将一些带有复杂实例化逻辑的类注入到其他对象中时,你可能会发现工厂的其他用途。

您还可以将依赖项注入工厂,并将它们用于复杂的实例化逻辑。

请记住,在代码库中移动代码不会使代码、逻辑、依赖项或耦合消失

根据经验,在控制器操作中创建视图模型是可以接受的。包含逻辑的类是我们想要注入并打破紧密耦合的类。

总结

本章介绍了依赖注入的基础知识,以及如何利用它来遵循控制反转原理帮助下的依赖反转原理

然后,我们回顾了策略设计模式,并了解了如何使用它创建一个灵活的、DI 就绪的系统。我们还回顾了单例模式,看到我们可以在容器中配置依赖项时使用单例生存期在系统范围内注入相同的实例。我们终于看到了如何利用工厂来处理复杂的对象创建逻辑。

我们还讨论了移动代码、改进的幻觉和工程成本。我们看到,当构造逻辑足够简单时,new关键字可以帮助降低复杂性,并且可以节省时间和金钱。

另一方面,我们还探索了一些处理对象创建复杂性的技术,以创建可维护和可测试的程序,例如工厂,并摆脱控制怪胎代码的味道。

在这些重要主题之间,我们还访问了保护子句,以保护注入的依赖项不受null的影响。这样,我们就可以使用构造函数注入来要求一些必需的服务,并从类方法中使用它们,而无需每次对null进行测试。

我们探讨了服务定位器(反)模式如何有害,以及如何从组合根中利用它来动态创建复杂对象。我们还讨论了为什么要使其使用频率尽可能接近“从不”。

了解这些模式和代码气味对于在程序规模不断增长时保持系统的可维护性非常重要。

此外,我们还研究了 C#语言中一些较新的元素,如元组、默认文字表达式和抛出表达式。

对于需要复杂 DI 逻辑、条件注入、多作用域、自动实现工厂和其他高级功能的程序,我们发现可以使用第三方容器而不是内置容器。

在接下来的部分中,我们将探讨一些工具,这些工具将功能添加到默认容器中,从而减少将其替换为其他容器的需要。如果您正在构建多个较小的项目(微服务),而不是一个较大的项目(整体),这可能会使您不再需要这些额外的功能,但没有什么是免费的,而且一切都是有成本的;更多信息请参见第 16 章微服务架构简介

在下一章中,我们将探讨选项和日志记录模式。这些 ASP.NET Core 模式的目的是在管理此类常见问题时减轻我们的负担。

问题

让我们来看看几个练习题:

  1. 我们可以分配给 ASP.NET Core 中的对象的三个依赖项注入生命周期是什么?
  2. 作文的词根是什么?
  3. 在实例化易失性依赖项时,我们是否应该避免使用new关键字?
  4. 在本章中,我们重温的有助于组合对象以消除继承的模式是什么?
  5. 服务定位器模式是设计模式、代码气味还是反模式?

进一步阅读

以下是我们在本章学到的一些链接: