第10章 带有依赖注入的服务配置(ASP.NET Core in Action, 2nd Edition)

发布时间 2023-04-11 13:46:23作者: 码农小白修炼记

第2部分 构建完整的应用程序

我们在第一部分中讨论了很多内容。您看到了ASP.NET Core应用程序是如何由中间件组成的,我们主要关注RazorPages框架。您了解了如何使用Razor语法构建传统的服务器端渲染应用程序,以及如何为移动和客户端应用程序构建API。

在第2部分中,我们将深入到框架中,并查看当您想要构建更复杂的应用程序时不可避免地需要的各种组件。在本部分结束时,您将能够构建针对特定用户定制的动态应用程序,这些应用程序可以部署到多个环境,每个环境都具有不同的配置。

ASP.NET Core在其库中使用依赖注入(DI),因此了解这种设计模式的工作原理很重要。在第10章中,我将介绍DI,为什么使用它,以及如何配置应用程序中的服务以使用DI。

第11章介绍ASP.NET Core配置系统,它允许您从一系列源JSON文件、环境变量等向应用程序传递配置值。您将了解如何根据应用程序运行的环境将其配置为使用不同的值,以及如何将强类型对象绑定到配置以帮助减少运行时错误。

大多数web应用程序都需要某种数据存储,因此在第12章中,我将介绍实体框架核心(EF核心)。这是一个新的跨平台库,可以更轻松地将应用程序连接到数据库。EF Core本身就值得一本书,所以我只提供一个简短的介绍,并向您介绍John Smith的优秀著作《实体框架核心在行动》,第二版(Manning,2021)。我将向您展示如何创建数据库以及如何插入、更新和查询简单数据。

在第13章到第15章中,我们将讨论如何构建更复杂的应用程序。您将看到如何将ASP.NET Core Identity添加到应用程序中,以便用户可以登录并享受自定义体验。您将学习如何使用授权保护您的应用程序,以确保只有特定用户才能访问特定的操作方法,您还将了解如何重构应用程序,以便使用过滤器从Razor Pages和API控制器中提取通用代码。

在本部分的最后一章中,我将介绍使应用程序运行所需的步骤,包括如何将应用程序发布到IIS,如何配置应用程序侦听的URL,以及如何优化客户端资产以提高性能。

本章包括(请点击这里阅读其他章节)

  • 了解依赖注入的好处
  • ASP.NET Core如何使用依赖注入
  • 配置服务以使用依赖注入
  • 为您的服务选择正确的生命周期

在本书的第1部分中,您了解了如何使用ASP.NET Core构建应用程序的基本知识。您学习了如何组合中间件来创建应用程序,以及如何使用MVC模式来使用RazorPages和WebAPI构建传统的web应用程序。这为您提供了开始构建简单应用程序的工具。

在本章中,您将了解如何在ASP.NET Core应用程序中使用依赖注入(DI)。DI是一种设计模式,可以帮助您开发松散耦合的代码。ASP.NET Core在框架内部和您构建的应用程序中广泛使用该模式,因此您需要在除最琐碎的应用程序之外的所有应用程序中使用它。

您可能以前听说过DI,甚至可能在自己的应用程序中使用过它。如果是这样的话,这一章应该不会给你带来太多惊喜。如果你以前没有使用过DI,不要害怕;我会确保你在这一章完成之前跟上进度!

本章首先介绍了DI的一般情况、它所驱动的原理,以及为什么您应该关注它。您将看到ASP.NET Core如何在其整个实现过程中接受DI,以及为什么在编写自己的应用程序时也应该这样做。

一旦您对这个概念有了坚实的理解,您将看到如何将DI应用于您自己的类。您将学习如何配置您的应用程序,以便ASP.NET Core框架可以为您创建类,从而消除在代码中手动创建新对象的痛苦。在本章末尾,您将学习如何控制对象的使用时间,以及编写自己的应用程序时需要注意的一些陷阱。

在第19章中,我们将研究一些更高级的DI使用方法,包括如何连接第三方DI容器。不过,现在,让我们回到基本问题:DI是什么,为什么要关心它?

10.1 依赖注入简介

本节旨在让您基本了解什么是依赖注入,为什么要关注它,以及ASP.NET Core如何使用它。本主题本身远远超出了本章的范围。如果你想了解更深入的背景,我强烈建议你在线查看马丁·福勒的文章。1

提示:要想更直接地阅读C#中的许多示例,我建议选择Steven van Deursen和Mark Seemann(Manning,2019)的依赖注入原则、实践和模式。

ASP.NET Core框架从一开始就被设计为模块化,并遵循“良好”的软件工程实践。与软件中的任何东西一样,被认为是最佳实践的东西随着时间的推移而变化,但是对于面向对象的编程来说,SOLID2原则已经得到了很好的支持。

在此基础上,ASP.NET Core将依赖注入(有时称为依赖反转、DI或控制反转3)烘焙到框架的核心。无论您是否希望在自己的应用程序代码中使用它,框架库本身都依赖于它作为一个概念。

我从一个常见的场景开始本节:应用程序中的一个类依赖于不同的类,而另一个类又依赖于另一个。您将看到依赖注入如何帮助您减轻这种依赖关系链,并提供一些额外的好处。

10.1.1 了解依赖注入的好处

当您第一次开始编程时,很可能没有立即使用DI框架。这并不奇怪,甚至是一件坏事;DI增加了一定数量的额外布线,这些布线在简单的应用程序中或在您开始时通常是不需要的。但当事情开始变得更加复杂时,DI就成为了一个很好的工具,可以帮助控制这种复杂性。

让我们考虑一个没有任何DI的简单示例。假设一个用户已经在你的网络应用上注册,你想给他们发送一封电子邮件。此列表显示了最初在API控制器中如何实现这一点。

注意:我在这个例子中使用的是API控制器,但我也可以很容易地使用RazorPage。RazorPages和API控制器都使用构造函数依赖注入,如第10.2节所示。

//创建EmailSender的新实例 emailSender.SendEmail(username); return Ok(); //使用新实例发送电子邮件 } }

在此示例中,当新用户在应用程序上注册时,UserController上的RegisterUser操作将执行。这将创建EmailSender类的新实例,并调用SendEmail()发送电子邮件。EmailSender类负责发送电子邮件。出于本示例的目的,您可以想象它看起来像这样:

public class EmailSender
{
    public void SendEmail(string username)
    {
        Console.WriteLine($"Email sent to {username}!");
    }
}

Console.Writeline代表发送电子邮件的真实过程。

注意:虽然我使用发送电子邮件作为一个简单的示例,但实际上您可能希望将此代码完全移出RazorPage和控制器类。这种类型的异步任务非常适合使用消息队列和后台进程。有关详细信息,请参阅http://mng.bz/pVWR.

如果EmailSender类与前面的示例一样简单,并且没有依赖关系,那么您可能不需要采用其他方法来创建对象。在某种程度上,你是对的。但如果您稍后更新EmailSender的实现,使其不实现整个电子邮件发送逻辑本身,该怎么办?
实际上,EmailSender需要做很多事情才能发送电子邮件。它需要

  • 创建电子邮件
  • 配置电子邮件服务器的设置
  • 将电子邮件发送到电子邮件服务器

在一个类中完成所有这些都将违反单一责任原则(SRP),因此您很可能最终会使用依赖于其他服务的EmailSender。图10.1显示了这个依赖关系网络的外观。UserController希望使用EmailSender发送电子邮件,但要做到这一点,它还需要创建EmailSender所依赖的MessageFactory、NetworkClient和EmailServerSettings对象。

图10.1 没有依赖注入的依赖关系图。UserController间接依赖于所有其他类,因此必须全部创建它们。

每个类都有许多依赖项,因此“根”类(在本例中为User-Controller)需要知道如何创建它所依赖的每个类及其依赖项所依赖的每一个类。这有时被称为依赖关系图。

定义:依赖关系图是必须创建的一组对象,以便创建特定请求的“根”对象。

EmailSender依赖于MessageFactory和NetworkClient对象,因此它们是通过构造函数提供的,如下所示。

清单10.2 具有多个依赖项的服务

public class EmailSender
{
    //EmailSender现在依赖于另外两个类。
    private readonly NetworkClient _client; 
    private readonly MessageFactory _factory;
    //依赖项的实例在构造函数中提供。 
    public EmailSender(MessageFactory factory, NetworkClient client)
    {
        _factory = factory;
        _client = client;
    }
    //EmailSender协调相关性以创建和发送电子邮件。    
    public void SendEmail(string username)
    {
        var email = _factory.Create(username);
        _client.SendEmail(email);
        Console.WriteLine($"Email sent to {username}!");
    }
}

除此之外,EmailSender所依赖的NetworkClient类还依赖于EmailServerSettings对象:

public class NetworkClient
{
  private readonly EmailServerSettings _settings; public NetworkClient(EmailServerSettings settings)
  {
    _settings = settings;
  }
}

这可能感觉有点做作,但这种依赖链很常见。事实上,如果你的代码中没有这一点,这可能是你的类太大,没有遵循单一责任原则的信号。

那么,这如何影响UserController中的代码?下面的列表显示了如果您坚持在控制器中添加新对象,您现在必须如何发送电子邮件。

清单10.3 当您手动创建依赖项时,在没有DI的情况下发送电子邮件

public IActionResult RegisterUser(string username)
{
    var emailSender = new   //要创建EmailSender,必须创建其所有依赖项。
        EmailSender( new MessageFactory(),  //你需要一个新的消息工厂。
            new NetworkClient(  //网络客户端也具有相关性。
            //你已经有两层了,但可能还有更多。 
            new EmailServerSettings (
                host: "smtp.server.com", 
                port: 25
            ))
        );
    emailSender.SendEmail(username);   //最后,您可以发送电子邮件。
    return Ok();
}

这变成了一些粗糙的代码。改进EmailSender的设计以区分不同的职责,这使得从UserController调用它成为一件真正的麻烦事。此代码有几个问题:

  • 不遵守单一责任原则——我们的代码现在负责创建EmailSender对象并使用它发送电子邮件。
  • 相当重要的仪式——RegisterUser方法中的11行代码中,只有最后两行代码在做任何有用的事情。这使得阅读和理解方法的意图变得更加困难。
  • 与实现相关——如果您决定重构EmailSender并添加另一个依赖项,则需要更新它使用的每个位置。同样,如果重构了任何依赖项,您也需要更新此代码。

UserController对EmailSender类具有隐式依赖关系,因为它作为RegisterUser方法的一部分手动创建对象本身。了解UserController使用EmailSender的唯一方法是查看其源代码。相反,EmailSender对NetworkClient和MessageFactory有显式依赖关系,必须在构造函数中提供。类似地,NetworkClient对EmailServerSettings类具有显式依赖关系。

提示:一般来说,代码中的任何依赖项都应该是显式的,而不是隐式的。隐式依赖关系很难推理,也很难测试,因此您应该尽可能避免它们。DI对指导您沿着这条道路前进很有用。

依赖注入旨在通过反转依赖链来解决构建依赖图的问题。与UserController在代码的实现细节深处手动创建依赖关系不同,已经创建的EmailSender实例通过构造函数注入。

现在,显然需要一些东西来创建对象,所以要做的代码必须存在于某个地方。负责创建对象的服务称为DI容器或IoC容器,如图10.2所示。

定义:DI容器或IoC容器负责创建服务实例。它知道如何通过创建服务的所有依赖项并将其传递给构造函数来构造服务的实例。在本书中,我将其称为DI容器。

图10.2 使用依赖注入的依赖关系图。UserController间接依赖于所有其他类,但不需要知道如何创建它们。UserController声明它需要EmailSender,容器提供它。

依赖注入一词经常与控制反转(IoC)互换使用。DI是IoC更一般原理的具体版本。IoC描述了框架调用代码来处理请求的模式,而不是您自己编写代码来从网卡上的字节解析请求。DI更进一步,您允许框架创建依赖项:它不是由UserController控制如何创建EmailSender实例,而是由框架提供。

注:许多DI容器可用于.NET:Autofac、Lamar、Unity、Ninject、Simple Injector。。。名单还在继续!在第19章中,您将看到如何用这些替代方案之一替换默认ASP.NET Core容器。

当您看到这种模式在多大程度上简化了依赖关系时,采用这种模式的优势就显而易见了。下面的列表显示了如果使用DI而不是手动创建EmailSender,UserController的外观。所有的新问题都已消失,您可以只关注控制器调用EmailSender并返回OkResult的操作。

public UserController(EmailSender emailSender) { _emailSender = emailSender; } //Action方法很容易阅读和理解。 [HttpPost("register")] public IActionResult RegisterUser(string username) { _emailSender.SendEmail(username); return Ok(); } }

DI容器的优点之一是它只有一个职责:创建对象或服务。您向容器请求一个服务实例,它会根据您的配置来确定如何创建依赖关系图。

注意:在谈到DI容器时,通常会提到服务,这有点不幸,因为它是软件工程中最过载的术语之一!在此上下文中,服务引用DI容器在需要时创建的任何类或接口。

这种方法的优点在于,通过使用显式依赖关系,您不必编写清单10.3中看到的代码。DI容器可以检查服务的构造函数,并确定如何编写大部分代码本身。DI容器总是可配置的,所以如果您想描述如何手动创建服务实例,您可以这样做,但默认情况下不需要这样做。

提示:您可以通过其他方式将依赖项注入到服务中;例如通过使用属性注入。但是构造函数注入是最常见的,也是ASP.NET Core中唯一一种开箱即用的支持,所以我只在本书中使用它。

希望在您的代码中使用DI的优点从这个快速示例中显而易见,但DI提供了免费的额外好处。特别是,它有助于保持代码与接口的松散耦合。

10.1.2 创建松耦合代码

耦合是面向对象编程中的一个重要概念。它是指给定的类如何依赖于其他类来执行其功能。松耦合代码不需要知道特定组件的很多细节就可以使用它。

UserController和EmailSender的最初示例是紧密耦合的示例;您直接创建了EmailSender对象,需要知道如何将其连接起来。除此之外,代码很难测试。任何测试UserController的尝试都会导致发送电子邮件。如果您正在用一套单元测试测试控制器,那么这将是将您的电子邮件服务器列入垃圾邮件黑名单的可靠方法!

将EmailSender作为构造函数参数并消除创建对象的责任有助于减少系统中的耦合。如果EmailSender实现发生更改,使其具有另一个依赖项,则不再需要同时更新UserController。

仍然存在的一个问题是,UserController仍然绑定到实现而不是接口。接口编码是一种常见的设计模式,它有助于进一步减少系统的耦合,因为您不受单个实现的约束。这在使类可测试时特别有用,因为您可以为测试目的创建依赖项的“存根”或“模拟”实现,如图10.3所示。

图10.3 通过编码到接口而不是显式实现,您可以在不同的场景中使用不同的IEmailSender实现,例如单元测试中的MockEmailSender。

提示:您可以从许多不同的模拟框架中进行选择。我最喜欢的是Moq,但NSubstitute和FakeItEay也是热门选项。

例如,您可以创建一个IEmailSender接口,EmailSender将实现该接口:

public interface IEmailSender
{
  public void SendEmail(string username);
}

然后UserController可以依赖于此接口,而不是特定的Email-Sender实现,如下面的列表所示。这将允许您在单元测试期间使用不同的实现,例如DummyEmailSender。

public UserController(IEmailSender emailSender) { _emailSender = emailSender; } //只要它实现了IEmailSender,你就不在乎实现是什么。 [HttpPost("register")] public IActionResult RegisterUser(string username) { _emailSender.SendEmail(username); return Ok(); } }

这里的关键点是,消费代码UserController并不关心依赖关系是如何实现的,只是它实现了IEmailSender接口并公开了SendEmail方法。应用程序代码现在独立于实现。

希望DI背后的原则通过松散耦合的代码看起来是合理的,很容易完全更改或替换实现。但这仍然给您留下了一个问题:应用程序如何知道在生产中使用EmailSender而不是DummyEmailSendr?告诉DI容器“当您需要IEmailSender时,请使用EmailSender”的过程称为注册。

定义:向DI容器注册服务,以便它知道每个请求的服务使用哪个实现。这通常采取“对于接口X,使用实现Y”的形式

如何向DI容器注册接口和类型取决于具体的DI容器实现,但原则基本相同。ASP.NET Core包含一个开箱即用的简单DI容器,因此让我们看看在典型请求期间如何使用它。

10.1.3 ASP.NET Core中的依赖注入

ASP.NET Core从一开始就被设计为模块化和可组合,具有几乎插件式的架构,通常由DI补充。因此,ASP.NET Core包含一个简单的DI容器,所有框架库都使用它来注册自己及其依赖项。

例如,这个容器用于注册RazorPages和WebAPI基础设施——格式化程序、视图引擎、验证系统等等。它只是一个基本容器,因此它只公开了一些注册服务的方法,但您也可以用第三方DI容器替换它。这可以为您提供额外的功能,例如自动注册或setter注入。DI容器内置在ASP.NET Core托管模型中,如图10.4所示。

图10.4 ASP.NET Core托管模型在创建控制器时使用DI容器来实现依赖关系。

托管模型在需要时从DI容器中提取依赖项。如果框架确定由于传入的URL/路由而需要UserController,则负责创建API控制器实例的控制器激活器将向DI容器请求IEmailSender实现。

注意:这种方法称为服务定位器模式,其中类直接调用DI容器来请求类。一般来说,您应该在代码中尽量避免这种模式;直接将依赖项作为构造函数参数,并让DI容器为您提供它们。

DI容器需要知道在请求IEmailSender时要创建什么,因此您必须已经向容器注册了一个实现,例如EmailSender。一旦注册了一个实现,DI容器就可以在任何地方注入它。这意味着您可以将与框架相关的服务注入到您自己的定制服务中,只要它们在容器中注册即可。这也意味着您可以注册框架服务的替代版本,并让框架自动使用这些替代默认版本。

DI的卖点之一是灵活地选择如何以及在应用程序中组合哪些组件。在下一节中,您将学习如何使用默认的内置容器在自己的ASP.NET Core应用程序中配置DI。

10.2 使用依赖注入容器

在ASP.NET的早期版本中,使用依赖注入是完全可选的。相反,要构建除最平凡的ASP.NET Core应用程序之外的所有应用程序,需要一定程度的DI。正如我所提到的,底层框架依赖于它,所以像使用RazorPages和API控制器这样的事情需要您配置所需的服务。在本节中,您将看到如何使用内置容器注册这些框架服务,以及如何注册自己的服务。一旦注册了服务,就可以将它们用作依赖项,并将它们注入到应用程序中的任何服务中。

10.2.1 向容器添加ASP.NET Core框架服务

如前所述,ASP.NET Core使用DI来配置其内部组件以及您自己的自定义服务。为了在运行时使用这些组件,DI容器需要知道它需要的所有类。您可以在Startup类的ConfigureServices方法中注册这些。

注意:依赖注入容器是在Startup.cs中Startup类的ConfigureServices方法中设置的。

现在,如果你在想,“等等,我必须自己配置内部组件?”那么不要惊慌。虽然在某种意义上是正确的——您确实需要在应用程序中的容器中显式地注册组件——但您将使用的所有库都会公开方便的扩展方法,为您处理具体细节。这些扩展方法可以一次性配置您所需的一切,而不是让您手动连接一切。

例如,RazorPages框架公开了您在第2、3和4章中看到的AddRazorPage()扩展方法。在Configure-Services of Startup中调用扩展方法。

//AddRazorPages扩展方法将所有必需的服务添加到IServiceCollection。 }

就这么简单。实际上,这个调用是在DI容器中注册多个组件,使用您稍后将看到的相同API来注册您自己的服务。

提示:AddControllers()方法为API控制器注册所需的服务,如第9章所示。有一个类似的方法,AddControllersWithViews(),如果你使用的是带有Razor视图的MVC控制器,还有一个AddMvc()方法来添加所有控制器和厨房水槽!

添加到应用程序中的大多数重要库都有需要添加到DI容器中的服务。按照惯例,每个具有必要服务的库都应该公开一个Add*()扩展方法,您可以在Configure-services中调用该方法。

无法确切地知道哪些库需要您向容器中添加服务;通常是检查文档中使用的任何库的情况。如果您忘记添加它们,您可能会发现功能不起作用,或者您可能会得到一个方便的异常,如图10.5所示。注意这些,并确保注册您需要的任何服务。

图10.5 如果您未能在ConfigureServices of Startup中调用AddRazorPages,您将在运行时收到一条友好的异常消息。

还值得注意的是,一些Add*()扩展方法允许您在调用它们时指定其他选项,通常是通过lambda表达式。您可以将这些视为在应用程序中配置服务的安装。例如,AddControllers方法提供了大量选项,用于在您想要弄脏手指时微调其行为,如图10.6中的IntelliSense片段所示。

一旦添加了所需的框架服务,就可以开始业务并注册自己的服务,这样就可以在自己的代码中使用DI。

图10.6 将服务添加到服务集合时配置服务。AddControllers()函数允许您配置API控制器服务的大量内部内容。AddRazorPages()函数中提供了类似的配置选项。

10.2.2 在容器中注册您自己的服务

在本章的第一节中,我描述了一种在新用户注册应用程序时发送电子邮件的系统。最初,UserController正在手动创建EmailSender的实例,但您随后对其进行了重构,因此您将IEmailSender的一个实例注入构造函数。

使重构工作的最后一步是使用DI容器配置服务。这让DI容器知道在需要实现IEmailSender依赖时要使用什么。如果你不注册你的服务,你会在运行时得到一个优化,如图10.7所示。幸运的是,这个异常非常有用,可以让您知道哪个服务没有注册(IEmailSender),哪个服务需要它(UserController)。

图10.7 如果您没有在ConfigureServices中注册所有必需的依赖项,那么在运行时会出现一个异常,告诉您哪个服务没有注册。

为了完全配置应用程序,您需要向DI容器注册EmailSender及其所有依赖项,如图10.8所示。

图10.8 在应用程序中配置DI容器包括告诉它在请求给定服务时使用什么类型;例如,“需要IEmailSender时使用EmailSender。”

配置DI包括对应用程序中的服务进行一系列声明。例如

  • 当服务需要IEmailSender时,请使用EmailSender的实例。
  • 当服务需要NetworkClient时,请使用NetworkClient的实例。
  • 当服务需要MessageFactory时,请使用MessageFactory的实例。

注意:您还需要向DI容器注册EmailServerSettings对象,我们将在下一节中以稍微不同的方式进行注册。

这些语句是通过在ConfigureServices方法中调用IServiceCollection上的各种Add*方法生成的。每个方法都向DI容器提供三条信息:

  • 服务类型——TService。这是将作为依赖项请求的类或接口。它通常是一个接口,如IEmailSender,但有时是一个具体类型,如NetworkClient或MessageFactory。
  • 实现类型——TService或TIimplementation。这是容器应该创建的实现依赖关系的类。它必须是具体类型,如EmailSender。它可能与NetworkClient和MessageFactory的服务类型相同。
  • 生存期——瞬时、单例或作用域。这定义了服务实例应该使用多长时间。我将在第10.3节中详细讨论寿命。

下面的列表显示了如何使用三种不同的方法在应用程序中配置EmailSender及其依赖项:AddScoped<TService>、Add-Singleton<TService>和AddScoped<TService,TIimplementation>。这告诉DI容器如何在需要时创建每个TService实例。

//您正在使用API控制器,因此必须调用AddControllers。 services.AddScoped<IEmailSender, EmailSender>(); //每当您需要IEmailSender时,请使用EmailSender。 services.AddScoped<NetworkClient>(); //每当您需要NetworkClient时,请使用NetworkClient。 services.AddSingleton<MessageFactory>(); //无论何时需要MessageFactory,请使用MessageFactory。 }

这就是依赖注入的全部内容!这可能看起来有点像魔术,5但你只是在告诉容器如何制作所有组成部分。你给它一个烹饪辣椒、切丝生菜和磨碎奶酪的食谱,这样当你要墨西哥卷饼时,它就可以把所有的部分放在一起,然后把你的饭递给你!

NetworkClient和MessageFactory的服务类型和实现类型是相同的,因此无需在AddScoped方法中两次指定相同的类型,因此签名稍微简单一些。

注意:EmailSender实例仅注册为IEmailSender,因此无法通过请求特定的EmailSendr实现来检索它;必须使用IEmailSender接口。

这些泛型方法不是向容器注册服务的唯一方法。您还可以直接或通过使用lambdas来提供对象,这将在下一节中看到。

10.2.3 使用对象和lambda注册服务

正如我前面提到的,我没有注册UserController所需的所有服务。在我前面的所有示例中,NetworkClient都依赖于EmailServerSettings,您还需要向DI容器注册该设置,以便项目无例外地运行。

我避免在前面的示例中注册此对象,因为您必须使用稍微不同的方法。前面的Add*方法使用泛型来指定要注册的类的类型,但它们没有给出如何构造该类型的实例的任何指示。相反,容器会做出一些您必须遵守的假设:

  • 类必须是具体类型。
  • 类只能具有容器可以使用的单个“有效”构造函数。
  • 要使构造函数“有效”,所有构造函数参数都必须向容器注册,或者它们必须是具有默认值的参数。

注意:这些限制适用于简单的内置DI容器。如果您选择在应用程序中使用第三方容器,它可能有一组不同的限制。

EmailServerSettings类不满足这些要求,因为它要求您在构造函数中提供主机和端口,它们是没有默认值的字符串:

public class EmailServerSettings
{
  public EmailServerSettings(string host, int port)
  {
    Host = host;
    Port = port;
  }
  public string Host { get; } public int Port { get; }
}

不能在容器中注册这些原语类型;如果说“对于任何类型的每个字符串构造函数参数,都使用”smtp.server.com“值,这会很奇怪。”

相反,您可以自己创建EmailServerSettings对象的实例,并将其提供给容器,如下所示。每当需要EmailServerSettings对象的实例时,容器都会使用预构造的对象。

清单10.8 注册服务时提供对象实例

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddScoped<IEmailSender, EmailSender>(); 
    services.AddSingleton<NetworkClient>(); 
    services.AddScoped<MessageFactory>(); 
    //每当需要实例时,将使用EmailServerSettings的此实例。
    services.AddSingleton(
        new EmailServerSettings (
        host: "smtp.server.com", port: 25
        ));
}

如果您只想在应用程序中有一个EmailServerSettings实例,那么可以在任何地方共享同一个对象。但是,如果每次请求一个新对象时都要创建一个新的对象,该怎么办?

注意:当每次请求时都使用同一个对象时,它被称为单例。如果您创建了一个对象并将其传递给容器,那么它将始终注册为单例。您还可以使用AddSingleton<T>()方法注册任何类,并且容器在整个应用程序中只使用一个实例。我在第10.3节中详细讨论了单重态和其他寿命。生存期是指DI容器应该使用给定对象来实现服务依赖关系的时间。

除了提供容器将始终使用的单个实例之外,您还可以提供一个函数,当容器需要该类型的实例时,它将调用该函数,如图10.9所示。

图10.9 您可以向DI容器注册一个函数,每当需要服务的新实例时,就会调用该函数。

最简单的方法是使用lambda函数(匿名委托),其中容器在需要时创建一个新的EmailServerSettings对象。

清单10.9 使用lambda工厂函数注册依赖项

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(); 
    services.AddScoped<IEmailSender, EmailSender>(); 
    services.AddSingleton<NetworkClient>(); 
    services.AddScoped<MessageFactory>(); 
    services.AddScoped(  //因为您提供了一个创建对象的函数,所以不限于单例。
    provider =>  //lambda是IServiceProvider的一个实例。
        //每次需要EmailServerSettings对象时都会调用构造函数,而不是只调用一次。 
        new EmailServerSettings (
        host: "smtp.server.com", port: 25
        ));
}

在本例中,我已将创建的EmailServerSettings对象的生存期更改为作用域,而不是单例,并提供了一个返回新EmailServerSetting对象的工厂lambda函数。每当容器需要一个新的Email-ServerSettings时,它就会执行该函数并使用它返回的新对象。

当您使用lambda注册服务时,将在运行时提供一个IServiceProvider实例,在清单10.9中称为provider。这是DI容器本身的公共API,它公开GetService()函数。如果需要获取依赖项来创建服务的实例,可以在运行时以这种方式访问容器,但如果可能,应避免这样做。

提示:尽可能避免在工厂函数中调用GetService()。相反,更倾向于构造函数注入,它更具性能,也更易于推理。

开放泛型和依赖注入
如前所述,您不能在EmailServerSettings中使用通用注册方法,因为它在构造函数中使用原始依赖项(在本例中为字符串)。您也不能使用泛型注册方法注册打开的泛型。
开放泛型是包含泛型类型参数的类型,例如Repository<T>。通常使用这种类型来定义可用于多个泛型类型的基本行为。在Repository<T>示例中,您可以将IRepository<Customer>注入到服务中,例如,它应该注入DbRepository<Customer>的实例。
要注册这些类型,必须使用不同的Add*方法重载。例如
services.AddScoped(typeof(IRespository<>), typeof(DbRepository<>));
这确保了每当服务构造函数需要IRespository<T>时,容器都会注入DbRepository<T>的实例。

此时,所有依赖项都已注册。但是ConfigureServices看起来有点混乱,不是吗?这完全取决于个人喜好,但我喜欢将我的服务分组到逻辑集合中,并为它们创建扩展方法,如下所示。这创建了与框架的AddControllers()扩展方法等效的方法——一个漂亮、简单的注册API。随着你在应用程序中添加越来越多的功能,我想你也会很感激的。

清单10.10 创建一个扩展方法来整理添加多个服务

public static class EmailSenderServiceCollectionExtensions
{
    //使用“this”关键字在IServiceCollection上创建扩展方法。
    public static IServiceCollection AddEmailSender( this IServiceCollection services)
    {
        //从ConfigureServices剪切并粘贴注册代码。
        services.AddScoped<IEmailSender, EmailSender>(); 
        services.AddSingleton<NetworkClient>(); 
        services.AddScoped<MessageFactory>(); 
        services.AddSingleton(
            new EmailServerSettings (
            host: "smtp.server.com", port: 25
            ));
        return services;  //按照惯例,返回IServiceCollection以允许方法链接。
    }
}

创建了前面的扩展方法后,ConfigureServices方法更容易实现!

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllers(); 
  services.AddEmailSender();
}

到目前为止,您已经了解了如何在只有一个服务实现的情况下注册简单的DI案例。在某些情况下,您可能会发现一个接口有多个实现。在下一节中,您将看到如何在容器中注册这些以满足您的需求。

10.2.4 在容器中多次注册服务

对接口进行编码的优点之一是可以创建服务的多个实现。例如,假设您希望创建一个更通用的IEmailSender版本,以便您可以通过SMS或Facebook以及电子邮件发送消息。你为它创建一个接口,

public interface IMessageSender
{
  public void SendMessage(string message);
}

以及几个实现:EmailSender、SmsSender和FacebookSender。但是如何在容器中注册这些实现呢?如何将这些实现注入到UserController中?答案略有不同,这取决于您是想使用消费者中的所有实现,还是只使用其中一个。

注入接口的多个实现

假设您希望在新用户注册时使用每个IMessageSender实现发送一条消息,以便他们收到一封电子邮件、一条短信和一条Facebook消息,如图10.10所示。

图10.10 当用户向应用程序注册时,他们调用RegisterUser方法。这将使用IMessageSender类向他们发送电子邮件、短信和Facebook消息。

实现这一点的最简单方法是在DI容器中注册所有服务实现,并让它将每种类型的一个注入UserController。然后,UserController可以使用一个简单的foreach循环对每个实现调用SendMessage(),如图10.11所示。

图10.11 您可以使用DI容器注册服务的多个实现,例如本例中的IEmailSender。您可以通过在UserController构造函数中要求IEnumerable<IMessageSender>来检索每个实现的实例。

使用Add*扩展方法,以与单个实现完全相同的方式向DI容器注册同一服务的多个实现。例如

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllers(); 
  services.AddScoped<IMessageSender, EmailSender>(); 
  services.AddScoped<IMessageSender, SmsSender>(); 
  services.AddScoped<IMessageSender, FacebookSender>();
}

然后,您可以将IEnumerable<IMessageSender>注入UserController,如下所示。容器注入一个IMessageSender数组,该数组包含您注册的每个实现之一,其顺序与您注册的顺序相同。然后,可以在RegisterUser方法中使用标准foreach循环来对每个实现调用SendMessage。

public UserController( IEnumerable<IMessageSender> messageSenders) { _messageSenders = messageSenders; } [HttpPost("register")] public IActionResult RegisterUser(string username) { //IEnumerable中的每个IMessageSender都是不同的实现。 foreach (var messageSender in _messageSenders) { messageSender.SendMessage(username); } return Ok(); } }

警告:必须使用IEnumerable<T>作为构造函数参数来注入服务的所有注册类型T。即使这将作为T[]数组注入,也不能将T[]或ICollection<T>用作构造函数参数。这样做将导致InvalidOperationException,类似于图10.7。

注入一个服务的所有注册实现已经足够简单了,但是如果你只需要一个呢?容器如何知道要使用哪个?

注册多个服务时注入单个实现

假设您已经注册了所有IMessageSender实现;如果您的服务只需要其中一个,会发生什么?例如

public class SingleMessageSender
{
  private readonly IMessageSender _messageSender;
  public SingleMessageSender(IMessageSender messageSender)
  {
    _messageSender = messageSender;
  }
}

容器需要从可用的三个实现中选择一个IMessageSender来注入此服务。它通过使用上一个示例中最后注册的实现FacebookSender来实现这一点。

注意:当解析服务的单个实例时,DI容器将使用服务的最后注册实现。

这对于用自己的服务替换内置DI注册尤其有用。如果您有一个服务的自定义实现,并且您知道它是在库的Add*扩展方法中注册的,那么您可以通过以后注册自己的实现来覆盖该注册。每当请求服务的单个实例时,DI容器将使用您的实现。

这种方法的主要缺点是最终仍然会注册多个实现——可以像以前一样注入IEnumerable<T>。有时您希望有条件地注册服务,因此您只需要一个注册的实现。

使用TRYADD有条件地注册服务

有时,您只想在尚未添加服务的情况下添加服务的实现。这对图书馆作者特别有用;他们可以创建接口的默认实现,并仅在用户尚未注册自己的实现时注册它。

您可以在Microsoft.Extensions.DependencyInjection.Extensions命名空间中找到多种用于条件注册的扩展方法,例如Try-AddScoped。这将检查以确保在对实现调用AddScoped之前尚未向容器注册服务。下面的列表显示了只有在没有现有的IMessageSender实现时,才能有条件地添加SmsSender。正如您之前注册的EmailSender一样,容器将忽略SmsSender注册,因此它在您的应用程序中不可用。

//EmailSender已向容器注册。 services.TryAddScoped<IMessageSender, SmsSender>(); //已经有IMessageSender实现,因此SmsSender未注册。 }

这样的代码在应用程序级别通常没有太多意义,但如果您正在构建用于多个应用程序的库,那么它可能很有用。例如,ASP.NET Core框架在许多地方使用TryAdd*,如果需要,它可以让您轻松地在自己的应用程序中注册内部组件的替代实现。

您还可以使用replace()扩展方法替换以前注册的实现。不幸的是,此方法的API不如TryAdd方法友好。要用SmsSender替换以前注册的IMessageSender,您可以使用

services.Replace(new ServiceDescriptor(
  typeof(IMessageSender), typeof(SmsSender), ServiceLifetime.Scoped
));

提示:使用Replace时,您必须提供与注册要替换的服务相同的生存期。

这几乎涵盖了注册依赖项。在我们深入研究依赖关系的“生存期”方面之前,我们将快速绕道,看看除了构造函数之外的两种在应用程序中注入依赖关系的方法。

10.2.5 将服务注入到操作方法、页面处理程序和视图中

我在第10.1节中提到,ASP.NET Core DI容器只支持构造函数注入,但还有三个位置可以使用依赖注入:

  • Action 方法
  • 页面处理程序方法
  • View 模板

在本节中,我将简要讨论这三种情况,它们是如何工作的,以及您可能希望何时使用它们。

使用[FROMSERVICES]将服务直接注入到操作方法和页面处理程序中

API控制器通常包含逻辑上属于一起的多个操作方法。例如,您可以将与管理用户帐户相关的所有操作方法分组到同一个控制器中。这允许您将筛选器和授权共同应用于所有操作方法,如第13章所示。

当您向控制器添加额外的操作方法时,您可能会发现控制器需要额外的服务来实现新的操作方法。通过构造函数注入,所有这些依赖项都是通过构造函数提供的。这意味着DI容器必须为控制器中的每个操作方法创建所有依赖项,即使调用的操作方法不需要这些依赖项。

例如,考虑清单10.13。这显示了具有两个存根方法的UserController:RegisterUser和PromoteUser。每个操作方法都需要不同的依赖项,因此将创建和注入两个依赖项,无论请求调用哪个操作方法。如果IPromotionService或IMessageSender本身有很多依赖关系,那么DI容器可能必须为一个经常不使用的服务创建很多对象。

清单10.13 通过构造函数将服务注入控制器

//每次都将IMessageSender和IPromotionService注入构造函数。
public class UserController : ControllerBase
{
    private readonly IMessageSender _messageSender; 
    private readonly IPromotionService _promoService; 
    public UserController( IMessageSender messageSender, IPromotionService promoService)
    {
        _messageSender = messageSender;
        _promoService = promoService;
    }

    [HttpPost("register")]
    public IActionResult RegisterUser(string username)
    {
        _messageSender.SendMessage(username);   //RegisterUser方法仅使用IMessageSender。
        return Ok();
    }

    [HttpPost("promote")]
    public IActionResult PromoteUser(string username, int level)
    {
        _promoService.PromoteUser(username, level);   //PromoteUser方法仅使用IPromotionService。
        return Ok();
    }
}

如果您知道服务的创建成本特别高,可以选择将其作为依赖项直接注入到操作方法中,而不是注入到控制器的构造函数中。这确保了DI容器仅在调用特定操作方法时创建依赖关系,而不是在调用控制器上的任何操作方法时。

注意:一般来说,你的控制器应该具有足够的凝聚力,这样就不需要这种方法了。如果您发现您的控制器依赖于多个服务,每个服务都由一个操作方法使用,您可能需要考虑拆分控制器。

通过将依赖项作为参数传递给方法并使用[FromServices]属性,可以将依赖项直接注入到操作方法中。在模型绑定期间,框架将从DI容器解析参数,而不是从请求值解析参数。此清单显示了如何重写清单10.13以使用[From-Services]而不是构造函数注入。

清单10.14 使用[FromServices]属性将服务注入控制器

public class UserController : ControllerBase
{
    //[FromServices]属性确保从DI容器解析IMessageSender。 
    [HttpPost("register")]
    public IActionResult RegisterUser([FromServices] IMessageSender messageSender, string username)
    {
        messageSender.SendMessage(username);   //IMessageSender仅在RegisterUser中可用。
        return Ok();
    }
    //IPromotionService从DI容器中解析并作为参数注入。
    [HttpPost("promote")]
    public IActionResult PromoteUser([FromServices] IPromotionService promoService, string username, int level)
    {
        promoService.PromoteUser(username, level);   //只有PromoteUser方法可以使用IPromotionService。
        return Ok();
    }
}

您可能会在所有操作方法中使用[FromServices]属性,但我建议您在大多数情况下使用标准构造函数注入。将构造函数作为声明类的所有依赖项的单个位置可能很有用,因此我只在创建依赖项实例非常昂贵且仅在单个操作方法中使用的罕见情况下使用[FromServices]。

[FromServices]属性的使用方式与Razor Pages完全相同。您可以将服务注入RazorPage的页面处理程序,而不是构造函数,如清单10.15所示。

提示:仅仅因为您可以像这样将服务注入页面处理程序,并不意味着您应该这样做。RazorPages天生就被设计得很小,很有凝聚力,所以最好只使用构造函数注入。

//只有OnPost页面处理程序才能使用IPromotionService。 return RedirectToPage("success"); } }

一般来说,如果您发现需要使用[FromServices]属性,则应后退一步,查看控制器/Razor页面。很可能你在一节课上做得太多了。与其解决[FromServices]的问题,不如考虑将类拆分或将一些行为向下推到应用程序模型服务中。
将服务注入视图模板

建议向构造函数中注入依赖项,但如果没有构造函数呢?特别是,当您无法控制如何构建模板时,如何将服务注入Razor视图模板?

假设您有一个简单的服务HtmlGenerator来帮助您在视图模板中生成HTML。问题是,假设您已经在DI容器中注册了该服务,那么如何将该服务传递给视图模板?

一种选择是使用构造函数注入将HtmlGenerator注入Razor Page,并将服务作为PageModel上的属性公开,如第7章所示。这通常是最简单的方法,但在某些情况下,您可能根本不希望在PageModel中引用HtmlGenerator服务。在这些情况下,您可以直接将HtmlGenerator注入到视图模板中。

注意:有些人对以这种方式向视图中注入服务感到反感。您绝对不应该将与业务逻辑相关的服务注入到视图中,但我认为这对于与HTML生成相关的服务来说是有意义的。

通过在模板中提供要注入的类型和注入服务的名称,可以使用@inject指令将服务注入Razor模板。

清单10.16 使用@inject将服务注入Razor视图模板

@inject HtmlGenerator htmlHelper  <!--将名为htmlHelper的HtmlGenerator实例注入视图-->
<h1>The page title</h1>
<footer>
    @htmlHelper.Copyright()  <!--通过调用htmlHelper实例来使用注入的服务-->
</footer>

将服务直接注入视图是将UI相关服务公开给视图模板的一种有用方式,而无需依赖PageModel中的服务。你可能不会发现你需要太依赖它,但它是一个有用的工具。

这几乎涵盖了注册和使用依赖项,但有一个重要的方面我只是模糊地提到:生存期,或者容器何时创建服务的新实例?了解生存期对于使用DI容器至关重要,因此在向容器注册服务时密切关注它们非常重要。

10.3 了解生命周期:何时创建服务?

每当DI容器被要求提供特定的注册服务(例如IMessageSender的实例)时,它可以执行以下两项操作之一:

  • 创建并返回服务的新实例
  • 返回服务的现有实例

服务的生命周期控制DI容器关于这两个选项的行为。您可以在DI服务注册期间定义服务的生存期。这决定了DI容器何时重用服务的现有实例以满足服务依赖性,以及何时创建新实例。

定义:服务的生存期是服务实例在创建新实例之前应该在容器中生存多长时间。

了解ASP.NET Core中使用的不同生存期的含义很重要,因此本节将介绍每个可用的生存期选项以及何时使用它。特别是,您将了解生存期如何影响DI容器创建新对象的频率。在第10.3.4节中,我将向您展示要注意的生存期模式,其中短生存期依赖关系被长生存期依赖“捕获”。这可能会导致一些难以调试的问题,因此在配置应用程序时必须牢记这一点。

在ASP.NET Core中,您可以在向内置容器注册服务时指定三种不同的生存期:

  • Transient——每次请求服务时,都会创建一个新实例。这意味着您可能在同一依赖关系图中拥有同一类的不同实例。
  • Scoped ——在作用域内,对服务的所有请求都将提供相同的对象。对于不同的范围,您将获得不同的对象。在ASP.NET Core中,每个web请求都有自己的作用域。
  • Singleton——无论在哪个范围内,您都会得到相同的服务实例。

注意:这些概念与大多数其他DI容器一致,但术语往往不同。如果您熟悉第三方DI容器,请确保您了解生命周期概念如何与内置ASP.NET Core DI容器保持一致。

为了说明每个生命周期的行为,我将在本节中使用一个简单的代表性示例。假设您有DataContext,它与数据库有连接,如清单10.17所示。它有一个属性RowCount,它显示数据库的Users表中的行数。出于本示例的目的,我们通过在构造函数中设置行数来模拟调用数据库,因此每次在给定的DataContext实例上调用RowCount时都会得到相同的值。DataContext的不同实例将返回不同的RowCount值。

清单10.17 DataContext在其构造函数中生成随机RowCount

public class DataContext
{
    static readonly Random _rand = new Random(); 
    public DataContext()
    {
        RowCount = _rand.Next(1, 1_000_000_000);  //生成介于1和1000000000之间的随机数
    }

    public int RowCount { get; }  //构造函数中设置的只读属性,因此它始终返回相同的值
}

您还有一个Repository类,它依赖于DataContext,如下一个列表所示。这还公开了RowCount属性,但该属性将调用委托给其DataContext实例。无论使用什么值创建DataContext,存储库都将显示相同的值。

public Repository(DataContext dataContext) { _dataContext = dataContext; } public int RowCount => _dataContext.RowCount; //RowCount返回与DataContext的当前实例相同的值。 }

最后,您有RazorPageRowCountModel,它直接依赖于Repository和DataContext。当RazorPage激活器创建RowCountModel的实例时,DI容器将注入DataContext的实例和Repository的实例。为了创建Repository,它还创建了DataContext的第二个实例。在两个请求过程中,总共需要四个DataContext实例,如图10.12所示。

图10.12 DI容器为每个请求使用两个DataContext实例。根据注册DataContext类型的生存期,容器可能会创建一个、两个或四个不同的DataContext实例。

RowCountModel将从Repository和DataContext返回的RowCount值记录为PageModel上的属性。然后使用Razor模板(未显示)渲染这些对象。

清单10.19 RowCountModel依赖于DataContext和Repository

public class RowCountModel : PageModel
{
    //DataContext和Repository是使用DI传入的。 
    private readonly Repository _repository; 
    private readonly DataContext _dataContext; 
    public RowCountPageModel(
    Repository repository, DataContext dataContext)
    {
        _repository = repository;
        _dataContext = dataContext;
    }
    //调用时,页面处理程序从两个依赖项中检索并记录RowCount。
    public void OnGet()
    {
        DataContextCount = _dataContext.RowCount; RepositoryCount = _repository.RowCount;
    }
    //计数显示在PageModel上,并在Razor视图中呈现为HTML。 
    public int DataContextCount { get; set ;} 
    public int RepositoryCount { get; set ;}
}

本示例的目的是探讨四个Data-Context实例之间的关系,这取决于用于向容器注册服务的生存期。我在DataContext中生成一个随机数,作为唯一标识DataContext实例的方法,但您可以将其视为登录到站点的用户数量的时间点快照,例如,仓库中的库存量。

我将从最短的生存期(短暂的)开始,转到共同的作用域生存期,然后看看单态。最后,我将展示一个重要的陷阱,当您在自己的应用程序中注册服务时,您应该注意这个陷阱。

10.3.1 Transient:每个人都是独一无二的

在ASP.NET Core DI容器中,临时服务总是在需要它们来实现依赖关系时创建的。您可以使用AddTransient扩展方法注册服务:

services.AddTransient<DataContext>(); 
services.AddTransient<Repository>();

以这种方式注册时,每次需要依赖项时,容器都会创建一个新的依赖项。这既适用于请求之间,也适用于请求内部;注入到Repository中的Data-Context将是与注入RowCountModel中的实例不同的实例。

注意:瞬态依赖关系可能会在单个依赖关系图中导致相同类型的不同实例。

图10.13显示了对两个服务使用瞬时生存期时,从两个连续请求获得的结果。注意,默认情况下,RazorPage和API控制器实例也是暂时的,并且总是重新创建。

图10.13 当使用瞬时生存期注册时,所有四个DataContext对象都不同。这可以从两个请求过程中显示的四个不同数字中看出。

短暂的生命周期可能会导致创建大量对象,因此对于状态很少或没有状态的轻量级服务来说,它们是最有意义的。这相当于每次你需要一个新对象时都调用new,所以在使用它时要记住这一点;您的大多数服务可能会被限定范围。

10.3.2 Scoped:让我们团结一致

作用域生存期表示对象的单个实例将在给定作用域内使用,但不同作用域之间将使用不同的实例。在ASP.NET Core中,作用域映射到请求,因此在单个请求中,容器将使用相同的对象来实现所有依赖关系。

对于行计数示例,这意味着在单个请求(单个范围)内,在整个依赖关系图中将使用相同的DataContext。注入存储库的DataContext将与注入RowCountModel的实例相同。

在下一个请求中,您将处于不同的范围,因此容器将创建DataContext的新实例,如图10.14所示。如您所见,不同的实例意味着每个请求的RowCount不同。

可以使用AddScoped扩展方法将依赖项注册为作用域。在本例中,我将DataContext注册为作用域,并将Repository保留为瞬态,但如果它们都是作用域,则在本例中将得到相同的结果:

图10.14 作用域依赖项在单个请求中使用相同的DataContext实例,但在单独的请求中使用新实例。因此,请求中的RowCount是相同的。

由于web请求的性质,您通常会发现在ASP.NET Core中注册为作用域依赖项的服务。数据库上下文和身份验证服务是应用于请求的服务的常见示例——您希望在单个请求中跨服务共享但需要在请求之间更改的任何内容。

一般来说,您会发现许多使用作用域生存期注册的服务——尤其是使用数据库或依赖于特定请求的服务。但有些服务不需要在请求之间进行更改,例如计算圆的面积或返回不同时区的当前时间的服务。对于这些,单例生存期可能更合适。

10.3.3 Singleton:只能有一个

单例是在依赖注入之前出现的模式;DI容器提供了一个健壮且易于使用的实现。单例在概念上很简单:服务的实例是在第一次需要时创建的(或在注册过程中,如第10.2.3节所述),就这样。您总是会将相同的实例注入到服务中。

单例模式对于创建成本高、包含必须跨请求共享的数据或不保持状态的对象特别有用。后两点很重要——任何注册为单例的服务都应该是线程安全的。

警告:在web应用程序中,Singleton服务必须是线程安全的,因为在并发请求期间,它们通常会被多个线程使用。

让我们来考虑一下对于行计数示例,使用singleton意味着什么。我可以在ConfigureServices中将DataContext的注册更新为单例:

services.AddSingleton<DataContext>();

然后,我们可以调用RowCountModel Razor Page两次,并观察图10.15中的结果。您可以看到,每个实例都返回了相同的值,这表明DataContext的所有四个实例都是同一个实例。

图10.15 任何注册为单例的服务将始终返回相同的实例。因此,对RowCount的所有调用在请求内和请求之间都返回相同的值。

对于需要共享或不可变且创建成本高昂的对象,单体很方便。缓存服务应该是单例的,因为所有请求都需要共享它。但是它必须是线程安全的。类似地,如果您在启动时加载一次设置,并在应用程序的整个生命周期中重用它们,则可以将从远程服务器加载的设置对象注册为单例。

从表面上看,为服务选择终身可能并不太棘手,但有一个重要的“陷阱”会以微妙的方式反噬你,你很快就会看到。

10.3.4 关注捕获的依赖关系

假设您正在为DataContext和Repository示例配置生存期。你考虑一下我提供的建议,然后决定下一代:

  • DataContext——作用域,因为它应该为单个请求共享
  • 存储库——Singleton,因为它没有自己的状态,而且是线程安全的,所以为什么不呢?

警告:这种终生配置是为了探索一个bug——不要在代码中使用它,否则你会遇到类似的问题!

不幸的是,您创建了一个捕获的依赖关系,因为您正在将一个作用域对象DataContext注入到一个单例Repository中。由于它是一个单实例,在应用程序的整个生命周期中都使用相同的Repository实例,因此注入其中的DataContext也将继续存在,即使每个请求都应该使用一个新实例。图10.16显示了这个场景,其中为每个作用域创建了一个新的DataContext实例,但Repository中的实例在应用程序的整个生命周期中都是挂起的。

图10.16 DataContext注册为作用域依赖项,但Repository是一个单例。即使您希望每个请求都有一个新的DataContext,Repository也会捕获注入的DataContext并使其在应用程序的生命周期内重用。

捕获的依赖关系可能会导致难以根除的细微错误,因此您应该始终关注它们。这些捕获的依赖关系相对容易引入,因此在注册单例服务时,请务必仔细考虑。

警告:服务只能使用生存期大于或等于服务生存期的依赖项。注册为单例的服务只能安全地使用单例依赖项。注册为scoped的服务可以安全地使用scoped或singleton依赖项。瞬态服务可以使用任何生存期的依赖项。

在这一点上,我应该提到,这个警示故事中有一线希望。ASP.NET Core自动检查这些捕获的依赖项,如果检测到它们,将在应用程序启动时抛出异常,如图10.17所示。

此范围验证检查会影响性能,因此默认情况下,它仅在应用程序在开发环境中运行时启用,但它应该可以帮助您解决大多数此类问题。通过在Program.cs中创建HostBuilder时设置ValidateScopes选项,无论环境如何,都可以启用或禁用此检查,如清单10.20所示。

图10.17 启用ValidateScopes后,DI容器在创建具有捕获的依赖项的服务时将抛出异常。默认情况下,此检查仅对开发环境启用。

清单10.20 将ValidateScopes属性设置为始终验证作用域

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)  //默认生成器将ValidateScopes设置为仅在开发环境中进行验证。
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            })
            .UseDefaultServiceProvider(options =>  //您可以使用UseDefaultServiceProvider扩展重写验证检查。
            {
                options.ValidateScopes = true;   //将此设置为true将验证所有环境中的作用域。这会影响性能。
                options.ValidateOnBuild = true;  //ValidateOnBuild检查每个注册的服务是否已注册其所有依赖项。

            });
}

清单10.20显示了另一个可以启用的设置ValidateOnBuild,它更进一步。启用后,DI容器会在应用程序启动时检查它是否为需要构建的每个服务注册了依赖项。如果没有,它会抛出一个异常,如图10.18所示,让您了解错误配置。这也会影响性能,因此默认情况下仅在开发环境中启用,但它对于指出任何错过的服务注册非常有用。

 

图10.18 启用ValidateOnBuild后,DI容器将在应用程序启动时检查它是否可以创建所有注册的服务。如果发现无法创建的服务,则抛出异常。默认情况下,此检查仅对开发环境启用。

至此,您就完成了ASP.NET Core中DI的介绍。现在您知道如何使用add*扩展方法(如AddRazorPages())将框架服务添加到应用程序中,以及如何在DI容器中注册自己的服务。希望这将帮助您保持代码的松散耦合和易于管理。

在下一章中,我们将研究ASP.NET Core配置模型。您将看到如何在运行时从文件中加载设置,如何安全地存储敏感设置,以及如何使应用程序根据其运行的机器而有不同的行为;它在ASP.NET Core中无处不在!

总结

  • 依赖注入被烘焙到ASP.NET Core框架中。您需要确保您的应用程序在Startup中添加了框架的所有依赖项,否则当DI容器找不到所需的服务时,您将在运行时遇到异常。
  • 依赖关系图是为了创建特定的请求“根”对象而必须创建的一组对象。DI容器负责为您创建所有这些依赖项。
  • 在大多数情况下,您应该使用显式依赖项而不是隐式依赖项。ASP.NET Core使用构造函数参数来声明显式依赖项。
  • 在讨论DI时,术语“服务”用于描述向容器注册的任何类或接口。
  • 您向DI容器注册服务,以便它知道每个请求的服务使用哪个实现。这通常采取“对于接口X,使用实现Y”的形式
  • DI或IoC容器负责创建服务实例。它知道如何通过创建服务的所有依赖项并将其传递给服务构造函数来构造服务的实例。
  • 默认内置容器仅支持构造函数注入。如果需要其他形式的DI,例如属性注入,可以使用第三方容器。
  • 必须通过在启动中的ConfigureServices中调用IServiceCollection上的Add*扩展方法向容器注册服务。如果忘记注册框架使用的服务或在自己的代码中使用的服务,则在运行时将得到InvalidOperationException。
  • 注册服务时,需要描述三个方面:服务类型、实现类型和生存期。服务类型定义将请求哪个类或接口作为依赖项。实现类型是容器应创建以实现依赖关系的类。生存期是服务实例应使用的时间。
  • 如果类是具体的,并且其所有构造函数参数都已向容器注册或具有默认值,则可以使用泛型方法注册服务。
  • 您可以在注册过程中提供服务的实例,这将把该实例注册为单例。当您已经有可用的服务实例时,这可能很有用。
  • 您可以提供一个lambda工厂函数,该函数描述如何创建具有任意生存期的服务实例。当您的服务依赖于只有在应用程序运行时才可访问的其他服务时,可以使用此方法。
  • 尽可能避免在工厂函数中调用GetService()。相反,更倾向于构造函数注入——它更具性能,而且更易于推理。
  • 您可以为一个服务注册多个实现。然后,您可以注入IEnumerable<T>以在运行时访问所有实现。
  • 如果您注入多个注册服务的单个实例,那么容器将注入最后一个注册的实现。
  • 您可以使用TryAdd*扩展方法来确保只有在未注册服务的其他实现时才注册实现。这对于库作者添加默认服务非常有用,同时仍允许用户覆盖注册的服务。
  • 您可以在DI服务注册期间定义服务的生存期。这决定了DI容器何时重用服务的现有实例以满足服务依赖性,以及何时创建新实例。
  • 瞬时生存期意味着每次请求服务时,都会创建一个新实例。
  • 作用域生存期意味着在一个作用域内,服务的所有请求都将为您提供相同的对象。对于不同的范围,您将获得不同的对象。在ASP.NET Core中,每个web请求都有自己的作用域。
  • 无论在哪个范围内,您都会得到相同的单例服务实例。
  • 服务只能使用生存期大于或等于服务生存期的依赖项。