文章目录
前言
我们在ASP.NET Core中使用EF Core都是通过在Services上AddDbContext的形式将其注册到DI容器里。并且还会有很多数据服务依赖于DbContext,这些依赖服务也是需要注册到DI容器里。
每次我在ASP.NET Core中使用EF Core都会很好奇,一次HTTP请求中,这些DbContext和依赖其的数据服务是如何被注入进去的。重复注入的情况下,什么情况下只会创建一次,什么情况下创建多次。其实这也就是关于DbContext生命周期的一个讨论。
本文将探讨通过DI注入EF Core的DbContext在HTTP请求中的生命周期,并且分析这样设计的原因。
这里我先简单给出结论,ASP.NET Core中默认的DbContext是Scoped的生命周期,一次HTTP请求对应着一个Scoped。换言之,在一个HTTP请求里,通过DI注入的DbContext都是同一个实例。
一、EF Core的DbContext默认生命周期
.NET9里的Program.cs,是通过AddDbContext的形式注入DbContext。其实这里的AddDbContext是一个名为EntityFrameworkServiceCollectionExtensions的静态扩展方法,在Microsoft.Extensions.DependencyInjection命名空间下。
不知道大家是否好奇DbContext的生命周期是什么,其实源码里已经给出了答案。
Pragram.cs
builder.Services.AddDbContext<MyDbContext>(opt =>
{
string conn = builder.Configuration["ConnectionStrings:MySQL"];
opt.UseMySQL(conn);
});
以下两个AddDbContext方法都是Entity Framework Core中用于向依赖注入容器注册数据库上下文的扩展方法。前者只接受一个泛型参数 TContext,它既是服务类型也是实现类型;后者接受两个泛型参数,TContextService 是服务接口 / 基类,TContextImplementation 是具体实现类,满足注册一个抽象服务类型和其具体实现时使用。
我们观察到参数ServiceLifetime contextLifetime = ServiceLifetime.Scoped,也就是说在调用AddDbContext时候,如果未指定参数,默认DbContext的生命周期为Scoped。
AddDbContext源码
public static IServiceCollection AddDbContext
<[DynamicallyAccessedMembers(DbContext.DynamicallyAccessedMemberTypes)] TContext>(
this IServiceCollection serviceCollection,
Action<DbContextOptionsBuilder>? optionsAction = null,
ServiceLifetime contextLifetime = ServiceLifetime.Scoped,
ServiceLifetime optionsLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
=> AddDbContext<TContext, TContext>(serviceCollection, optionsAction, contextLifetime, optionsLifetime);
public static IServiceCollection AddDbContext
<TContextService, [DynamicallyAccessedMembers(DbContext.DynamicallyAccessedMemberTypes)] TContextImplementation>(
this IServiceCollection serviceCollection,
Action<DbContextOptionsBuilder>? optionsAction = null,
ServiceLifetime contextLifetime = ServiceLifetime.Scoped,
ServiceLifetime optionsLifetime = ServiceLifetime.Scoped)
where TContextImplementation : DbContext, TContextService
=> AddDbContext<TContextService, TContextImplementation>(
serviceCollection,
optionsAction == null
? null
: (_, b) => optionsAction(b), contextLifetime, optionsLifetime);
好了,我们知道了EF Core的DbContext默认生命周期为Scope。现在需要明确的是一次HTTP是否对应着一个Scope,注册Scoped生命周期的DbContext是否是共享同一个实例。
二、单次HTTP请求中DbContext的状态
2.1 准备工作
在一次HTTP请求中,我们可以通过观察DbContext实例后的对象来间接观察DbContext的状态。也就是说如果单次HTTP请求中DbContext只被实例化了一次,那就说明一次HTTP请求中只会注入同一个DbContext。
为了确认DbContext实例化后对象,我们在DbContext里创建一个实例ID,用来分辨实例化后的对象。
实例的唯一ID
public Guid InstanceId { get; } = Guid.NewGuid();
ASP.NET Core中往DI注册的服务生命周期分三种,为Scoped、Transient 和 Singleton。为了方便测试一次HTTP请求中DbContext的状态,我们接下来我们往Program.cs注册三种生命周期的DbContext。
并且在Controllerl里除了通过构造函数注入DbContext,还通过服务定位器模式从DI容器里再次获取DbContext实例,来判断二者是否相同。
在控制器中通过服务定位器模式第二次获取各种DbContext
var scopedDb2 = _serviceProvider.GetRequiredService<ScopedDbContext>();
var transientDb2 = _serviceProvider.GetRequiredService<TransientDbContext>();
var singletonDb2 = _serviceProvider.GetRequiredService<SingletonDbContext>();
最后我们再注册一个依赖DbContext的服务,在里面再次通过构造函数注入DbContext。
依赖DbContext的服务
public class TestService
{
public ScopedDbContext ScopedDb { get; }
public TransientDbContext TransientDb { get; }
public SingletonDbContext SingletonDb { get; }
public TestService(
ScopedDbContext scopedDb,
TransientDbContext transientDb,
SingletonDbContext singletonDb)
{
ScopedDb = scopedDb;
TransientDb = transientDb;
SingletonDb = singletonDb;
}
}
2.2 DbContext类型
建立三种DbContext,用于对应注册三种生命周期。并且通过基类分配一个用于初始化的ID。
BaseDbContext.cs
// 测试用的DbContext基类
public abstract class BaseDbContext : DbContext
{
public Guid InstanceId { get; } = Guid.NewGuid();
public BaseDbContext(DbContextOptions options) : base(options) { }
}
TransientDbContext.cs
public class TransientDbContext : BaseDbContext
{
public TransientDbContext(DbContextOptions<TransientDbContext> options) : base(options) { }
}
ScopedDbContext.cs
public class ScopedDbContext: BaseDbContext
{
public ScopedDbContext(DbContextOptions<ScopedDbContext> options) : base(options) { }
}
SingletonDbContext.cs
public class SingletonDbContext : BaseDbContext
{
public SingletonDbContext(DbContextOptions<SingletonDbContext> options) : base(options) { }
}
2.3 注册服务到DI容器
在Program文件里注册三种DbContext,和依赖DbContext的测试服务。
Program.cs
// 1. 注册Scoped生命周期的DbContext
builder.Services.AddDbContext<ScopedDbContext>(options =>
{
string conn = builder.Configuration["ConnectionStrings:MySQL"];
options.UseMySQL(conn);
},
ServiceLifetime.Scoped
);
// 2. 注册Transient生命周期的DbContext
builder.Services.AddDbContext<TransientDbContext>(options =>
{
string conn = builder.Configuration["ConnectionStrings:MySQL"];
options.UseMySQL(conn);
},
ServiceLifetime.Transient
);
// 3. 注册Singleton生命周期的DbContext
builder.Services.AddDbContext<SingletonDbContext>(options =>
{
string conn = builder.Configuration["ConnectionStrings:MySQL"];
options.UseMySQL(conn);
},
ServiceLifetime.Singleton
);
// 注册测试服务
builder.Services.AddScoped<TestService>();
2.4 依赖DbContext的测试服务
建立一个依赖DbContext的测试服务,同时也注入三种DbContext。
依赖DbContext的服务
public class TestService
{
public ScopedDbContext ScopedDb { get; }
public TransientDbContext TransientDb { get; }
public SingletonDbContext SingletonDb { get; }
public TestService(
ScopedDbContext scopedDb,
TransientDbContext transientDb,
SingletonDbContext singletonDb)
{
ScopedDb = scopedDb;
TransientDb = transientDb;
SingletonDb = singletonDb;
}
}
2.5 Controller里HTTP请求注入DbContext
ASP.NET Core中Controller是服务器端用于接收、处理这些请求的 “逻辑容器”,是处理请求的核心组件。每次请求,路由中间件在请求管道中负责路由匹配的核心组件,最后匹配到Controller里的Action上。
这里我们通过在Controller的构造函数注入三种生命周期的DbContext,依赖DbContext的测试服务和一个ServiceProvider。
其中ServiceProvider是为了通过服务定位器模式从DI里再次获取三种生命周期的DbContext。这样我们就能模拟出DbContext通过DI容器在控制器里三次注入DbContext的情况。
[Route("api/[controller]/[action]")]
[ApiController]
public class MovieController : ControllerBase
{
private readonly ScopedDbContext _scopedDb1;
private readonly TransientDbContext _transientDb1;
private readonly SingletonDbContext _singletonDb1;
private readonly TestService _testService;
private readonly IServiceProvider _serviceProvider;
public MovieController(ScopedDbContext scopedDb1, TransientDbContext transientDb1, SingletonDbContext singletonDb1, TestService testService, IServiceProvider serviceProvider)
{
_scopedDb1 = scopedDb1;
_transientDb1 = transientDb1;
_singletonDb1 = singletonDb1;
_testService = testService;
_serviceProvider = serviceProvider;
}
public ActionResult TestLifeCycle()
{
// 在控制器中通过服务定位器模式第二次获取各种DbContext
var scopedDb2 = _serviceProvider.GetRequiredService<ScopedDbContext>();
var transientDb2 = _serviceProvider.GetRequiredService<TransientDbContext>();
var singletonDb2 = _serviceProvider.GetRequiredService<SingletonDbContext>();
var result = new StringBuilder();
result.AppendLine("=== 单次HTTP请求中不同生命周期DbContext测试 ===");
result.AppendLine();
// 测试Scoped DbContext
result.AppendLine("1. Scoped DbContext:");
result.AppendLine($" 控制器直接注入实例ID: {_scopedDb1.InstanceId}");
result.AppendLine($" 服务中注入实例ID: {_testService.ScopedDb.InstanceId}");
result.AppendLine($" 第二次获取实例ID: {scopedDb2.InstanceId}");
result.AppendLine($" 同一请求内是否相同: {_scopedDb1.InstanceId == _testService.ScopedDb.InstanceId && _scopedDb1.InstanceId == scopedDb2.InstanceId}");
result.AppendLine();
// 测试Transient DbContext
result.AppendLine("2. Transient DbContext:");
result.AppendLine($" 控制器直接注入实例ID: {_transientDb1.InstanceId}");
result.AppendLine($" 服务中注入实例ID: {_testService.TransientDb.InstanceId}");
result.AppendLine($" 第二次获取实例ID: {transientDb2.InstanceId}");
result.AppendLine($" 同一请求内是否相同: {_transientDb1.InstanceId == _testService.TransientDb.InstanceId && _transientDb1.InstanceId == transientDb2.InstanceId}");
result.AppendLine();
// 测试Singleton DbContext
result.AppendLine("3. Singleton DbContext:");
result.AppendLine($" 控制器直接注入实例ID: {_singletonDb1.InstanceId}");
result.AppendLine($" 服务中注入实例ID: {_testService.SingletonDb.InstanceId}");
result.AppendLine($" 第二次获取实例ID: {singletonDb2.InstanceId}");
result.AppendLine($" 所有地方是否相同: {_singletonDb1.InstanceId == _testService.SingletonDb.InstanceId && _singletonDb1.InstanceId == singletonDb2.InstanceId}");
return Content(result.ToString(), "text/plain", System.Text.Encoding.UTF8);
}
}
然后不停刷新请求,我们观察到:在单次HTTP请求中被注册为Scoped的DbContext,无论控制器通过DI注入了多少次,得到的还是同一个实例。而Transient的DbContext,每次通过DI注入,获得的都是新的实例。最后是Singleton的DbContext,从服务启动开始,一直维持同一个实例。
执行结果
=== 单次HTTP请求中不同生命周期DbContext测试 ===
1. Scoped DbContext:
控制器直接注入实例ID: 1bd4f1df-8c03-433a-96b8-8c65a56fbf07
服务中注入实例ID: 1bd4f1df-8c03-433a-96b8-8c65a56fbf07
第二次获取实例ID: 1bd4f1df-8c03-433a-96b8-8c65a56fbf07
同一请求内是否相同: True
2. Transient DbContext:
控制器直接注入实例ID: 75aaf5d5-b474-4bd4-86f9-bfe2333fa3fa
服务中注入实例ID: 4a62dd59-a6cc-4d1a-a799-0b1aba97398f
第二次获取实例ID: d4513e70-12e4-40fc-8363-42ab5f93d80f
同一请求内是否相同: False
3. Singleton DbContext:
控制器直接注入实例ID: 67806fd0-58ae-45fc-8a52-0746712b6e9d
服务中注入实例ID: 67806fd0-58ae-45fc-8a52-0746712b6e9d
第二次获取实例ID: 67806fd0-58ae-45fc-8a52-0746712b6e9d
所有地方是否相同: True
2.6 更改依赖DbContext测试服务的生命周期
受限于依赖注入的原则,长生命周期的服务不能依赖于短生命周期的服务。所以这里的DbContext有且只能选择Scoped和Transient,注册Singleton运行时会异常报错。接下来我们测试Transient。
// 注册测试服务
builder.Services.AddTransient<TestService>();
执行结果其实并未有改变,这是因为虽然依赖DbContext的测试服务被注册为Transient,每次都是通过DI注入的一个全新的实例,但是就DbContext本身,还是取决于DbContext被注册的自己的生命周期。
换句话说TestService 的作用只是 “传递” 它所依赖的DbContext实例,而不是 “决定” 这些DbContext 的生命周期。
这样得出一个结论,像我们平常用到的数据库服务类,如果被注册了Transient。也仅仅是DI注入的时候会创建实例,不影响DbContext 。
执行结果
=== 单次HTTP请求中不同生命周期DbContext测试 ===
1. Scoped DbContext:
控制器直接注入实例ID: 10954500-fb13-4bb4-9551-3f27adf2a993
服务中注入实例ID: 10954500-fb13-4bb4-9551-3f27adf2a993
第二次获取实例ID: 10954500-fb13-4bb4-9551-3f27adf2a993
同一请求内是否相同: True
2. Transient DbContext:
控制器直接注入实例ID: 1cdad0b5-3a9f-4d18-9471-150528cb9d34
服务中注入实例ID: 3f70152d-156b-4d8e-9cbd-e35ca500b9cd
第二次获取实例ID: 49ce5f69-f8ab-458e-b367-dc6a58ec6107
同一请求内是否相同: False
3. Singleton DbContext:
控制器直接注入实例ID: c409c795-085b-424f-aba7-d23deab80180
服务中注入实例ID: c409c795-085b-424f-aba7-d23deab80180
第二次获取实例ID: c409c795-085b-424f-aba7-d23deab80180
所有地方是否相同: True
三、结论
至此,总结为以下几点内容:
- 通过Scoped 注册。同一HTTP请求内,无论在哪里获取DbContext,都是同一个实例。保证了单次请求中实体跟踪和事务等操作中数据操作的一致性,在请求结束后自动释放资源。并且依赖DbContext 的服务也是注册为Scoped 最佳。这是最为推荐的方式。
- 通过Transient注册。同一HTTP请求内,每次获取都会创建新的DbContext实例。这会导致实体状态无法共享,出现修改的数据不同步。并且会频繁创建和销毁实例。
- 通过Singleton注册。整个应用生命周期内只有一个实例,所有请求和服务共享。这样会导致多请求并发操作时会导致数据混乱和异常,线程不安全,并且出现内存泄漏的问题。要极力避免。