【ASP.NET Core】探讨注入EF Core的DbContext在HTTP请求中的生命周期

发布于:2025-07-31 ⋅ 阅读:(18) ⋅ 点赞:(0)


前言

我们在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

三、结论

至此,总结为以下几点内容:

  1. 通过Scoped 注册。同一HTTP请求内,无论在哪里获取DbContext,都是同一个实例。保证了单次请求中实体跟踪和事务等操作中数据操作的一致性,在请求结束后自动释放资源。并且依赖DbContext 的服务也是注册为Scoped 最佳。这是最为推荐的方式。
  2. 通过Transient注册。同一HTTP请求内,每次获取都会创建新的DbContext实例。这会导致实体状态无法共享,出现修改的数据不同步。并且会频繁创建和销毁实例。
  3. 通过Singleton注册。整个应用生命周期内只有一个实例,所有请求和服务共享。这样会导致多请求并发操作时会导致数据混乱和异常,线程不安全,并且出现内存泄漏的问题。要极力避免。

网站公告

今日签到

点亮在社区的每一天
去签到