.NET Core 依赖注入:你应该知道的一切
TLDR;
我从构建 Coravel 中学到的东西:第 3 部分
什么是依赖注入?
为什么我们应该将依赖项作为参数传递?
什么是依赖注入(重温)
快速查看测试
快速了解模块化
避免类继承
在 .NET Core 中使用依赖注入
现实世界的例子
真实世界测试
关于使用 .NET Core 依赖注入你应该知道些什么
结论
导航您的软件开发职业通讯
如果您查看ASP.NET Core 的官方文档,您会发现依赖注入位于“基础”区域下。
这是真的吗?这真的是根本性的吗?
最初发表在我的博客上。
TLDR;
依赖注入是 .NET Core 的原生特性。它存在是有原因的:它通常可以促进最佳编码实践,并为开发人员提供构建可维护、模块化和可测试软件的工具。
它还为库作者提供了工具,可以帮助使他们的库的安装/配置变得非常简单和直接。
我从构建 Coravel 中学到的东西:第 3 部分
这是正在进行的系列的第三部分。其他版本包括:
- 第 1 部分:到目前为止我在构建 Coravel(开源 .NET Core 工具)中所学到的知识
- 第 2 部分:流畅的 API 让开发人员爱上使用 .NET 库(builtwithdot.net 上的客座文章)
正如您所猜测的,本文将介绍我在 .NET Core 中学习到的一些有关 DI 的知识,以及我对您应该了解的内容的建议。😊
首先,我想为那些可能不太熟悉依赖注入的人探索一下DI。我们将从基础知识开始,然后逐步介绍一些更高级的场景。
如果您已经知道什么是 DI,以及如何使用接口来模拟您的类并测试它们等,那么您可以转到在 .NET Core 中使用依赖注入部分。
是的,这会很长。做好准备吧。😎
什么是依赖注入?
如果您不熟悉 DI,它实际上只是指将依赖项作为外部参数传递到您的对象中。
这可以通过对象的构造函数或方法来完成。
// Argument "dep" is "injected" as a dependency.
public MyClass(ExternalDependency dep)
{
this._dep = dep;
}
那么,依赖注入从根本上来说,就是将依赖项作为参数传递。就是这样。仅此而已。
好吧...如果这真的是DI 的全部 - 我就不会写这个了。😜
为什么我们应该将依赖项作为参数传递?
你为什么要这么做?有几个原因:
- 提倡将逻辑拆分为多个较小的类和/或结构
- 提高代码可测试性
- 提倡使用抽象,从而实现更加模块化的代码结构
让我们简单看一下这种想法如何提高可测试性(这反过来又会影响所有其他提到的要点)。
我们为什么要测试代码?为了确保我们的系统正常运行。
这意味着您可以信任您的代码。
如果没有测试,你就不能真正信任你的代码。
我在另一篇关于重构遗留整体的博客文章中更详细地讨论了这个问题- 在那里我讨论了一些围绕这个问题的重构技术。
什么是依赖注入(重温)
当然,DI 不仅仅是“传递参数”。依赖注入是一种机制,运行时(比如 .NET Core)会自动将所需的依赖项传递(注入)到你的类中。
我们为什么需要它?
看看这个简单的类:
public class Car
{
public Car(Engine engine)
{
this._engine = engine;
}
}
如果在其他地方我们需要这样做该怎么办?
Car ferrari = new Car(new Engine());
太好了。如果我们想测试这个Car
类怎么办?
问题在于,为了测试,Car
你需要Engine
。如果你愿意的话,这是一个“硬”依赖。
这意味着这些类紧密地联系在一起。换句话说,紧耦合。这可不是个好词。
我们希望类是松耦合的。这使得我们的代码更加模块化、通用化,也更易于测试(这意味着更高的信任度和更大的灵活性)。
快速查看测试
测试时常用的一些技巧是使用“模拟”。模拟就是一个存根类,用来“假装”一个真实的实现。
我们不能模拟具体类。但是,我们可以模拟接口!
让我们改为Car
依赖接口:
public class Car
{
public Car(IEngine engine)
{
this._engine = engine;
}
}
太棒了!我们来测试一下:
// Mock code configuration would be here.
// "mockEngine" is just a stubbed IEngine.
Car ferrari = new Car(mockEngine);
Assert.IsTrue(ferrari.IsFast());
所以现在我们正在测试没有严格依赖的Car
类。👍Engine
快速了解模块化
我之前提到过,使用 DI 可以让你的代码模块化。其实,这并非 DI 的真正作用,而是上面提到的那种技术(依赖接口)。
比较以下两个例子:
Car ferrari = new Car(new FastEngine());
和
Car civic = new Car(new HondaEngine());
由于我们依赖于接口,因此我们在制造汽车类型方面拥有更大的灵活性!
避免类继承
另一个好处是您不需要使用类继承。
我发现类继承经常被滥用。因此,我尽量“永远”不去使用类继承。
它很难测试,很难理解,并且通常会导致构建错误的模型,因为事后很难改变。
99% 的时间里,有更好的方法来使用这样的模式来构建代码 - 依赖于抽象而不是紧密耦合的类。
是的,类继承是代码中耦合度最高的关系!(不过那是另一篇博文了😉 )
在 .NET Core 中使用依赖注入
上面的例子强调了我们为什么需要 DI。
依赖注入允许我们“绑定”一个特定的类型,以便全局使用,例如,代替一个特定的接口。
在运行时,我们依赖 DI 系统为我们创建这些对象的新实例。所有依赖关系均自动处理。
在 .NET Core 中,您可能会做这样的事情来告诉 DI 系统在请求某些接口等时我们想要使用哪些类。
// Whenever the type 'Car' is asked for we get a new instance of the 'Car' type.
services.AddTransient<Car, Car>();
// Whenever the type 'IEngine' is asked for we get a new instance of the concrete 'HondaEngine' type.
services.AddTransient<IEngine, HondaEngine>();
Car
依赖于IEngine
。
当 DI 系统尝试“构建”(实例化)一个新的时,Car
它将首先抓取一个new HondaEngine()
,然后将其注入到中new Car()
。
每当我们需要一个Car
.NET Core 的 DI 系统时,它都会自动帮我们搞定!所有依赖项都会级联。
因此,在 MVC 控制器中我们可以这样做:
public CarController(Car car)
{
this._car = car; // Car will already be instantiated by the DI system using the 'HondaEngine' as the engine.
}
现实世界的例子
好吧,汽车的例子很简单。这只是一些基础知识。我们来看一个更现实的场景。
做好准备。😎
我们有一个在应用程序中创建新用户的用例:
public class CreateUser
{
// Filled out later...
}
该用例需要发出一些数据库查询来保留新用户。
为了使其可测试 - 并确保我们可以在不需要数据库作为依赖项的情况下测试我们的代码- 我们可以使用已经讨论过的技术:
public interface IUserRepository
{
public Task<int> CreateUserAsync(UserModel user);
}
以及影响数据库的具体实现:
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _dbContext;
public UserRepository(ApplicationDbContext dbContext) =>
this._dbContext = dbContext;
public async Task<int> CreateUserAsync(UserModel user)
{
this._dbContext.Users.Add(user);
await this._dbContext.SaveChangesAsync();
}
}
使用 DI,我们会得到如下结果:
services.AddTransient<CreateUser, CreateUser>();
services.AddTransient<IUserRepository, UserRepository>();
每当我们有一个需要实例的类时,IUserRepository
DI 系统就会自动new
UserRepository
为我们构建一个。
同样可以说CreateUser
-CreateUser
当我们被要求时,将会给我们一个新的(连同已经注入的所有依赖项)。
现在,在我们的用例中我们这样做:
public class CreateUser
{
private readonly IUserRepository _repo;
public CreateUser(IUserRepository repo) =>
this._repo = repo;
public async Task InvokeAsync(UserModel user)
{
await this._repo.CreateUserAsync(user);
}
}
在 MVC 控制器中,我们可以“询问”CreateUser
用例:
public class CreateUserController : Controller
{
private readonly CreateUser _createUser;
public CreateUserController(CreateUser createUser) =>
this._createUser = createUser;
[HttpPost]
public async Task<ActionResult> Create(UserModel userModel)
{
await this._createUser.InvokeAsync(userModel);
return Ok();
}
}
DI 系统将自动:
- 尝试创建 的新实例
CreateUser
。 - 由于
CreateUser
依赖于IUserRepository
接口,DI 系统接下来将查看是否有与该接口“绑定”的类型。 - 是的——它是具体的
UserRepository
。 - 创建一个新的
UserRepository
。 - 将其传递到 new
CreateUser
作为其构造函数参数的实现IUserRepository
。
一些显而易见的好处:
- 您的代码更加模块化和灵活(如上所述)
- 您的控制器等(无论使用什么 DI)变得更加简单和易于阅读。
真实世界测试
最后一个好处是,我们无需访问数据库就可以进行测试。
// Some mock configuration...
var createUser = new CreateUser(mockUserRepositoryThatReturnsMockData);
int createdUserId = await createUser.InvokeAsync(dummyUserModel);
Assert.IsTrue(createdUserId == expectedCreatedUserId);
这使得:
- 快速测试(无数据库)
- 隔离测试(仅重点测试中的代码
CreateUser
)
关于使用 .NET Core 依赖注入你应该知道些什么
现在我想介绍一些您应该知道的更正确和技术性的术语,以及有关 .NET Core 的 DI 系统的推荐知识。
服务提供商
当我们提到“DI 系统”时,我们实际上是在谈论服务提供商。
在其他框架或 DI 系统中,这也被称为服务容器。
这是保存所有 DI 内容配置的对象。
它最终也会被“要求”为我们创建新的对象。因此,它负责确定每个服务在运行时需要哪些依赖项。
绑定
当我们谈论绑定时,我们只是意味着类型A
映射到类型B
。
在我们关于该Car
场景的例子中,我们会说IEngine
必然为HondaEngine
。
当我们请求的依赖项时,IEngine
我们会返回的实例HondaEngine
。
解析
解析是指找出特定服务需要哪些依赖项的过程。
使用上面的用例示例CreateUser
,当要求服务提供商注入一个实例时,CreateUser
我们会说提供商正在“解决”该依赖关系。
解析涉及找出整个依赖关系树:
CreateUser
需要一个实例IUserRepository
- 提供商认为这
IUserRepository
必然UserRepository
UserRepository
需要一个实例ApplicationDbContext
- 提供者看到它
ApplicationDbContext
是可用的(并且绑定到相同类型)。
找出级联依赖关系树就是我们所说的“解析服务”。
作用域
通常称为范围,或称为服务寿命,这指的是服务寿命是短还是长。
例如,单例(按照模式定义)是一种每次都会解析为同一实例的服务。
如果不了解范围,您可能会遇到一些非常奇怪的错误。😜
.NET Core DI 系统有 3 种不同的范围:
单例模式
services.AddSingleton<IAlwaysExist, IAlwaysExist>();
例如,每当我们IAlwaysExist
在 MVC 控制器构造函数中解析时,它总是完全相同的实例。
附注:这意味着对线程安全等的担忧,取决于您正在做的事情。
范围
services.AddScoped<IAmSharedPerRequests, IAmSharedPerRequests>();
Scoped 是生命周期中最复杂的。我们稍后会更详细地讨论它。
为了简单起见,这意味着在特定的 HttpRequest(在 ASP .NET Core 应用程序中)中解析的实例将是相同的。
假设我们有服务A
和B
。两者都由同一个控制器解析:
public SomeController(A a, B b)
{
this._a = a;
this._b = b;
}
现在想象A
一下B
两者都依赖于服务C
。
如果C
是范围服务,并且由于范围服务针对相同的 HTTP 请求解析为相同的实例,因此A
和都B
将具有完全相同的C
注入实例。
但是,C
对于所有其他 HTTP 请求,将会实例化一个不同的方法。
瞬态
services.AddTransient<IAmAlwaysADifferentInstance, IAmAlwaysADifferentInstance>();
瞬态服务在解决时始终是一个全新的实例。
给出这个例子:
public SomeController(A a, A anotherA)
{
}
假设类型A
被配置为瞬态服务,变量a
和anotherA
将是类型的不同实例A
。
注意:同样的示例,如果A
服务是作用域服务,则变量a
和anotherA
将是同一个实例。但是,在下一个 HTTP 请求中,如果A
服务是作用域服务,则下一个请求中的a
和anotherA
会与第一个请求中的实例不同。
如果A
是单例,那么两个HTTP 请求中的变量a
和将引用同一个单个实例。anotherA
范围问题
当使用试图相互依赖的不同范围的服务时会出现一些问题。
循环依赖
别这么做。这毫无意义😜
public class A
{
public A(B b) { }
}
public class B
{
public B(A a){ }
}
单例 + 传递服务
再次强调,单例是“永远”存在的。它始终是同一个实例。
另一方面,传递服务在被请求或解析时总是不同的实例。
所以这里有一个有趣的问题:当单例依赖于传递依赖时,传递依赖会存在多久?
答案是永远。更具体地说,只要它的父母还活着。
由于单例永远存在,所以它引用的所有子对象也将永远存在。
这不一定是坏事。但是,如果您不理解此设置的含义,可能会引发一些奇怪的问题。
线程安全
也许您有一个传递服务 - 我们称之为ListService
非线程安全的。
ListService
有一个物品清单,并公开了Add
这些Remove
物品的方法。
现在,您开始使用ListService
单例内部作为依赖项。
这个单例将在任何地方被重复使用。也就是说,在每个 HTTP 请求上。这暗示着在许多不同的线程上。
由于单例访问/使用ListService
,并且ListService
不是线程安全的 - 大问题!
当心。
单例 + 作用域服务
现在让我们假设这ListService
是一个有范围的服务。
如果您尝试将范围服务注入单例中,会发生什么?
.NET Core 将会爆炸并告诉你你无法做到这一点!
还记得范围服务的存在时间与 HTTP 请求一样长吗?
但是,还记得我说过它实际上比这更复杂吗?......
范围服务如何真正发挥作用
在底层,.NET Core 的服务提供商公开了一个方法CreateScope
。
注意:或者,您可以使用IServiceScopeFactory
相同的方法CreateScope
。我们稍后再讨论。
CreateScope
创建一个实现该IDisposable
接口的“作用域”。它的使用方式如下:
using(var scope = serviceProvider.CreateScope())
{
// Do stuff...
}
服务提供商还公开了解析服务的方法:GetService
和GetRequiredService
。
它们的区别是,GetService
当服务没有绑定到提供者时,会返回null,并且GetRequiredService
会抛出异常。
因此,范围可能被这样使用:
using(var scope = serviceProvider.CreateScope())
{
var provider = scope.ServiceProvider;
var resolvedService = provider.GetRequiredService(someType);
// Use resolvedService...
}
当 .NET Core 在后台发起 HTTP 请求时,它会执行类似的操作。例如,它会解析控制器可能需要的服务,这样你就不必担心底层细节了。
就将服务注入 ASP 控制器而言 - 范围服务基本上附加到 HTTP 请求的生命周期。
但是,我们可以创建自己的服务(这将成为服务定位器模式的一种形式 - 稍后会详细介绍)!
因此,作用域服务并非只能附加到 HTTP 请求。其他类型的应用程序可以根据需要,在任意生命周期或上下文中创建自己的作用域。
多家服务提供商
注意到每个作用域都有自己的 吗ServiceProvider
?这是怎么回事?
DI 系统有多个服务提供商。哇 🤯
单例是通过根服务提供程序(在应用的整个生命周期内都存在)解析的。根提供程序不受作用域限制。
每当您创建一个作用域时,您都会获得一个新的作用域服务提供商!这个作用域提供商仍然能够解析单例服务,但通过代理,它们来自根提供商,因为所有作用域提供商都可以访问其“父”提供商。
以下是我们刚刚了解到的内容的概要:
- 单例服务始终可解析(从根提供程序或通过代理)
- 传递服务始终可解析(从根提供商或通过代理)
- 范围服务需要范围,因此需要可用的范围服务提供商
那么,当我们尝试从根提供程序(非范围提供程序)解析范围服务时会发生什么?...
轰隆隆🔥
回到我们的主题
所有这些都表明,范围服务需要存在范围。
单例由根提供程序解析。
由于根提供程序没有范围(从某种意义上说,它是一个“全局”提供程序) - 将范围服务注入单例是没有意义的。
范围+传递服务
那么依赖于传递服务的范围服务怎么样?
实际操作中,它会起作用。但是,出于与在单例中使用传递服务相同的原因,它的行为可能不会像你预期的那样。
范围服务所使用的传递服务将与范围服务一样长。
只要确保这在您的用例中是有意义的即可。
库作者的依赖注入
作为库的作者,我们有时希望提供类似原生的工具。例如,对于Coravel,我希望它能够与 .NET Core DI 系统无缝集成。
我们该怎么做呢?
IServiceScopeFactory
顺便提一下,.NET Core 提供了一个用于创建作用域的实用程序。这对于库作者来说非常有用。
IServiceProvider
库作者可能不应该抓取的实例,而应该使用IServiceScopeFactory
。
为什么?嗯,还记得根服务提供者无法解析作用域服务吗?如果你的库需要围绕作用域服务做一些“魔法”怎么办?哎呀!
例如,Coravel需要在特定情况下(如实例化可调用类)从服务提供商解析某些类型。
Entity Framework Core 上下文是有范围的,因此您可能想要执行诸如在库内执行数据库查询之类的操作(代表用户/开发人员)。
这是Coravel Pro所做的事情——在后台自动从用户的 EF Core 上下文执行查询。
附带说明一下,在闭包中捕获要在后台使用的服务Task
和/或从后台解析服务的问题Task
也促进了手动解析服务的需要(Coravel需要这样做)。
如果感兴趣的话,David Fowler 曾在这里简要地写过这篇文章。
服务定位器模式
一般来说,服务定位器模式不是一个好的做法。这适用于我们手动向服务提供者请求特定类型的情况。
using(var scope = serviceProvider.CreateScope())
{
var provider = scope.ServiceProvider;
var resolvedService = provider.GetRequiredService(someType);
// Use resolvedService...
}
然而,对于上面提到的情况,我们需要做的就是抓住范围,解决服务并做一些“魔术”。
这类似于 .NET Core 如何准备 DI 范围并为您的 ASP .NET Core 控制器解析服务。
这还不错,因为它不是“用户代码”而是“框架代码”——如果你愿意的话。
结论
我们研究了依赖注入成为我们可用的有用工具的一些原因。
它有助于促进:
- 代码可测试性
- 通过组合重用代码
- 代码可读性
然后我们研究了 .NET Core 中的依赖注入是如何使用的,以及它如何工作的一些较低级别的方面。
总的来说,我们发现当服务依赖于其他寿命较短的服务时就会出现问题。
- 单例 -> 作用域
- 单例 -> 传递
- 作用域 -> 传递
最后,我们研究了 .NET Core 如何为库作者提供一些有用的工具,以帮助与 .NET Core 的 DI 系统无缝集成。
希望你学到了新东西!和往常一样,在评论区留下你的想法吧👌
保持联系
导航您的软件开发职业通讯
一封电子邮件简报,助您提升软件开发职业水平!您是否想过:
✔ 软件开发人员通常经历哪些阶段?
✔ 我如何知道自己处于哪个阶段?如何进入下一个阶段?
✔ 什么是技术领导者?如何成为技术领导者?
听起来很有趣?加入社区吧!