.NET 支持依赖关系注入 (DI) 软件设计模式,这是一种在类及其依赖项之间实现控制反转 (IoC) 的技术。 .NET 中的依赖关系注入是框架的内置部分,与配置、日志记录和选项模式一样。
依赖项是指另一个对象所依赖的对象。 使用其他类所依赖的 MessageWriter 方法检查以下 Write 类:
public class MessageWriter
{
public void Write(string message)
{
Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
}
}
类可以创建类的 MessageWriter 实例以使用其 Write 方法。 在以下示例中,MessageWriter 类是 Worker 类的依赖项:
public class Worker : BackgroundService
{
private readonly MessageWriter _messageWriter = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1_000, 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}\")");
}
}
示例代码使用具体类型 IMessageWriter 注册 MessageWriter 服务。
AddSingleton 方法使用单一实例生存期(应用的生存期)注册服务。 本文后面将介绍服务生存期。
using DependencyInjection.Example;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddSingleton<IMessageWriter, MessageWriter>();
using IHost host = builder.Build();
host.Run();
在上面的代码中,示例应用:
创建主机应用生成器实例。
通过注册以下内容来配置服务:
-
Worker作为托管服务。 有关详细信息,请参阅 .NET 中的辅助角色服务。 -
IMessageWriter接口作为具有MessageWriter类相应实现的单一实例服务。
-
生成主机并运行它。
主机包含依赖关系注入服务提供程序。 它还包含自动实例化 Worker 并提供相应的 IMessageWriter 实现作为参数所需的所有其他相关服务。
namespace DependencyInjection.Example;
public sealed class Worker(IMessageWriter messageWriter) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
await Task.Delay(1_000, stoppingToken);
}
}
}
通过使用 DI 模式,辅助角色服务:
- 不使用具体类型
MessageWriter,只IMessageWriter使用它实现的接口。 这样可以轻松更改工作服务使用的实现,而无需修改工作服务。 - 不要创建
MessageWriter的实例。 DI 容器创建实例。
可以通过使用内置日志 API 来改进 IMessageWriter 接口的实现:
namespace DependencyInjection.Example;
public class LoggingMessageWriter(
ILogger<LoggingMessageWriter> logger) : IMessageWriter
{
public void Write(string message) =>
logger.LogInformation("Info: {Msg}", message);
}
更新的 AddSingleton 方法注册新的 IMessageWriter 实现:
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
HostApplicationBuilder (builder)类型是Microsoft.Extensions.Hosting NuGet 包的一部分。
LoggingMessageWriter 依赖于 ILogger<TCategoryName>,并在构造函数中对其进行请求。
ILogger<TCategoryName> 是ILogger<TCategoryName>。
以链式方式使用依赖关系注入并不罕见。 每个请求的依赖关系相应地请求其自己的依赖关系。 容器解析图中的依赖关系并返回完全解析的服务。 必须解析的依赖项集通常称为 依赖项树、 依赖项图或 对象图。
容器通过利用ILogger<TCategoryName>解析 ,而无需注册每个(泛型)构造类型。
在依赖项注入术语中,服务:
- 通常是向其他对象提供服务的对象,如
IMessageWriter服务。 - 与 Web 服务无关,尽管该服务可能使用 Web 服务。
框架提供可靠的日志记录系统。
IMessageWriter前面的示例中所示的实现演示了基本 DI,而不是日志记录。 大多数应用都不需要编写记录器。 下面的代码展示了如何使用默认日志记录,只需要将Worker注册为托管服务AddHostedService:
public sealed class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1_000, stoppingToken);
}
}
}
使用前面的代码无需更新 Program.cs,因为框架提供日志记录。
多个构造函数发现规则
当某个类型定义多个构造函数时,服务提供程序具有用于确定要使用哪个构造函数的逻辑。 选择最多参数的构造函数,其中的类型是可 DI 解析的类型。 请考虑以下 C# 示例服务:
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 类型的依赖注入不可解析。
如果发现构造函数时存在歧义,将引发异常。 请考虑以下 C# 示例服务:
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(ILogger<ExampleService> logger)
{
// omitted for brevity
}
public ExampleService(IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
警告
ExampleService具有不明确 DI 解析类型参数的代码将引发异常。
不要 这样做 — 它旨在显示“不明确的 DI 可解析类型”的含义。
在前面的示例中,有三个构造函数。 第一个构造函数是无参数的,不需要服务提供商提供的服务。 假设日志记录和选项都已添加到 DI 容器,并且是可 DI 解析的服务。 当 DI 容器尝试解析 ExampleService 类型时,它会引发异常,因为两个构造函数不明确。
通过定义接受两种 DI 解析类型的构造函数来避免歧义:
public class ExampleService
{
public ExampleService()
{
}
public ExampleService(
ILogger<ExampleService> logger,
IOptions<ExampleOptions> options)
{
// omitted for brevity
}
}
使用扩展方法注册服务组
Microsoft 扩展使用一种约定来注册一组相关服务。 约定使用单个 Add{GROUP_NAME} 扩展方法来注册该框架功能所需的所有服务。 例如,AddOptions 扩展方法会注册使用选项所需的所有服务。
框架提供的服务
使用任何可用的主机或应用生成器模式时,会应用默认值,并由框架注册服务。 请考虑一些最常用的主机和应用生成器模式:
- Host.CreateDefaultBuilder()
- Host.CreateApplicationBuilder()
- WebHost.CreateDefaultBuilder()
- WebApplication.CreateBuilder()
- WebAssemblyHostBuilder.CreateDefault
- MauiApp.CreateBuilder
从其中任一 API 创建生成器后, IServiceCollection 框架定义的服务取决于 主机的配置方式。 对于基于 .NET 模板的应用,该框架会注册数百个服务。
下表列出了框架注册的这些服务的一小部分:
服务生存期
可以使用以下任一生存期注册服务:
下列各部分描述了上述每个生存期。 为每个注册的服务选择适当的生存期。
暂时
暂时生存期服务是每次从服务容器进行请求时创建的。 若要将服务注册为暂时性,请调用 AddTransient。
在处理请求的应用中,在请求结束时会释放暂时服务。 此生存期会产生每个请求的分配,因为每次都会解析和构建服务。 有关详细信息,请参阅依赖项注入指南:暂时性实例和共享实例的 IDisposable 指南。
范围内
对于 Web 应用,指定了作用域的生存期指明了每个客户端请求(连接)创建一次服务。 向 AddScoped 注册范围内服务。
在处理请求的应用中,在请求结束时会释放有作用域的服务。
注意
使用 Entity Framework Core 时,默认情况下 AddDbContext 扩展方法使用范围内生存期来注册 DbContext 类型。
范围服务应始终在作用域内使用——即在隐式作用域中(例如,ASP.NET Core 的每个请求作用域),或在使用IServiceScopeFactory.CreateScope() 创建的显式作用域中。
请勿通过构造函数注入或在单一实例中请求IServiceProvider,直接解析范围服务。 这样做会导致作用域服务表现为单例,进而在处理后续请求时可能导致状态不正确。
如果创建并使用显式作用域, IServiceScopeFactory则可以在单一实例中解析作用域服务。
也没关系:
- 从范围内或暂时性服务解析单一实例服务。
- 从其他范围内或暂时性服务解析范围内服务。
默认情况下在开发环境中,从具有较长生存期的其他服务解析服务将引发异常。 有关详细信息,请参阅作用域验证。
单例
创建单例生命周期服务的情况如下:
- 在首次请求它们时进行创建;或者
- 在向容器直接提供实现实例时由开发人员进行创建。 很少用到此方法。
来自依赖关系注入容器的服务实现的每一个后续请求都使用同一个实例。 如果应用需要单一实例行为,则允许服务容器管理服务的生存期。 不要实现单一实例设计模式,或提供代码来释放单一实例。 服务永远不应由解析容器服务的代码释放。 如果类型或工厂注册为单一实例,则容器自动释放单一实例。
向 AddSingleton 注册单一实例服务。 单一实例服务必须是线程安全的,并且通常在无状态服务中使用。
在处理请求的应用中,当应用关闭并释放 ServiceProvider 时,会释放单一实例服务。 由于内存在应用关闭之前不会被释放,因此在使用单例服务时请仔细考虑内存使用。
服务注册方法
框架提供了适用于特定场景的服务注册扩展方法:
| 方法 | 自动 对象 释放 |
多种 实现 |
传递参数 |
|---|---|---|---|
Add{LIFETIME}<{SERVICE}, {IMPLEMENTATION}>()示例: services.AddSingleton<IMyDep, MyDep>(); |
是 | 是 | 否 |
Add{LIFETIME}<{SERVICE}>(sp => new {IMPLEMENTATION})示例: services.AddSingleton<IMyDep>(sp => new MyDep());services.AddSingleton<IMyDep>(sp => new MyDep(99)); |
是 | 是 | 是 |
Add{LIFETIME}<{IMPLEMENTATION}>()示例: services.AddSingleton<MyDep>(); |
是 | 否 | 否 |
AddSingleton<{SERVICE}>(new {IMPLEMENTATION})示例: services.AddSingleton<IMyDep>(new MyDep());services.AddSingleton<IMyDep>(new MyDep(99)); |
否 | 是 | 是 |
AddSingleton(new {IMPLEMENTATION})示例: services.AddSingleton(new MyDep());services.AddSingleton(new MyDep(99)); |
否 | 否 | 是 |
要详细了解释放类型,请参阅服务释放部分。
仅使用实现类型注册服务等效于使用相同的实现和服务类型注册该服务。 例如,考虑以下代码:
services.AddSingleton<ExampleService>();
这相当于将服务注册到相同类型的服务和实现:
services.AddSingleton<ExampleService, ExampleService>();
这种等效性就是无法使用未采用显式服务类型的方法来注册服务的多个实现的原因。 这些方法可以注册多个 实例 但它们都具有相同的 执行 类型
任何服务注册方法都可用于注册同一服务类型的多个服务实例。 下面的示例以 AddSingleton 作为服务类型调用 IMessageWriter 两次。 第二次对 AddSingleton 的调用在解析为 IMessageWriter 时替代上一次调用,在通过 IEnumerable<IMessageWriter> 解析多个服务时添加到上一次调用。 通过 IEnumerable<{SERVICE}> 解析服务时,服务按其注册顺序显示。
using ConsoleDI.IEnumerableExample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
builder.Services.AddSingleton<ExampleService>();
using IHost host = builder.Build();
_ = host.Services.GetService<ExampleService>();
await host.RunAsync();
前面的示例源代码注册了 IMessageWriter 的两个实现。
using System.Diagnostics;
namespace ConsoleDI.IEnumerableExample;
public sealed class ExampleService
{
public ExampleService(
IMessageWriter messageWriter,
IEnumerable<IMessageWriter> messageWriters)
{
Trace.Assert(messageWriter is LoggingMessageWriter);
var dependencyArray = messageWriters.ToArray();
Trace.Assert(dependencyArray[0] is ConsoleMessageWriter);
Trace.Assert(dependencyArray[1] is LoggingMessageWriter);
}
}
ExampleService 定义两个构造函数参数:一个是 IMessageWriter,另一个是 IEnumerable<IMessageWriter>。 单一 IMessageWriter 是要注册的最后一个实现,而 IEnumerable<IMessageWriter> 表示所有已注册的实现。
框架还提供 TryAdd{LIFETIME} 扩展方法,只有当尚未注册某个实现时,才注册该服务。
在下面的示例中,对 AddSingleton 的调用会将 ConsoleMessageWriter 注册为 IMessageWriter的实现。 对 TryAddSingleton 的调用没有任何作用,因为 IMessageWriter 已有一个已注册的实现:
services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();
TryAddSingleton 无效,因为它已被添加,并且“尝试”失败。 断 ExampleService 言以下内容:
public class ExampleService
{
public ExampleService(
IMessageWriter messageWriter,
IEnumerable<IMessageWriter> messageWriters)
{
Trace.Assert(messageWriter is ConsoleMessageWriter);
Trace.Assert(messageWriters.Single() is ConsoleMessageWriter);
}
}
有关详细信息,请参阅:
TryAddEnumerable(ServiceDescriptor) 方法仅会在没有同一类型实现的情况下才注册该服务。 多个服务通过 IEnumerable<{SERVICE}> 解析。 注册服务时,如果尚未添加同类型的实例,请添加一个实例。 库作者使用 TryAddEnumerable 来避免在容器中注册一个实现的多个副本。
在下面的示例中,对 TryAddEnumerable 的第一次调用会将 MessageWriter 注册为 IMessageWriter1的实现。 第二次调用向 MessageWriter 注册 IMessageWriter2。 第三次调用没有任何作用,因为 IMessageWriter1 已有一个 MessageWriter 的已注册的实现:
public interface IMessageWriter1 { }
public interface IMessageWriter2 { }
public class MessageWriter : IMessageWriter1, IMessageWriter2
{
}
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter2, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
服务注册独立于顺序,除非注册同一类型的多个实现。
IServiceCollection 是 ServiceDescriptor 对象的集合。 以下示例演示如何通过创建和添加 ServiceDescriptor 来注册服务:
string secretKey = Configuration["SecretKey"];
var descriptor = new ServiceDescriptor(
typeof(IMessageWriter),
_ => new DefaultMessageWriter(secretKey),
ServiceLifetime.Transient);
services.Add(descriptor);
内置 Add{LIFETIME} 方法使用同一种方式。 相关示例请参阅 AddScoped 源代码。
构造函数注入行为
可以使用以下方法解析服务:
- IServiceProvider
- %>
- 创建未在容器中注册的对象。
- 用于某些框架功能。
构造函数可以接受非依赖关系注入提供的参数,但参数必须分配默认值。
当 IServiceProvider 或 ActivatorUtilities 解析服务时,构造函数注入需要一个 公共 构造函数。
解析服务时 ActivatorUtilities ,构造函数注入要求只有一个适用的构造函数存在。 支持构造函数重载,但其参数可以全部通过依赖注入来实现的重载只能存在一个。
作用域验证
如果应用在Development环境中运行,并调用CreateApplicatioBuilder以生成主机,默认服务提供程序会执行检查,以确认以下内容:
- 没有从根服务提供程序解析到范围内服务。
- 未将范围内服务注入单一实例。
调用 BuildServiceProvider 时创建根服务提供程序。 在启动提供程序和应用时,根服务提供程序的生存期对应于应用的生存期,并在关闭应用时释放。
有作用域的服务由创建它们的容器释放。 如果范围内服务创建于根容器,则该服务的生存期实际上提升至单一实例,因为根容器只会在应用关闭时将其释放。 验证服务作用域,将在调用 BuildServiceProvider 时收集这类情况。
范围场景
IServiceScopeFactory 始终注册为单一实例,但 IServiceProvider 可能因包含类的生存期而异。 例如,如果从某个范围解析服务,并且其中任一服务使用 IServiceProvider,则它是一个作用域的实例。
若要在IHostedService的实现中提供范围服务(例如BackgroundService),不要 通过构造函数注入服务依赖项。 请改为注入 IServiceScopeFactory,创建范围,然后从该范围解析依赖项以使用适当的服务生存期。
namespace WorkerScope.Example;
public sealed class Worker(
ILogger<Worker> logger,
IServiceScopeFactory serviceScopeFactory)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (IServiceScope scope = serviceScopeFactory.CreateScope())
{
try
{
logger.LogInformation(
"Starting scoped work, provider hash: {hash}.",
scope.ServiceProvider.GetHashCode());
var store = scope.ServiceProvider.GetRequiredService<IObjectStore>();
var next = await store.GetNextAsync();
logger.LogInformation("{next}", next);
var processor = scope.ServiceProvider.GetRequiredService<IObjectProcessor>();
await processor.ProcessAsync(next);
logger.LogInformation("Processing {name}.", next.Name);
var relay = scope.ServiceProvider.GetRequiredService<IObjectRelay>();
await relay.RelayAsync(next);
logger.LogInformation("Processed results have been relayed.");
var marked = await store.MarkAsync(next);
logger.LogInformation("Marked as processed: {next}", marked);
}
finally
{
logger.LogInformation(
"Finished scoped work, provider hash: {hash}.{nl}",
scope.ServiceProvider.GetHashCode(), Environment.NewLine);
}
}
}
}
}
在上述代码中,当应用运行时,后台服务:
- 依赖于 IServiceScopeFactory。
- 为解析其他服务而创建IServiceScope。
- 解析区分范围内的服务以供使用。
- 处理要处理的对象,然后对其执行中继操作,最后将其标记为已处理。
在示例源代码中,可以看到 IHostedService 的实现如何从区分范围的服务生存期中获益。
键控服务
从 .NET 8 开始,支持基于密钥的服务注册和查找,这意味着可以使用其他密钥注册多个服务,并使用此密钥进行查找。
例如,假设接口 IMessageWriter 有不同的实现:MemoryMessageWriter 和 QueueMessageWriter。
可以使用支持密钥作为参数的服务注册方法(前面所示)的重载来注册这些服务:
services.AddKeyedSingleton<IMessageWriter, MemoryMessageWriter>("memory");
services.AddKeyedSingleton<IMessageWriter, QueueMessageWriter>("queue");
key不限于 string.
key只要类型正确实现object,就可以Equals是所需的任何类型。
在使用 IMessageWriter 的类的构造函数中,添加 FromKeyedServicesAttribute 以指定要解析的服务的密钥:
public class ExampleService
{
public ExampleService(
[FromKeyedServices("queue")] IMessageWriter writer)
{
// Omitted for brevity...
}
}