我們都知道 ASP.NET Core 依賴注入(DI)容器註冊服務有三種生命週期選項:Singleton、Scoped、Transient,依先前學習 Autofac 建立的概念,Singleton 是從頭到尾共用一個、Transient 是每次建立新物件,每次 Request 共用的 Scoped 則是 .NET Core 的新概念,且幾乎只用在 DbContext 上。微軟 DI 文件針對 EF DbContext 有段特別說明

By default, Entity Framework contexts are added to the service container using the scoped lifetime because web app database operations are normally scoped to the client request.
EF DbContext 預設都會註冊為 Scoped 生命週期,理由是一次客戶端請求的資料庫作業常會自成一體。

當初讀這段文字沒什麼感覺,但隨著對 EF Core Transaction 認識更深,漸能體會其背後的意涵。

DbContext 每次 SaveChanges(),累積的資料庫更新要嘛全部成功、要嘛全部取消,自成一次 Transaction;想將多個 SaveChanges() 動作包成一個 Transaction,或是跟 Dapper 更新、其他 DbContext 更新包成 Transaction,則可透過 TransactionScope 或 Database.BeginTransaction() 達成。

【延伸閱讀】

啟用 DbContext.Database.BeginTransaction() 之後,該 DbContext 的所有異動都會自動參與交易,最後靠 Commit()/Rollback() 更新或取消,不像傳統 ADO.NET 寫法每個動作要傳 Transaction 物件當參數,寫法簡潔,深得我心。而將 DbContext 設為 Scoped,同一次 HttpRequest 裡,所有服務或 Repository 用到 DbContext 將會是同一顆(前題是要透過 DI 取得),一旦啟用交易,各服務或 Respository 進行的資料庫動作都會自動包成 Transaction,跟 TransactionScope 有異曲同工之妙。

我打算用實驗來驗證這點。

建立一個 Minmal API 專案,在其中宣告一個 MyDbContext 及 AlphaRepository、BetaRepository 兩個引用 MyDbContext 的服務。

using Microsoft.EntityFrameworkCore;

namespace dbctx_scope
{
    public class Alpha
    {
        public int AlphaId { get; set; }
        public string Name { get; set; }
    }
    public class Beta {
        public int BetaId { get; set; }
        public string Name { get; set; }

    }
    public class MyDbContext : DbContext
    {
        public DbSet<Alpha> Alphas { get; set; }
        public DbSet<Beta> Betas { get; set; }

        public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { }
    }
    public class AlphaRepository
    {
        private readonly MyDbContext ctx;
        public AlphaRepository(MyDbContext ctx)
        {
            this.ctx = ctx;
        }
        public MyDbContext DbContext => ctx;
        public void AddData() {
            ctx.Alphas.Add(new Alpha { Name = "From Alpha" });
            ctx.SaveChanges();
        }        
        public string AllData => string.Join(",", ctx.Alphas.Select(o => o.Name).ToArray());

    }
    public class BetaRepository
    {
        private readonly MyDbContext ctx;
        public BetaRepository(MyDbContext ctx)
        {
            this.ctx = ctx;
        }
        public MyDbContext DbContext => ctx;
        public void AddData() {
            ctx.Betas.Add(new Beta { Name = "From Beta" });
            ctx.SaveChanges();
        }
        public string AllData => string.Join(",", ctx.Betas.Select(o => o.Name).ToArray());
    }
}

Program.cs 分別註冊 MyDbContext、AlphaRepository、BetaRepository,在 MapGet("/") 裡跑簡單測試:
用 GetHashCode() 比對 AlphaRepository 與 BetaRepository 取得的 MyDbContext 是不是同一個,AlphaRepository 的 MyDbContext 呼叫 BeginTransaction(),啟動交易後,看 BetaRepository 的 CurrentTransaction 是否是啟動狀態。最後實測用 AlphaRepository、BetaRepository 分別寫入資料,觀察 Rollback() 後是否一起消失。

using System.Text;
using dbctx_scope;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

var dbPath = Path.Combine(
    builder.Environment.ContentRootPath, "test.sqlite");
if (File.Exists(dbPath)) File.Delete(dbPath);
builder.Services.AddDbContext<MyDbContext>(b => {
    b.UseSqlite($"data source={dbPath}");
});
builder.Services.AddScoped<AlphaRepository>();
builder.Services.AddScoped<BetaRepository>();

var app = builder.Build();

app.MapGet("/", (AlphaRepository a, BetaRepository b) =>
{
    a.DbContext.Database.EnsureCreated();
    var sb = new StringBuilder();
    sb.AppendLine($"DbContext in AlphaRepository = {a.DbContext.GetHashCode()}");
    sb.AppendLine($"DbContext in BetaRepository = {b.DbContext.GetHashCode()}");
    sb.AppendLine($"Beta DbContext Transactdion (Before)= {b.DbContext.Database.CurrentTransaction}");
    using (var trn = a.DbContext.Database.BeginTransaction())
    {
        sb.AppendLine($"Beta DbContext Transactdion (After)= {b.DbContext.Database.CurrentTransaction}");
        sb.AppendLine($"Transaction HashCode (Alpha vs Beta) = {a.DbContext.Database.CurrentTransaction?.GetHashCode()} vs {b.DbContext.Database.CurrentTransaction?.GetHashCode()}");
        a.AddData();
        try
        {
            b.AddData();
        }
        catch (Exception e)
        {
            sb.AppendLine($"Error-{e.Message}");
        }
        sb.AppendLine($"Data Before Rollback = {a.AllData} / {b.AllData}");
        trn.Rollback();
    }
    sb.AppendLine($"Data After Rollback = {a.AllData} / {b.AllData}");
    return sb.ToString();
});

app.Run();

實驗證明,AlphaRepository 與 BetaRepository 取得的 MyDbContext 是同一顆,二者的 AddData() 也被包成同一個交易:

最後惡搞一下,故意將 MyDbContext 改註冊為 Transient,每次取用都建立新物件。

builder.Services.AddTransient<MyDbContext>(svc =>
{
    var opt = new DbContextOptionsBuilder<MyDbContext>()
        .UseSqlite($"data source={dbPath}").Options;
    return new MyDbContext(opt);
});

如此,AlphaRepository、BetaRepository 的 MyDbContext 不同,甚至 AlphaRepository 啟用交易後,還引發資料庫鎖定導致 BetaRepository 無法寫入,想當然爾,兩邊也無法包成交易。

不過,若資料庫從 SQLite 換成 SQL Server 的話,BetaRepository 的 AddData() 就不會被 AlphaRepository Transaction 卡住,無法形成交易但 "From Beta" 可寫入。

透過以上實驗,我們觀察到同一 HttpRequest 引用 Repository 會共用同一顆 DbContext 自動包成 Transaction 的行為,說明 EF Core DbContext 該註冊為 Scoped 的好理由。

Using a simple experiment to show why EF Core DbContext should be registered as scoped object.


Comments

# by Kirai

請問使用efcore + sqlite 還會不會有同時寫入錯誤的問題 例如之前這篇 https://blog.darkthread.net/blog/sqlite-multithreading-issue/

# by Jeffrey

to Kirai,這我倒沒研究過,EF Core 有自己的 Transaction 做法 https://blog.darkthread.net/blog/efcore-transaction-experiment/ ,EF Core SQLite 版應該也會遵循該原則處理 Transaction。

Post a comment