乘风破浪,遇见最佳跨平台跨终端框架.Net Core/.Net生态 - 再谈依赖注入(Dependency Injection,DI)
什么是依赖注入(DI)
在软件工程中,依赖注入(Dependency Injection,缩写为DI)是一种软件设计模式,也是实现控制反转的其中一种技术。这种模式能让一个物件接收它所依赖的其他物件。“依赖”是指接收方所需的对象。“注入”是指将“依赖”传递给接收方的过程。在“注入”之后,接收方才会调用该“依赖”。此模式确保了任何想要使用给定服务的物件不需要知道如何建立这些服务。取而代之的是,连接收方物件(像是client)也不知道它存在的外部代码(注入器)提供接收方所需的服务。
解决什么问题
依赖注入(DI)该设计的目的是为了分离关注点,分离接收方和依赖,从而提供松耦合以及代码重用性。
传统编程方式,客户对象自己创建一个服务实例并使用它。这带来的缺点和问题是:
- 如果使用不同类型的服务对象,就需要修改、重新编译客户类。
- 客户类需要通过配置来适配服务类及服务类的依赖。如果程序有多个类都使用同一个服务类,这些配置就会变得复杂并分散在程序各处。
- 难以单元测试。本来需要使用服务类的mock或stub,在这种方式下不太可行。
依赖注入可以解决上述问题:
- 使用接口或抽象基类,来抽象化依赖实现。
- 依赖在一个服务容器中注册。客户类构造函数被注入服务实例。框架负责创建依赖实例并在没有使用者时销毁它。
涉及概念
依赖注入涉及四个概念:
- 服务:任何类,提供了有用功能。
- 客户:使用服务的类。
- 接口:客户不应该知道服务实现的细节,只需要知道服务的名称和API。
- 注入器:Injector,也称assembler、container、provider或factory。负责把服务引入给客户。
依赖注入把对象构建与对象注入分开。因此创建对象的new关键字也可消失了。
实现方式
- 建构子注入:依赖由客户对象的构造函数传入。
- Setter注入:客户对象暴露一个能接收依赖的setter方法。
- 接口注入:依赖的接口提供一个注入器方法,该方法会把依赖注入到任意一个传给它的客户端。
在.Net中的依赖注入
.NET支持依赖关系注入(DI)软件设计模式,这是一种在类及其依赖项之间实现控制反转(IoC)的技术。.NET中的依赖关系注入是框架的内置部分,与配置、日志记录和选项模式一样。
使用前后变化
依赖项是指另一个对象所依赖的对象。使用其他类所依赖的Write
方法检查以下MessageWriter
类:
public class MessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}
Worker
类可以创建MessageWriter
类的实例,以便利用其Write
方法。在以下示例中,MessageWriter
类是Worker
类的依赖项:
public class Worker : BackgroundService
{
private readonly MessageWriter _messageWriter = new MessageWriter();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1000, stoppingToken);
}
}
}
该类创建并直接依赖于MessageWriter
类。
硬编码的依赖项(如前面的示例)会产生问题,应避免使用,原因如下:
- 要用不同的实现替换
MessageWriter
,必须修改Worker
类。 - 如果
MessageWriter
具有依赖项,则必须由Worker
类对其进行配置。在具有多个依赖于MessageWriter
的类的大型项目中,配置代码将分散在整个应用中。 - 这种实现很难进行单元测试。应用需使用模拟或存根
MessageWriter
类,而该类不能使用此方法。
依赖关系注入通过以下方式解决了这些问题:
- 使用
接口
或基类
将依赖关系实现抽象化。 - 在服务容器中注册依赖关系。.NET提供了一个内置的服务容器
IServiceProvider
。服务通常在应用启动时注册,并追加到IServiceCollection
。添加所有服务后,可以使用BuildServiceProvider
创建服务容器。 - 将服务注入到使用它的类的构造函数中。框架负责创建依赖关系的实例,并在不再需要时将其释放。
例如,IMessageWriter
接口定义Write
方法:
namespace DependencyInjection.Example;
public interface IMessageWriter
{
void Write(string message);
}
此接口由具体类型MessageWriter
实现:
namespace DependencyInjection.Example;
public class MessageWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}
示例代码使用具体类型MessageWriter
注册IMessageWriter
服务。AddScoped
方法使用范围内生存期(单个请求的生存期)注册服务。
using DependencyInjection.Example;
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(
services =>
services.AddHostedService<Worker>()
.AddScoped<IMessageWriter, MessageWriter>());
var host = builder.Build();
host.Run();
在上面的代码中,示例应用:
- 创建了主机生成器实例。
- 通过注册以下内容来配置服务:
- Worker作为托管服务。
- IMessageWriter接口作为具有MessageWriter类的相应实现的作用域服务。
- 生成主机并运行它。
主机包含依赖关系注入服务提供程序。它还包含自动实例化Worker
并提供相应的IMessageWriter
实现作为参数所需的所有其他相关服务。
namespace DependencyInjection.Example;
public class Worker : BackgroundService
{
private readonly IMessageWriter _messageWriter;
public Worker(IMessageWriter messageWriter) =>
_messageWriter = messageWriter;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1000, stoppingToken);
}
}
}
通过使用依赖注入(DI)模式,辅助角色服务:
- 不使用具体类型
MessageWriter
,只使用实现它的IMessageWriter
接口。这样可以轻松地更改控制器使用的实现,而无需修改控制器。 - 不要创建
MessageWriter
的实例。该实例由DI容器创建。
可以通过使用内置日志记录API来改善
IMessageWriter
接口的实现:
namespace DependencyInjection.Example;
public class LoggingMessageWriter : IMessageWriter
{
private readonly ILogger<LoggingMessageWriter> _logger;
public LoggingMessageWriter(ILogger<LoggingMessageWriter> logger) =>
_logger = logger;
public void Write(string message) =>
_logger.LogInformation(message);
}
更新的ConfigureServices
方法注册新的IMessageWriter
实现:
static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((_, services) =>
services.AddHostedService<Worker>()
.AddScoped<IMessageWriter, LoggingMessageWriter>());
LoggingMessageWriter
依赖于ILogger<TCategoryName>
,并在构造函数中对其进行请求。ILogger<TCategoryName>
是ILogger<TCategoryName>
。
以链式方式使用依赖关系注入并不罕见。每个请求的依赖关系相应地请求其自己的依赖关系。容器解析图中的依赖关系并返回完全解析的服务。必须被解析的依赖关系的集合通常被称为“依赖关系树”、“依赖关系图”或“对象图”。
容器通过利用(泛型)开放类型解析ILogger<TCategoryName>
,而无需注册每个(泛型)构造类型。
在依赖项注入术语中,一个服务:
- 通常是向其他对象提供服务的对象,如
IMessageWriter
服务。 - 与Web服务无关,尽管服务可能使用Web服务。
框架提供可靠的日志记录系统。编写上述示例中的IMessageWriter
实现来演示基本的DI,而不是来实现日志记录。大多数应用都不需要编写记录器。
下面的代码展示了如何使用默认日志记录,只需要将Worker
在ConfigureServices
中注册为托管服务AddHostedService
:
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger) =>
_logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}
使用前面的代码时,无需更新ConfigureServices
,因为框架提供日志记录。
多个构造函数发现规则
当某个类型定义多个构造函数时,服务提供程序具有用于确定要使用哪个构造函数的逻辑。选择最多参数的构造函数,其中的类型是可DI解析的类型。请考虑以下示例服务:
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(ILogger<ExampleService> logger)
{
// omitted for brevity
}
public ExampleService(FooService fooService, BarService barService)
{
// omitted for brevity
}
}
在前面的代码中,假定已添加日志记录,并且可以从服务提供程序解析,但FooService
和BarService
类型不可解析。使用ILogger<ExampleService>
参数的构造函数用于解析ExampleService
实例。即使有定义多个参数的构造函数,FooService
和BarService
类型也不能进行DI解析。
如果发现构造函数时存在歧义,将引发异常。请考虑以下示例服务:
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(ILogger<ExampleService> logger)
{
// omitted for brevity
}
public ExampleService(IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
具有不明确的可DI解析的类型参数的
ExampleService
代码将引发异常。不要执行此操作,它旨在显示“不明确的可DI解析类型”的含义。
在前面的示例中,有三个构造函数。第一个构造函数是无参数的,不需要服务提供商提供的服务。假设日志记录和选项都已添加到DI容器,并且是可DI解析的服务。当DI容器尝试解析ExampleService
类型时,将引发异常,因为这两个构造函数不明确。
可通过定义一个接受DI可解析的类型的构造函数来避免歧义:
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(
ILogger<ExampleService> logger,
IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
使用扩展方法注册服务组
Microsoft Extensions使用一种约定来注册一组相关服务。约定使用单个Add{GROUP_NAME}
扩展方法来注册该框架功能所需的所有服务。例如,AddOptions
扩展方法会注册使用选项所需的所有服务。
框架提供的服务
ConfigureServices
方法注册应用使用的服务,包括平台功能。最初,提供给ConfigureServices
的IServiceCollection
具有框架定义的服务(具体取决于主机配置方式)。对于基于.NET模板的应用,该框架会注册数百个服务。
下表列出了框架注册的这些服务的一小部分:
服务生存期
可以使用以下任一生存期注册服务:
- 暂时(Transient)
- 作用域(Scoped)
- 单例(Singleton)
下列各部分描述了上述每个生存期。为每个注册的服务选择适当的生存期。
暂时(Transient)
暂时(Transient)生存期服务是每次从服务容器进行请求时创建的。这种生存期适合轻量级、无状态的服务。向AddTransient
注册暂时性服务。
在处理请求的应用中,在请求结束时会释放暂时服务。
作用域(Scoped)
对于Web应用,指定了作用域(Scoped)的生存期指明了每个客户端请求(连接)一次服务。向AddScoped
注册范围内服务。
在处理请求的应用中,在请求结束时会释放有作用域的服务。
使用EntityFrameworkCore
时,默认情况下AddDbContext
扩展方法使用作用域(Scoped)
生存期来注册DbContext
类型。
默认情况下在开发环境中,从具有较长生存期的其他服务解析服务将引发异常。
不要从单一实例解析限定范围的服务,并小心不要间接地这样做,例如通过暂时(Transient)性服务。当处理后续请求时,它可能会导致服务处于不正确的状态。可以:
- 从作用域(Scoped)或暂时(Transient)性服务解析单一实例服务。
- 从其他作用域(Scoped)或暂时(Transient)性服务解析范围内服务。
单例(Singleton)
创建单例生命周期服务的情况如下:
- 在首次请求它们时进行创建。
- 在向容器直接提供实现实例时由开发人员进行创建。很少用到此方法。
来自依赖关系注入容器的服务实现的每一个后续请求都使用同一个实例。如果应用需要单一实例行为,则允许服务容器管理服务的生存期。不要实现单一实例设计模式,或提供代码来释放单一实例。服务永远不应由解析容器服务的代码释放。如果类型或工厂注册为单一实例,则容器自动释放单一实例。
向AddSingleton
注册单一实例服务。单一实例服务必须是线程安全的,并且通常在无状态服务中使用。
在处理请求的应用中,当应用关闭并释放ServiceProvider
时,会释放单一实例服务。由于应用关闭之前不释放内存,因此请考虑单一实例服务的内存使用。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步