【ASP.NET Core】探讨注入EF Core的DbContext在HTTP请求中的生命周期
文章目录
- 前言
- 一、EF Core的DbContext默认生命周期
- 二、单次HTTP请求中DbContext的状态
- 2.1 准备工作
- 2.2 DbContext类型
- 2.3 注册服务到DI容器
- 2.4 依赖DbContext的测试服务
- 2.5 Controller里HTTP请求注入DbContext
- 2.6 更改依赖DbContext测试服务的生命周期
- 三、结论
前言
我们在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(){// 在控制器中通过服务定位器模式第二次获取各种DbContextvar 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 DbContextresult.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 DbContextresult.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 DbContextresult.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同一请求内是否相同: True2. Transient DbContext:控制器直接注入实例ID: 75aaf5d5-b474-4bd4-86f9-bfe2333fa3fa服务中注入实例ID: 4a62dd59-a6cc-4d1a-a799-0b1aba97398f第二次获取实例ID: d4513e70-12e4-40fc-8363-42ab5f93d80f同一请求内是否相同: False3. 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同一请求内是否相同: True2. Transient DbContext:控制器直接注入实例ID: 1cdad0b5-3a9f-4d18-9471-150528cb9d34服务中注入实例ID: 3f70152d-156b-4d8e-9cbd-e35ca500b9cd第二次获取实例ID: 49ce5f69-f8ab-458e-b367-dc6a58ec6107同一请求内是否相同: False3. 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注册。整个应用生命周期内只有一个实例,所有请求和服务共享。这样会导致多请求并发操作时会导致数据混乱和异常,线程不安全,并且出现内存泄漏的问题。要极力避免。