C# · #csharp#aspnetcore#dependency-injection#dotnet

ASP.NET Core 依赖注入深入理解

2017.06.22 C# 3 min 1.3k
// 目录 · contents

ASP.NET Core 把依赖注入提升为框架的一等公民,内置 DI 容器开箱即用。用了半年之后,我发现大多数问题都源于对生命周期的误解。这篇文章把我踩过的坑和总结的最佳实践都写下来。

1. 三种生命周期

1
2
3
services.AddTransient<IEmailService, SmtpEmailService>();  // 瞬态
services.AddScoped<IOrderService, OrderService>(); // 作用域
services.AddSingleton<ICacheService, RedisCacheService>(); // 单例
生命周期 创建时机 销毁时机 适用场景
Transient 每次注入 使用完立即 无状态、轻量级服务
Scoped 每个 HTTP 请求 请求结束 DbContext、工作单元
Singleton 应用启动 应用关闭 配置、缓存、无状态工具

Scoped 是最常用的——一次请求内共享同一个实例,既避免了每次创建的开销,又保证了请求之间的隔离。DbContext 就应该是 Scoped。

2. 生命周期陷阱:Captive Dependency

这是最常见的错误:在长生命周期的服务里注入短生命周期的服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 错误!Singleton 依赖了 Scoped 服务
public class OrderService // Singleton
{
private readonly AppDbContext _db; // Scoped

public OrderService(AppDbContext db) // 构造时注入的是第一个请求的 DbContext
{
_db = db; // 这个 DbContext 永远不会被释放!
}
}

// 正确方案 1:让 OrderService 也变成 Scoped
services.AddScoped<IOrderService, OrderService>();

// 正确方案 2:注入 IServiceScopeFactory,在方法内创建临时作用域
public class OrderService // Singleton
{
private readonly IServiceScopeFactory _scopeFactory;

public OrderService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}

public async Task ProcessOrderAsync(int orderId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 在这个 scope 内使用 db
}
}

ASP.NET Core 在开发模式下会自动检测并抛出 InvalidOperationException 来提醒这个问题。

3. 注册模式

接口注册(最常用):

1
services.AddScoped<IUserService, UserService>();

泛型服务注册:

1
2
3
4
5
6
7
8
9
// 注册开放泛型
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

// 使用时
public class OrderService
{
public OrderService(IRepository<Order> orderRepo,
IRepository<User> userRepo) { }
}

工厂方法注册:

1
2
3
4
5
6
7
8
9
10
11
12
services.AddScoped<IPaymentService>(provider =>
{
var config = provider.GetRequiredService<IConfiguration>();
var paymentType = config["Payment:Type"];

return paymentType switch
{
"alipay" => new AlipayService(config["Payment:AlipayKey"]),
"wechat" => new WechatPayService(config["Payment:WechatKey"]),
_ => throw new NotSupportedException($"不支持的支付方式: {paymentType}")
};
});

同一接口多实现(策略模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services.AddScoped<INotificationService, EmailNotificationService>();
services.AddScoped<INotificationService, SmsNotificationService>();
services.AddScoped<INotificationService, PushNotificationService>();

// 注入所有实现
public class NotificationManager
{
private readonly IEnumerable<INotificationService> _services;

public NotificationManager(IEnumerable<INotificationService> services)
{
_services = services;
}

public async Task SendAllAsync(string message)
{
await Task.WhenAll(_services.Select(s => s.SendAsync(message)));
}
}

4. Options 模式:强类型配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// appsettings.json
{
"Email": {
"SmtpHost": "smtp.example.com",
"SmtpPort": 587,
"FromAddress": "[email protected]"
}
}

// 配置类
public class EmailOptions
{
public string SmtpHost { get; set; }
public int SmtpPort { get; set; }
public string FromAddress { get; set; }
}

// 注册
services.Configure<EmailOptions>(Configuration.GetSection("Email"));

// 使用(三种注入方式)
public class EmailService
{
// IOptions:单例,应用启动时读取一次
public EmailService(IOptions<EmailOptions> options)
{
var opts = options.Value;
}

// IOptionsSnapshot:Scoped,每次请求重新读取(支持热更新)
public EmailService(IOptionsSnapshot<EmailOptions> options)
{
var opts = options.Value;
}

// IOptionsMonitor:Singleton + 变更通知
public EmailService(IOptionsMonitor<EmailOptions> monitor)
{
monitor.OnChange(opts => Console.WriteLine("配置已更新"));
}
}

5. 集成第三方容器

内置 DI 功能基本够用,但有些高级场景(属性注入、拦截器、自动扫描)需要 Autofac 等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Startup.cs
public IServiceProvider ConfigureServices(IServiceCollection services)
{
services.AddMvc();
// ... 其他框架服务

// 创建 Autofac 容器
var builder = new ContainerBuilder();
builder.Populate(services); // 把内置服务转移到 Autofac

// Autofac 特有功能:自动注册程序集中所有 Service
builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
.Where(t => t.Name.EndsWith("Service"))
.AsImplementedInterfaces()
.InstancePerLifetimeScope();

// 拦截器(AOP)
builder.RegisterType<LoggingInterceptor>();
builder.RegisterType<OrderService>()
.As<IOrderService>()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(LoggingInterceptor));

var container = builder.Build();
return new AutofacServiceProvider(container);
}

踩坑记录

我们项目里有一次诡异的 bug:DbContext 偶发性地在一个请求里被两个操作同时使用,抛出”A second operation started on this context before a previous asynchronous operation completed”异常。

排查了很久才发现:有一个 BackgroundService 是 Singleton,里面注入了 IOrderService(Scoped),而这个 IOrderService 又依赖了 DbContext。因为 BackgroundService 持有的是第一个请求的 DbContext,多个后台任务并发时共享了同一个实例。

解决方案就是用 IServiceScopeFactory 在每个后台任务执行时创建独立的 scope,不直接持有 Scoped 服务。

总结

  • Transient/Scoped/Singleton 三种生命周期,DbContext 用 Scoped,缓存/配置用 Singleton
  • 不要在 Singleton 里注入 Scoped 服务,这是最常见的陷阱
  • Options 模式是读取配置的最佳实践,优于直接注入 IConfiguration
  • 同一接口多实现通过 IEnumerable<T> 注入,配合策略模式非常优雅
  • 后台服务里需要 Scoped 服务时,用 IServiceScopeFactory 手动创建作用域
作者 · authorzt
发布 · date2017-06-22
篇幅 · length1.3k 字 · 3 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论