ASP.NET Core 实战-10.使用依赖注入的服务配置
依赖注入简介
本节旨在让您基本了解什么是依赖注入,为什么要关注它,以及 ASP.NET Core 如何使用它。 该主题本身远远超出了这一章的范围。 如果您想要更深入的背景,我强烈建议您在线查看 Martin Fowler 的文章。
ASP.NET Core 框架从头开始设计为模块化并遵循“良好”的软件工程实践。 与软件中的任何东西一样,被认为是最佳实践的东西会随着时间而变化,但对于面向对象的编程,SOLID 原则一直很好。
在此基础上,ASP.NET Core 将依赖注入(有时称为依赖反转、DI 或控制反转)融入框架的核心。 无论您是否想在自己的应用程序代码中使用它,框架库本身都依赖于它作为一个概念。
我从一个常见场景开始本节:应用程序中的一个类依赖于另一个类,而另一个类又依赖于另一个。 您将看到依赖注入如何帮助您减轻这种依赖链并提供许多额外的好处。
了解依赖注入的好处
当您第一次开始编程时,您可能没有立即使用 DI 框架。 这并不奇怪,甚至是一件坏事。 DI 增加了一定数量的额外接线,这在简单的应用程序或开始时通常是不需要的。 但是当事情开始变得更加复杂时,DI 就会成为一个很好的工具来帮助控制这种复杂性。
让我们考虑一个没有任何 DI 的简单示例。 想象一个用户在你的网络应用上注册了,你想给他们发一封电子邮件。 此清单显示了您最初可能如何在 API 控制器中处理此问题。
清单 10.1 在没有依赖项时发送没有 DI 的电子邮件
public class UserController : ControllerBase
{
[HttpPost("register")]
public IActionResult RegisterUser(string username) //创建新用户时调用action 方法。
{
var emailSender = new EmailSender(); //创建一个新的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 代表发送电子邮件的真实过程。
如果 EmailSender 类与前面的示例一样简单并且没有依赖项,那么您可能不需要采用不同的方法来创建对象。在某种程度上,您是对的。 但是,如果您稍后更新 EmailSender 的实现,使其不实现整个电子邮件发送逻辑本身呢?
实际上,EmailSender 需要做很多事情来发送电子邮件。 它需要
创建电子邮件
配置邮件服务器的设置
将邮件发送到邮件服务器
在一个类中完成所有这些操作将违反单一责任原则 (SRP),因此您最终可能会使用依赖于其他服务的 EmailSender。 图 10.1 显示了这个依赖网络的外观。 UserController 想要使用 EmailSender 发送电子邮件,但要这样做,它还需要创建 EmailSender 所依赖的 MessageFactory、NetworkClient 和 EmailServerSettings 对象。
图 10.1 没有依赖注入的依赖关系图。 UserController 间接依赖于所有其他类,因此它必须全部创建它们。 |
---|
每个类都有许多依赖项,因此“root”类,在本例中为 UserController,需要知道如何创建它所依赖的每个类,以及它的依赖项所依赖的每个类。 这有时被称为依赖图。
定义 依赖图是为了创建特定请求的“root”对象而必须创建的一组对象。
EmailSender 依赖于 MessageFactory 和 NetworkClient 对象,因此它们是通过构造函数提供的,如下所示。
清单 10.2 具有多个依赖项的服务
public class EmailSender
{
private readonly NetworkClient _client;
//EmailSender 现在依赖于另外两个类。
private readonly MessageFactory _factory;
//依赖项的实例在构造函数中提供。
public EmailSender(MessageFactory factory, NetworkClient client)
{
_factory = factory;
_client = client;
}
public void SendEmail(string username)
{
//EmailSender 协调依赖关系以创建和发送电子邮件。
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(), //你需要一个新的 MessageFactory
new NetworkClient( //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 容器。
术语依赖注入通常与控制反转(IoC)互换使用。 DI 是 IoC 更一般原则的特定版本。 IoC 描述了框架调用您的代码来处理请求的模式,而不是您自己编写代码来解析来自网卡上字节的请求。 DI 更进一步,您允许框架也创建您的依赖项:而不是您的 UserController 控制如何创建一个 EmailSender 实例,而是由框架提供一个。
图 10.2 使用依赖注入的依赖关系图。 UserController 间接依赖于所有其他类,但不需要知道如何创建它们。UserController 声明它需要 EmailSender,容器提供它。 |
---|
框架调用您的代码来处理请求的模式,而不是您自己编写代码来解析来自网卡上字节的请求。 DI 更进一步,您允许框架也创建您的依赖项:而不是您的 UserController 控制如何创建一个 EmailSender 实例,而是由框架提供一个。
当您看到它在多大程度上简化了依赖项的使用时,采用这种模式的优势就变得显而易见了。 以下清单显示了如果您使用 DI 创建 EmailSender 而不是手动创建,UserController 的外观。 所有新的东西都消失了,您可以完全专注于控制器正在做的事情——调用 EmailSender 并返回一个 OkResult。
清单 10.4 使用 DI 发送电子邮件以注入依赖项
public class UserController : ControllerBase
{
private readonly EmailSender _emailSender;
//不是隐式创建依赖项,而是通过构造函数注入它们。
public UserController(EmailSender emailSender)
{
_emailSender = emailSender;
}
[HttpPost("register")]
public IActionResult RegisterUser(string username) //动作方法很容易再次阅读和理解。
{
_emailSender.SendEmail(username);
return Ok();
}
}
DI 容器的优点之一是它只有一个职责:创建对象或服务。 您向容器请求服务实例,它会根据您的配置方式来确定如何创建依赖关系图。
注意 在谈论 DI 容器时通常会提到服务,这有点令人遗憾,因为它是软件工程中最重载的术语之一! 在这种情况下,服务是指 DI 容器在需要时创建的任何类或接口。
这种方法的美妙之处在于,通过使用显式依赖关系,您永远不必编写清单 10.3 中看到的乱七八糟的代码。 DI 容器可以检查您的服务的构造函数并确定如何编写大部分代码本身。 DI 容器始终是可配置的,因此如果您想描述如何手动创建服务实例,您可以,但默认情况下您不需要。
提示 您可以通过其他方式将依赖项注入服务; 例如,通过使用属性注入。 但是构造函数注入是最常见的,也是 ASP.NET Core 中唯一支持开箱即用的注入,所以我只会在本书中使用它。
希望在您的代码中使用 DI 的优势从这个快速示例中显而易见,但 DI 提供了您免费获得的额外好处。 特别是,它有助于通过对接口进行编码来保持代码松散耦合。
创建松散耦合的代码
耦合是面向对象编程中的一个重要概念。 它指的是给定类如何依赖其他类来执行其功能。 松散耦合的代码不需要知道很多关于特定组件的细节来使用它。
UserController 和 EmailSender 的初始示例是紧密耦合的示例; 您正在直接创建 EmailSender 对象,并且需要确切地知道如何连接它。 最重要的是,代码很难测试。 任何测试 UserController 的尝试都会导致发送一封电子邮件。 如果您使用一套单元测试来测试控制器,那将是让您的电子邮件服务器被列入垃圾邮件黑名单的可靠方法!
将 EmailSender 作为构造函数参数,去掉创建对象的责任,有助于减少系统中的耦合。 如果 EmailSender 实现发生更改,使其具有另一个依赖项,则您不再需要同时更新 UserController。
剩下的一个问题是 UserController 仍然与实现而不是接口相关联。 对接口进行编码是一种常见的设计模式,有助于进一步减少系统的耦合,因为您不依赖于单个实现。这对于使类可测试特别有用,因为您可以创建“存根(stub)”或“模拟(mock)”实现 用于测试目的的依赖项,如图 10.3 所示。
图 10.3 通过对接口进行编码而不是显式实现,您可以在不同的场景中使用不同的 IEmailSender 实现,例如单元测试中的 MockEmailSender。 |
---|
例如,您可以创建一个 IEmailSender 接口,EmailSender 将实现该接口:
public interface IEmailSender
{
public void SendEmail(string username);
}
然后,UserController 可以依赖此接口而不是特定的 EmailSender 实现,如下面的清单所示。 这将允许您在单元测试期间使用不同的实现,例如 DummyEmailSender。
清单 10.5 使用带有依赖注入的接口
public class UserController : ControllerBase
{
private readonly IEmailSender _emailSender;
//您现在依赖 IEmailSender 而不是特定的 EmailSender 实现。
public UserController(IEmailSender emailSender)
{
_emailSender = emailSender;
}
[HttpPost("register")]
public IActionResult RegisterUser(string username)
{
_emailSender.SendEmail(username); //你不关心实现是什么,只要它实现了 IEmailSender。
return Ok();
}
}
这里的关键点是消费代码 UserController 并不关心依赖项是如何实现的,只关心它实现了 IEmailSender 接口并公开了一个 SendEmail 方法。 应用程序代码现在独立于实现。
希望 DI 背后的原则看起来是合理的——通过松散耦合的代码,很容易完全改变或交换实现。 但这仍然给您留下一个问题:应用程序如何知道在生产中使用 EmailSender 而不是 DummyEmailSender? 告诉您的 DI 容器“当您需要 IEmailSender 时,使用 EmailSender”的过程称为注册。
定义 您向 DI 容器注册服务,以便它知道为每个请求的服务使用哪个实现。 这通常采用“对于接口 X,使用实现 Y”的形式。
使用 DI 容器注册接口和类型的确切方式可能因特定的 DI 容器实现而异,但原则通常都是相同的。 ASP.NET Core 包含一个开箱即用的简单 DI 容器,因此让我们看看它在典型请求期间是如何使用的。
ASP.NET Core 中的依赖注入
ASP.NET Core 从一开始就被设计为模块化和可组合的,具有几乎插件式的架构,通常由 DI 补充。 因此,ASP.NET Core 包含一个简单的 DI 容器,所有框架库都使用该容器来注册自身及其依赖项。
例如,此容器用于注册 Razor 页面和 Web API 基础架构——格式化程序、视图引擎、验证系统等。 它只是一个基本的容器,所以它只暴露了几个注册服务的方法,但你也可以将它替换为第三方 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。
使用依赖注入容器
在以前的 ASP.NET 版本中,使用依赖注入是完全可选的。 相反,要构建除最简单的 ASP.NET Core 应用程序之外的所有应用程序,需要一定程度的 DI。 正如我所提到的,底层框架依赖于它,所以像使用 Razor 页面和 API 控制器这样的事情需要你配置所需的服务。
在本节中,您将看到如何使用内置容器注册这些框架服务,以及如何注册您自己的服务。 注册服务后,您可以将它们用作依赖项并将它们注入应用程序中的任何服务中。
向容器添加 ASP.NET Core 框架服务
如前所述,ASP.NET Core 使用 DI 来配置其内部组件以及您自己的自定义服务。 要在运行时使用这些组件,DI 容器需要知道它需要的所有类。 您在 Startup 类的 ConfigureServices 方法中注册这些。
注意 依赖注入容器是在 Startup.cs 中的 Startup 类的 ConfigureServices 方法中设置的。
现在,如果你在想,“等等,我必须自己配置内部组件?”那么不要惊慌。 虽然在某种意义上是正确的——你确实需要在你的应用程序中使用容器显式注册组件——但你将使用的所有库都公开了方便的扩展方法来为你处理细节。 这些扩展方法一举配置你需要的一切,而不是让你手动连接所有东西。
例如,Razor Pages 框架公开了您在第 2、3 和 4 章中看到的 AddRazorPages() 扩展方法。在 Startup 的 ConfigureServices 中调用扩展方法。
清单 10.6 向 DI 容器注册 MVC 服务
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages(); //AddRazorPages 扩展方法将所有必要的服务添加到 IServiceCollection。
}
就这么简单。 在底层,这个调用是向 DI 容器注册多个组件,使用相同的 API 来注册您自己的服务。
提示 AddControllers() 方法为 API 控制器注册所需的服务,如您在第 9 章中所见。如果您使用带有 Razor 视图的 MVC 控制器,还有一个类似的方法 AddControllersWithViews(),还有一个 AddMvc() 方法来添加所有 他们和厨房水槽!
您添加到应用程序的大多数重要库都将具有您需要添加到 DI 容器的服务。 按照惯例,每个具有必要服务的库都应该公开一个可以在 ConfigureServices 中调用的 Add*() 扩展方法。
无法确切知道哪些库需要您向容器添加服务; 这通常是检查您使用的任何库的文档的情况。 如果您忘记添加它们,您可能会发现该功能不起作用,或者您可能会得到一个方便的异常,如图 10.5 所示。 请留意这些,并确保注册您需要的任何服务。
图 10.5 如果在 Startup 的 ConfigureServices 中调用 AddRazorPages 失败,您将在运行时收到友好的异常消息。 |
---|
还值得注意的是,一些 Add*() 扩展方法允许您在调用它们时指定其他选项,通常是通过 lambda 表达式。 您可以将这些视为将服务安装配置到您的应用程序中。 例如,如果您想弄脏手指,AddControllers 方法提供了大量用于微调其行为的选项,如图 10.6 中的 IntelliSense 片段所示。
一旦你添加了所需的框架服务,你就可以开始做生意并注册你自己的服务,这样你就可以在你自己的代码中使用 DI。
图 10.6 将服务添加到服务集合时配置服务。 AddControllers() 函数允许您配置 API 控制器服务的大量内部结构。 AddRazorPages() 函数中提供了类似的配置选项。 |
---|
向容器注册您自己的服务
在本章的第一部分,我描述了一个在新用户注册您的应用程序时发送电子邮件的系统。 最初,UserController 手动创建了一个 EmailSender 的实例,但您随后对其进行了重构,因此您将 IEmailSender 的实例注入到构造函数中。
使重构工作的最后一步是使用 DI 容器配置您的服务。 这让 DI 容器知道在需要满足 IEmailSender 依赖项时使用什么。 如果你不注册你的服务,你会在运行时得到一个异常,如图 10.7 中的那个。 幸运的是,这个异常很有用,让您知道哪个服务没有注册(IEmailSender)以及哪个服务需要它(UserController)。
为了完全配置应用程序,您需要将 EmailSender 及其所有依赖项注册到 DI 容器中,如图 10.8 所示。
配置 DI 包括对应用程序中的服务进行一系列声明。 例如,
当服务需要 IEmailSender 时,使用 EmailSender 的实例。
当服务需要 NetworkClient 时,使用 NetworkClient 的实例。
当服务需要 MessageFactory 时,使用 MessageFactory 的实例。
图 10.7 如果你没有在 ConfigureServices 中注册所有需要的依赖项,你会在运行时得到一个异常,告诉你哪个服务没有注册。 |
---|
图 10.8 在应用程序中配置 DI 容器包括告诉它在请求给定服务时使用什么类型; 例如,“需要 IEmailSender 时使用 EmailSender”。 |
---|
这些语句是通过在 ConfigureServices 方法中调用 IServiceCollection 上的各种 Add* 方法来生成的。 每种方法都向 DI 容器提供了三条信息:
服务类型——TService。 这是将作为依赖项请求的类或接口。 它通常是一个接口,例如 IEmailSender,但有时是一个具体的类型,例如 NetworkClient 或 MessageFactory。
实现类型——TService 或 TImplementation。 这是容器应该创建的类来满足依赖关系。 它必须是具体类型,例如 EmailSender。 它可能与服务类型相同,如 NetworkClient 和 MessageFactory。
生命周期——瞬态(transient)、单例(singleton)或作用域(scoped)。 这定义了服务实例应该使用多长时间。 我将在 10.3 节详细讨论生命周期。
以下清单显示了如何使用三种不同的方法在应用程序中配置 EmailSender 及其依赖项:AddScoped
清单 10.7 向 DI 容器注册服务
public void ConfigureServices(IServiceCollection services)
{
//您正在使用 API 控制器,因此您必须调用 AddControllers。
services.AddControllers();
//每当您需要 IEmailSender 时,请使用 EmailSender。
services.AddScoped<IEmailSender, EmailSender>();
//每当您需要 NetworkClient 时,请使用 NetworkClient。
services.AddScoped<NetworkClient>();
//每当您需要 MessageFactory 时,请使用 MessageFactory。
services.AddSingleton<MessageFactory>();
}
这就是依赖注入的全部内容! 它可能看起来有点像魔术,但您只是在向容器说明如何制作所有组成部分。 你给它一个如何煮辣椒、切生菜和磨碎奶酪的食谱,这样当你要墨西哥卷饼时,它就可以把所有的部分放在一起,然后把你的饭菜递给你!
NetworkClient 和 MessageFactory 的服务类型和实现类型相同,因此不需要在 AddScoped 方法中指定两次相同的类型,因此签名稍微简单一些。
注 EmailSender 实例仅注册为 IEmailSender,因此您无法通过请求特定的 EmailSender 实现来检索它;您必须使用 IEmailSender 接口。
这些通用方法并不是向容器注册服务的唯一方法。 您也可以直接提供对象或使用 lambdas,您将在下一节中看到。
使用对象和 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
() 方法注册任何类,并且容器将在整个应用程序中仅使用一个实例。我将在 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 对象的生命周期更改为作用域而不是单例,并提供了一个工厂 lambda 函数,该函数返回一个新的 EmailServerSettings 对象。 每次容器需要一个新的 EmailServerSettings 时,它都会执行该函数并使用它返回的新对象。
当您使用 lambda 注册服务时,会在运行时为您提供一个 IServiceProvider 实例,在清单 10.9 中称为 provider。 这是 DI 容器本身的公共 API,它公开了 GetService() 函数。 如果您需要获取依赖项来创建服务实例,您可以通过这种方式在运行时访问容器,但应尽可能避免这样做。
提示 如果可能,请避免在工厂函数中调用 GetService()。 相反,更喜欢构造函数注入——它更高效,也更容易推理。
开放泛型和依赖注入
如前所述,您不能将通用注册方法与 EmailServerSettings 一起使用,因为它在其构造函数中使用原始依赖项(在本例中为字符串)。 您也不能使用泛型注册方法来注册开放的泛型。
开放泛型是包含泛型类型参数的类型,例如 Repository
。 您通常使用这种类型来定义可以与多个泛型类型一起使用的基本行为。 在 Repository 示例中,您可以将 IRepository 注入到您的服务中,例如,它应该注入 DbRepository 的实例。 要注册这些类型,您必须使用 Add* 方法的不同重载。 例如,
services.AddScoped(typeof(IRespository<>), typeof(DbRepository<>));
这确保了每当服务构造函数需要 IRespository
时,容器都会注入 DbRepository 的实例。
此时,您的所有依赖项都已注册。 但是 ConfigureServices 开始看起来有点乱了,不是吗? 这完全取决于个人喜好,但我喜欢将我的服务分组为逻辑集合并为它们创建扩展方法,如下面的清单所示。 这创建了框架的 AddControllers() 扩展方法的等效项——一个漂亮、简单的注册 API。 随着您向应用程序添加越来越多的功能,我想您也会喜欢它。
清单 10.10 创建一个扩展方法来整理添加多个服务
public static class EmailSenderServiceCollectionExtensions
{
public static IServiceCollection AddEmailSender(
this IServiceCollection services)//使用“this”关键字在IServiceCollection 上创建扩展方法。
{
//从 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 案例。 在某些情况下,您可能会发现一个接口有多个实现。 在下一节中,您将看到如何将这些注册到容器中以满足您的要求。
多次在容器中注册服务
对接口进行编码的优点之一是您可以创建服务的多个实现。 例如,假设您想创建一个更通用的 IEmailSender 版本,以便您可以通过 SMS 或 Facebook 以及电子邮件发送消息。 你为它创建一个接口,
public interface IMessageSender
{
public void SendMessage(string message);
}
以及几个实现:EmailSender、SmsSender 和 FacebookSender。 但是如何在容器中注册这些实现呢? 以及如何将这些实现注入到您的 UserController 中? 答案略有不同,具体取决于您是要使用消费者中的所有实现还是只使用其中一个。
注入一个接口的多个实现
想象一下,每当新用户注册时,您想使用每个 IMessageSender 实现发送一条消息,以便他们收到一封电子邮件、一条 SMS 和一条 Facebook 消息,如图 10.10 所示。
图 10.10 当用户向您的应用程序注册时,他们调用 RegisterUser 方法。 这会使用 IMessageSender 类向他们发送电子邮件、SMS 和 Facebook 消息。 |
---|
实现这一点的最简单方法是在 DI 容器中注册所有服务实现,并将每种类型中的一种注入 UserController。 然后 UserController 可以使用一个简单的 foreach 循环在每个实现上调用 SendMessage(),如图 10.11 所示。
图 10.11 您可以使用 DI 容器注册服务的多个实现,例如本例中的 IEmailSender。 您可以通过在 UserController 构造函数中要求 IEnumerable |
---|
使用 Add* 扩展方法,以与单个实现完全相同的方式向 DI 容器注册同一服务的多个实现。 例如,
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddScoped<IMessageSender, EmailSender>();
services.AddScoped<IMessageSender, SmsSender>();
services.AddScoped<IMessageSender, FacebookSender>();
}
然后,您可以将 IEnumerable
清单 10.11 将服务的多个实现注入消费者
public class UserController : ControllerBase
{
//请求一个 IEnumerable 将注入一个 IMessageSender 数组。
private readonly IEnumerable<IMessageSender> _messageSenders;
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[] 或 ICollection 作为 你的构造函数参数。 这样做会导致 InvalidOperationException,类似于图 10.7 中的情况。
注入服务的所有注册实现很简单,但如果你只需要一个呢? 容器如何知道使用哪一个?
注册多个服务时注入单个实现
假设您已经注册了所有 IMessageSender 实现; 如果您的服务只需要其中一个,会发生什么? 例如,
public class SingleMessageSender
{
private readonly IMessageSender _messageSender;
public SingleMessageSender(IMessageSender messageSender)
{
_messageSender = messageSender;
}
}
容器需要从三个可用的实现中选择一个 IMessageSender 来注入此服务。 它通过使用最后注册的实现——上一个示例中的 FacebookSender 来做到这一点。
注 DI 容器在解析服务的单个实例时将使用服务的最后注册实现
这对于用您自己的服务替换内置 DI 注册特别有用。 如果您有一个您知道已在库的 Add* 扩展方法中注册的服务的自定义实现,您可以通过之后注册您自己的实现来覆盖该注册。 每当请求服务的单个实例时,DI 容器将使用您的实现。
这种方法的主要缺点是你最终仍然注册了多个实现——你可以像以前一样注入一个 IEnumerable
使用 TRYADD 有条件地注册服务
有时,您只想添加尚未添加的服务实现。 这对图书馆作者特别有用; 他们可以创建接口的默认实现,并且仅在用户尚未注册自己的实现时才注册它。
您可以在 Microsoft.Extensions.DependencyInjection.Extensions 命名空间中找到几种用于条件注册的扩展方法,例如 TryAddScoped。 这会检查以确保在对实现调用 AddScoped 之前没有向容器注册服务。 以下清单显示了如何有条件地添加 SmsSender,前提是没有现有的 IMessageSender 实现。由于您之前注册了 EmailSender,容器将忽略 SmsSender 注册,因此它在您的应用程序中不可用。
清单 10.12 使用 TryAddScoped 有条件地添加服务
public void ConfigureServices(IServiceCollection services)
{
//EmailSender 已在容器中注册。
services.AddScoped<IMessageSender, EmailSender>();
//已经有一个 IMessageSender 实现,所以 SmsSender 没有注册。
services.TryAddScoped<IMessageSender, SmsSender>();
}
像这样的代码在应用程序级别通常没有多大意义,但如果您正在构建用于多个应用程序的库,它可能会很有用。 例如,ASP.NET Core 框架在许多地方使用 TryAdd*,这使您可以根据需要在自己的应用程序中轻松注册内部组件的替代实现。
您还可以使用 Replace() 扩展方法替换以前注册的实现。 不幸的是,此方法的 API 不如 TryAdd 方法友好。 要将以前注册的 IMessageSender 替换为 SmsSender,您可以使用
services.Replace(new ServiceDescriptor(
typeof(IMessageSender), typeof(SmsSender), ServiceLifetime.Scoped
));
提示 使用 Replace 时,您必须提供与注册要替换的服务相同的生命周期。
这几乎涵盖了注册依赖项。 在我们更深入地研究依赖项的“生命周期”方面之前,我们将绕道而行,看看除了构造函数之外的两种方法来在你的应用程序中注入依赖项。
将服务注入到操作方法、页面处理程序和视图中
我在 10.1 节中提到 ASP.NET Core DI 容器仅支持构造函数注入,但还有另外三个位置可以使用依赖注入:
- 行动方法
- 页面处理方法
- 查看模板
在本节中,我将简要讨论这三种情况,它们是如何工作的,以及您何时可能想要使用它们。
使用 [FROMSERVICES] 将服务直接注入操作方法和页面处理程序
API 控制器通常包含逻辑上属于一起的多个操作方法。例如,您可以将与管理用户帐户相关的所有操作方法分组到同一个控制器中。 这允许您将过滤器和授权一起应用于所有操作方法,正如您将在第 13 章中看到的那样。
当您向控制器添加额外的操作方法时,您可能会发现控制器需要额外的服务来实现新的操作方法。 使用构造函数注入,所有这些依赖项都是通过构造函数提供的。 这意味着 DI 容器必须为控制器中的每个操作方法创建所有依赖项,即使被调用的操作方法都不需要它们。
例如,考虑清单 10.13。 这显示了具有两个存根方法的 UserController:RegisterUser 和 PromoteUser。 每个动作方法都需要不同的依赖项,因此无论请求调用哪个动作方法,都将创建和注入两个依赖项。 如果 IPromotionService 或 IMessageSender 本身有很多依赖项,则 DI 容器可能必须为经常不使用的服务创建大量对象。
清单 10.13 通过构造函数将服务注入控制器
public class UserController : ControllerBase
{
private readonly IMessageSender _messageSender;
//IMessageSender 和 IPromotionService每次都注入到构造函数中。
private readonly IPromotionService _promoService;
public UserController(
IMessageSender messageSender, IPromotionService promoService)
{
_messageSender = messageSender;
_promoService = promoService;
}
[HttpPost("register")]
public IActionResult RegisterUser(string username)
{
//RegisterUser 方法仅使用 IMessageSender。
_messageSender.SendMessage(username);
return Ok();
}
[HttpPost("promote")]
public IActionResult PromoteUser(string username, int level)
{
//PromoteUser 方法仅使用 IPromotionService。
_promoService.PromoteUser(username, level);
return Ok();
}
}
如果您知道创建服务的成本特别高,您可以选择将其作为依赖项直接注入到操作方法中,而不是注入到控制器的构造函数中。这样可以确保 DI 容器仅在调用特定操作方法时创建依赖项, 与调用控制器上的任何操作方法时相反。
注 一般来说,您的控制器应该具有足够的凝聚力,以至于不需要这种方法。 如果你发现你有一个依赖于许多服务的控制器,每个服务都由一个操作方法使用,你可能需要考虑拆分你的控制器。
您可以通过将依赖项作为参数传递给方法并使用 [FromServices] 属性直接将依赖项注入到操作方法中。 在模型绑定期间,框架将从 DI 容器中解析参数,而不是从请求值中解析。 此清单显示了如何重写清单 10.13 以使用 [FromServices] 而不是构造函数注入。
清单 10.14 使用 [FromServices] 属性将服务注入控制器
public class UserController : ControllerBase
{
//[FromServices] 属性确保 IMessageSender 从 DI 容器中解析。
[HttpPost("register")]
public IActionResult RegisterUser(
[FromServices] IMessageSender messageSender,
string username)
{
//IMessageSender 仅在 RegisterUser 中可用。
messageSender.SendMessage(username);
return Ok();
}
//IPromotionService 从 DI 容器中解析并作为参数注入。
[HttpPost("promote")]
public IActionResult PromoteUser(
[FromServices] IPromotionService promoService,
string username, int level)
{
//只有 PromoteUser 方法可以使用 IPromotionService。
promoService.PromoteUser(username, level);
return Ok();
}
}
您可能很想在所有操作方法中使用 [FromServices] 属性,但我鼓励您在大多数情况下使用标准构造函数注入。将构造函数作为声明类的所有依赖项的单个位置可以是 很有用,所以我只在极少数情况下使用 [FromServices],即创建依赖项的实例很昂贵并且仅用于单个操作方法中。
[FromServices] 属性可以以与 Razor 页面完全相同的方式使用。 您可以将服务注入 Razor Page 的页面处理程序,而不是注入构造函数,如清单 10.15 所示。
注 仅仅因为您可以像这样将服务注入页面处理程序并不意味着您应该这样做。 Razor Pages 本质上设计为小而有凝聚力,因此最好只使用构造函数注入。
清单 10.15 使用 [FromServices] 属性将服务注入 Razor 页面
public class PromoteUserModel: PageModel
{
public void OnGet() //OnGet 处理程序不需要任何服务。
{
}
public IActionResult OnPost(
//IPromotionService 从 DI 容器中解析并作为参数注入。
[FromServices] IPromotionService promoService,
string username, int level)
{
//只有 OnPost 页面处理程序可以使用 IPromotionService。
promoService.PromoteUser(username, level);
return RedirectToPage("success");
}
}
一般来说,如果您发现需要使用 [FromServices] 属性,您应该退后一步,查看您的控制器/Razor Page。 你很可能在一堂课上做的太多了。 与其解决 [FromServices] 的问题,不如考虑拆分类或将某些行为下推到您的应用程序模型服务中。
将服务注入视图模板
推荐将依赖注入到构造函数中,但是如果你没有构造函数怎么办? 特别是,当您无法控制模板的构建方式时,如何将服务注入 Razor 视图模板?
想象一下,您有一个简单的服务 HtmlGenerator,可以帮助您在视图模板中生成 HTML。 问题是,假设您已经在 DI 容器中注册了该服务,如何将它传递给您的视图模板?
一种选择是使用构造函数注入将 HtmlGenerator 注入 Razor 页面,并将服务公开为 PageModel 上的属性,如您在第 7 章中所见。这通常是最简单的方法,但在某些情况下您可能不想拥有 完全引用 PageModel 中的 HtmlGenerator 服务。 在这些情况下,您可以直接将 HtmlGenerator 注入到您的视图模板中。
注 有些人对以这种方式向视图中注入服务感到不满。您绝对不应该将与业务逻辑相关的服务注入到您的视图中,但我认为与 HTML 生成相关的服务是有意义的。
您可以使用 @inject 指令将服务注入到 Razor 模板中,方法是在模板中提供要注入的类型和注入服务的名称。
清单 10.16 使用 @inject 将服务注入 Razor 视图模板
@inject HtmlGenerator htmlHelper //将 HtmlGenerator 的实例注入到视图中,名为 htmlHelper
<h1>The page title</h1>
<footer>
@htmlHelper.Copyright() //通过调用 htmlHelper 实例使用注入的服务
</footer>
将服务直接注入视图是一种将 UI 相关服务暴露给视图模板的有用方法,而无需依赖 PageModel 中的服务。 你可能不会发现你需要过多地依赖它,但它是一个有用的工具。
这几乎涵盖了注册和使用依赖项,但我只是模糊地提到了一个重要方面:生命周期,或者容器何时创建服务的新实例? 了解生命周期对于使用 DI 容器至关重要,因此在向容器注册服务时密切关注它们非常重要。
了解生命周期:何时创建服务?
每当向 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,它显示数据库用户表中的行数。 出于本示例的目的,我们通过在构造函数中设置行数来模拟调用数据库,因此每次在给定 DataContext 实例上调用 RowCount 时都会得到相同的值。 DataContext 的不同实例将返回不同的 RowCount 值。
清单 10.17 DataContext 在其构造函数中生成随机 RowCount
public class DataContext
{
static readonly Random _rand = new Random();
public DataContext()
{
//生成 1 到 1,000,000,000 之间的随机数
RowCount = _rand.Next(1, 1_000_000_000);
}
//构造函数中设置的只读属性,所以它总是返回相同的值
public int RowCount { get; }
}
您还有一个依赖于 DataContext 的 Repository 类,如下面的清单所示。 这也公开了一个 RowCount 属性,但该属性将调用委托给它的 DataContext 实例。 无论创建 DataContext 的值是什么,存储库都将显示相同的值。
清单 10.18 依赖于 DataContext 实例的存储库服务
public class Repository
{
//DataContext 的一个实例是使用 DI 提供的。
private readonly DataContext _dataContext;
public Repository(DataContext dataContext)
{
_dataContext = dataContext;
}
//RowCount 返回与 DataContext 的当前实例相同的值
public int RowCount => _dataContext.RowCount;
}
最后,您拥有 Razor Page RowCountModel,它直接依赖于 Repository 和 DataContext。 当 Razor Page 激活器创建 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;
}
public void OnGet()
{
//调用时,页面处理程序从两个依赖项中检索并记录 RowCount。
DataContextCount = _dataContext.RowCount;
RepositoryCount = _repository.RowCount;
}
//计数在 PageModel 上公开,并在 Razor 视图中呈现为 HTML。
public int DataContextCount { get; set ;}
public int RepositoryCount { get; set ;}
}
此示例的目的是探索四个 DataContext 实例之间的关系,具体取决于您用于向容器注册服务的生命周期。 我在 DataContext 中生成一个随机数作为唯一标识 DataContext 实例的一种方式,但您可以将其视为登录到您网站的用户数量的时间点快照,例如,或者 仓库中的库存量。
我将从最短的生命周期开始,瞬态的,然后转到常见的作用域生命周期,然后看看单例。 最后,我将展示一个在您自己的应用程序中注册服务时应该注意的重要陷阱。
瞬态(Transient):每个人都是独一无二的
在 ASP.NET Core DI 容器中,临时服务总是在需要满足依赖关系时创建新的。 您可以使用 AddTransient 扩展方法注册您的服务:
services.AddTransient<DataContext>();
services.AddTransient<Repository>();
以这种方式注册时,每次需要依赖项时,容器都会创建一个新依赖项。 这既适用于请求之间,也适用于请求内; 注入 Repository 的 DataContext 将与注入 RowCountModel 的实例不同。
注 瞬态(Transient)依赖可能导致单个依赖图中相同类型的不同实例。
图 10.13 显示了当您对两个服务都使用瞬态生命周期时,您从两个连续请求中获得的结果。 请注意,默认情况下,Razor 页面和 API 控制器实例也是瞬态的,并且总是重新创建。
图 10.13 当使用瞬态生命周期注册时,所有四个 DataContext 对象都是不同的。 这可以从两个请求过程中显示的四个不同数字看出。 |
---|
瞬态生命周期会导致创建大量对象,因此它们对于状态很少或没有状态的轻量级服务最有意义。 这相当于每次你需要一个新对象时都调用 new ,所以在使用它时要记住这一点。 您可能不会太频繁地使用瞬态生命周期; 您的大部分服务可能会改为范围。
范围(Scoped):让我们团结起来
作用域生命周期表明对象的单个实例将在给定作用域内使用,但在不同作用域之间将使用不同的实例。 在 ASP.NET Core 中,范围映射到请求,因此在单个请求中,容器将使用相同的对象来满足所有依赖项。
对于行计数示例,这意味着在单个请求(单个范围)内,将在整个依赖关系图中使用相同的 DataContext。 注入 Repository 的 DataContext 将与注入 RowCountModel 的实例相同。
在下一个请求中,您将处于不同的范围内,因此容器将创建一个新的 DataContext 实例,如图 10.14 所示。 如您所见,不同的实例意味着每个请求的不同 RowCount。
您可以使用 AddScoped 扩展方法将依赖项注册为作用域。在此示例中,我将 DataContext 注册为作用域并将 Repository 保留为瞬态,但在这种情况下,如果它们都是作用域的,您将获得相同的结果:
services.AddScoped<DataContext>();
图 10.14 作用域依赖在单个请求中使用相同的 DataContext 实例,但为单独的请求使用新实例。 因此,请求中的 RowCounts 是相同的。 |
---|
由于 Web 请求的性质,您经常会发现在 ASP.NET Core 中注册为作用域依赖项的服务。 数据库上下文和身份验证服务是应将范围限定为请求的服务的常见示例 — 您希望在单个请求中跨服务共享但需要在请求之间更改的任何内容。
一般来说,你会发现很多服务都是使用作用域生命周期注册的——尤其是任何使用数据库或依赖于特定请求的服务。但有些服务不需要在请求之间更改,例如计算 圆形区域或返回不同时区的当前时间。 对于这些,单例生命周期可能更合适。
单例(Singleton:):只能有一个
单例是依赖注入之前出现的一种模式; DI 容器提供了一个健壮且易于使用的实现。 单例在概念上很简单:在第一次需要时(或在注册期间,如 10.2.3 节)创建服务实例,仅此而已。 您将始终将相同的实例注入到您的服务中。
单例模式对于创建成本高昂、包含必须跨请求共享的数据或不保持状态的对象特别有用。后两点很重要——任何注册为单例的服务都应该是线程安全的 .
警告 单例服务在 Web 应用程序中必须是线程安全的,因为它们通常会在并发请求期间被多个线程使用
让我们考虑一下使用单例对于行计数示例意味着什么。 我可以将 DataContext 的注册更新为 ConfigureServices 中的单例:
services.AddSingleton<DataContext>();
然后我们可以调用 RowCountModel Razor Page 两次并观察图 10.15 中的结果。 可以看到每个实例都返回了相同的值,说明DataContext的四个实例都是同一个实例。
图 10.15 任何注册为单例的服务将始终返回相同的实例。 因此,对 RowCount 的所有调用都返回相同的值,无论是在请求内还是在请求之间。 |
---|
单例对于需要共享或不可变且创建成本高的对象很方便。 缓存服务应该是单例的,因为所有请求都需要共享它。 它必须是线程安全的。 同样,如果您在启动时加载设置一次并在应用程序的整个生命周期中重用它们,则可以将从远程服务器加载的设置对象注册为单例。
从表面上看,为服务选择生命周期似乎并不太棘手,但有一个重要的“陷阱”可能会以微妙的方式反过来咬你,你很快就会看到。
密切关注捕获的依赖项
假设您正在为 DataContext 和 Repository 示例配置生命周期。您考虑我提供的建议并决定以下生命周期:
- DataContext——作用域,因为它应该为单个请求共享
- Repository——单例,因为它没有自己的状态并且是线程安全的,为什么不呢?
警告 这个生命周期配置是为了探索一个错误——不要在你的代码中使用它,否则你会遇到类似的问题!
不幸的是,您创建了一个捕获的依赖项,因为您将一个作用域对象 DataContext 注入到一个单例 Repository 中。 因为它是一个单例,所以在应用程序的整个生命周期中都会使用同一个 Repository 实例,因此注入到其中的 DataContext 也会一直存在,即使每个请求都应该使用一个新实例。 图 10.16 显示了这种情况,其中为每个范围创建了一个新的 DataContext 实例,但 Repository 中的实例在应用程序的生命周期内一直存在。
图 10.16 DataContext 被注册为一个作用域依赖,但 Repository 是一个单例。 即使您希望每个请求都有一个新的 DataContext,Repository 仍会捕获注入的 DataContext 并使其在应用程序的生命周期内被重用。 |
---|
捕获的依赖项可能会导致难以根除的细微错误,因此您应该始终留意它们。 这些捕获的依赖关系比较容易引入,所以在注册单例服务时要慎重考虑。
警告 服务应该只使用生命周期长于或等于服务生命周期的依赖项。 注册为单例的服务只能安全地使用单例依赖项。 注册为作用域的服务可以安全地使用作用域或单例依赖项。 瞬态服务可以使用任何生命周期的依赖项。
在这一点上,我应该提到,在这个警示故事中有一线希望。 ASP.NET Core 会自动检查这些捕获的依赖关系,如果检测到它们会在应用程序启动时抛出异常,如图 10.17 所示。
图 10.17 启用 ValidateScopes 后,DI 容器在创建具有捕获依赖项的服务时会抛出异常。 默认情况下,此检查仅对开发环境启用。 |
---|
这种范围验证检查会对性能产生影响,因此默认情况下,它仅在您的应用程序在开发环境中运行时启用,但它应该可以帮助您发现大多数此类问题。 在 Program.cs 中创建 HostBuilder 时,您可以通过设置 ValidateScopes 选项来启用或禁用此检查,而不考虑环境,如清单 10.20 所示。
清单 10.20 将 ValidateScopes 属性设置为始终验证范围
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
//默认构建器将 ValidateScopes 设置为验证仅在开发中您可以覆盖环境。
Host.CreateDefaultBuilder(args)
//您可以覆盖环境。 使用 UseDefaultServiceProvider 扩展进行验证检查。
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.UseDefaultServiceProvider(options =>
{
//将此设置为 true 将验证所有环境中的范围。 这对性能有影响。
options.ValidateScopes = true;
//ValidateOnBuild 检查每个已注册的服务是否已注册其所有依赖项。
options.ValidateOnBuild = true;
});
}
清单 10.20 显示了另一个可以启用的设置,ValidateOnBuild,它更进一步。 启用后,DI 容器会在应用程序启动时检查它是否为需要构建的每个服务注册了依赖项。 如果没有,它会抛出异常,如图 10.18 所示,让您知道配置错误。这也会影响性能,因此默认情况下仅在开发环境中启用,但对于指出任何遗漏非常有用 服务注册。
图 10.18 启用 ValidateOnBuild 后,DI 容器将在应用启动时检查它是否可以创建所有已注册的服务。 如果它找到一个它不能创建的服务,它就会抛出一个异常。 默认情况下,此检查仅对开发环境启用。 |
---|
至此,您已经完成了对 ASP.NET Core 中 DI 的介绍。 您现在知道如何使用 Add* 扩展方法(如 AddRazorPages())将框架服务添加到您的应用程序,以及如何使用 DI 容器注册您自己的服务。 希望这将帮助您保持代码松散耦合且易于管理。
在下一章中,我们将了解 ASP.NET Core 配置模型。 您将看到如何在运行时从文件中加载设置,如何安全地存储敏感设置,以及如何根据运行的机器使应用程序的行为有所不同。 我们甚至会使用一些 DI; 它在 ASP.NET Core 中无处不在!
总结
- 依赖注入已融入 ASP.NET Core 框架。 您需要确保您的应用程序在 Startup 中添加了所有框架的依赖项,否则当 DI 容器找不到所需的服务时,您将在运行时遇到异常。
- 依赖图是为了创建特定请求的“root”对象而必须创建的对象集。 DI 容器会为您创建所有这些依赖项。
- 在大多数情况下,您的目标应该是使用显式依赖而不是隐式依赖。 ASP.NET Core 使用构造函数参数来声明显式依赖项。
- 在讨论 DI 时,术语服务用于描述向容器注册的任何类或接口。
- 您使用 DI 容器注册服务,以便它知道每个请求的服务使用哪个实现。 这通常采用“对于接口 X,使用实现 Y”的形式。
- DI 或 IoC 容器负责创建服务实例。 它知道如何通过创建所有服务的依赖项并将它们传递给服务构造函数来构造服务的实例。
- 默认的内置容器只支持构造函数注入。 如果您需要其他形式的 DI,例如属性注入,则可以使用第三方容器。
- 您必须通过在 Startup 的 ConfigureServices 中调用 IServiceCollection 上的 Add* 扩展方法来向容器注册服务。 如果您忘记注册框架使用或在您自己的代码中使用的服务,您将在运行时收到 InvalidOperationException。
- 在注册服务时,您需要描述三件事:服务类型、实现类型和生命周期。 服务类型定义将请求哪个类或接口作为依赖项。 实现类型是容器应该创建的类来实现依赖。 生命周期是服务实例应该使用多长时间。
- 如果类是具体的并且它的所有构造函数参数都在容器中注册或具有默认值,则可以使用泛型方法注册服务。
- 您可以在注册期间提供服务的实例,这会将该实例注册为单例。 当您已经有可用的服务实例时,这可能很有用。
- 您可以提供一个 lambda 工厂函数,该函数描述如何创建具有您选择的任何生命周期的服务实例。 当您的服务依赖于只有在您的应用程序运行后才能访问的其他服务时,您可以使用这种方法。
- 如果可能,请避免在工厂函数中调用 GetService()。 相反,支持构造函数注入——它更高效,也更易于推理。
- 您可以为一个服务注册多个实现。 然后,您可以注入 IEnumerable
以在运行时访问所有实现。 - 如果您注入多个注册服务的单个实例,则容器会注入最后注册的实现。
- 您可以使用 TryAdd* 扩展方法来确保仅在未注册服务的其他实现时才注册实现。这对于库作者添加默认服务同时仍允许消费者覆盖已注册的服务很有用。
- 您可以在 DI 服务注册期间定义服务的生命周期。 这决定了 DI 容器何时重用现有服务实例来满足服务依赖关系,以及何时创建新实例。
- 瞬态生命周期意味着每次请求服务时,都会创建一个新实例。
- 作用域生命周期意味着在一个作用域内所有对服务的请求都会给你相同的对象。 对于不同的范围,你会得到不同的对象。 在 ASP.NET Core 中,每个 Web 请求都有自己的范围。
- 无论在哪个范围内,您都将始终获得相同的单例服务实例。
- 服务应该只使用生命周期长于或等于服务生命周期的依赖项。