6

將多個 EF Core DbContext 包成 Transaction

 2 years ago
source link: https://blog.darkthread.net/blog/multi-dbcontext-transaction/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
將多個 EF Core DbContext 包成 Transaction-黑暗執行緒

之前介紹過將 EF Core DbContext 動作包入 Transaction,做法有兩種:呼叫 DbContext.Database.BeginTransaction() 啟動交易、用 TransactionScope 包住 DbContext 動作。如果要將兩個以上 DbContext 包進同一個 Transaction 呢?

花了點時間,參考微軟文件 EF Core - Using Transactions 以及其他參考資料,又做了實驗,這才有了概念。

先說結論:

  • 一樣是用 Database.BeginTransaction() 或 TransactionScope(),前者需共用連線及 Transaction 物件。
  • 共享 Transaction 時要用 A DbContext 的連線物件建立 B DbContext
  • 不支援分散式交易 不支援分散式交易 不支援分散式交易 參考
    好消息是 EF Core 7 將會支援
  • 我測試了 SQL 及 SQLite,發現 EF Core SQLite 不支援 TransactionScope 參考
    會出現 An ambient transaction has been detected, but the current provider does not support ambient transactions 錯誤

底下是我的實驗專案,包含 AlphaDbContext 及 BetaDbContext 兩個 DbContext,並分別使用 SQL 或 SQLite 測試。專案包含多個 DbContext 及多種資料庫來源,需透過 命令列參數 控制使用 SQL 或 SQLite,並在 ``dotnet ef migrations add``` 時加上 --context 及命令列參數以產生指定 DbContext 的 Migration 指令。相關技巧可參考這篇微軟文件 - Migrations with Multiple Providers

AlphaDbContext,宣告一個只有 Id 跟 Name 的資料表,在 Name 加上 Unique Index,測試時可藉由插入重複資料引發錯誤,觀察 Rollback 是否成功。

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace ext_trans_dbctx
{
    public class Alpha
    {
        [Key]
        public int AlphaId { get; set; }
        [MaxLength(32)]
        public string Name { get; set; }
    }

    public class AlphaDbContext : DbContext
    {
        public DbSet<Alpha> Alphas { get; set; }
        public AlphaDbContext(DbContextOptions<AlphaDbContext> options) : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Alpha>().HasIndex(c => c.Name).IsUnique();
            base.OnModelCreating(modelBuilder);
        }
    }
}

BetaDbContext 的結構跟 AlphaDbContext 完全相同。

using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;

namespace ext_trans_dbctx
{
    public class Beta {
        [Key]
        public int BetaId {get; set;}
        [MaxLength(32)]
        public string Name { get; set;}
    }

    public class BetaDbContext : DbContext
    {
        public DbSet<Beta> Betas { get; set; }
        public BetaDbContext(DbContextOptions<BetaDbContext> options) : base(options)
        {
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Beta>().HasIndex(c => c.Name).IsUnique();
            base.OnModelCreating(modelBuilder);
        }
    } 
}

測試程式如下,分別使用 TransactionScope 及 BeginTransaction() 測試。測試前先清空 Alphas 及 Betas 資料表並各寫一筆 "Init",接著在 AlphaDbContext.Alphas 插入一筆 "To Rollback",在 BetaDbContext.Betas 插入兩筆 "Duplicated",故意引發錯誤。若 Transaction 機制有效,AlphaDbContext 的寫入動作應該要被 Rollback,故最後兩資料表應只有一筆 Init。

讓 AlphaDbContext 跟 BetaDbContext 參與同一個 Transaction 的關鍵在於必須用 AlphaDbContext 使用中的 DbConnection 物件(透過 alphaCtx.Database.GetDbConnection() 取得) 去建立 BetaDbContext,DbContextOptionsBuilder.UseSqlServer() 或 UseSqlite() 一般是傳連線字串,但也可傳入 DbConnection,要求沿用即有資料庫連線,另外由 IDbContextTransaction.GetDbTransaction() 取得當時執行中的 DbTransaction 物件,傳給 BetaDbContext.UseTransaction() 引用,就能將兩個 DbContext 加進同一個 Transaction。

using System.Text;
using System.Transactions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace ext_trans_dbctx
{
    public class TransTester
    {
        private readonly AlphaDbContext alphaCtx;
        private readonly BetaDbContext betaCtx;
        public TransTester(AlphaDbContext alphaCtx, BetaDbContext betaCtx)
        {
            this.alphaCtx = alphaCtx;
            this.betaCtx = betaCtx;
        }
        public void ResetData()
        {
            alphaCtx.Database.Migrate();
            alphaCtx.Alphas.RemoveRange(alphaCtx.Alphas.ToArray());
            alphaCtx.Alphas.Add(new Alpha { Name = "Init" });
            alphaCtx.SaveChanges();

            betaCtx.Database.Migrate();
            betaCtx.Betas.RemoveRange(betaCtx.Betas.ToArray());
            betaCtx.Betas.Add(new Beta { Name = "Init" });
            betaCtx.SaveChanges();
        }

        public string Check()
        {
            return "Alphas: " +
                string.Join(',', alphaCtx.Alphas.Select(o => o.Name).ToArray()) + "\n" +
                "Betas: " +
                string.Join(',', betaCtx.Betas.Select(o => o.Name).ToArray());
        }
        public string TestTranScope()
        {
            ResetData();
            var sb = new StringBuilder();
            sb.AppendLine("*** TestTranScope ***");
            using (var ts = new TransactionScope())
            {
                try
                {
                    alphaCtx.Alphas.Add(new Alpha { Name = "To Rollback" });
                    alphaCtx.SaveChanges();
                    betaCtx.Betas.Add(new Beta { Name = "Duplicated" });
                    betaCtx.Betas.Add(new Beta { Name = "Duplicated" });
                    betaCtx.SaveChanges();
                    ts.Complete();
                }
                catch (Exception ex)
                {
                    sb.AppendLine(ex.Message + "\n" + ex.InnerException?.Message);
                }
            }
            sb.AppendLine(Check());
            return sb.ToString();
        }
        public string TestSharedTrans()
        {
            ResetData();
            var sb = new StringBuilder();
            sb.AppendLine("*** TestSharedTrans ***");
            using (var cn = alphaCtx.Database.GetDbConnection())
            {
                var trn = alphaCtx.Database.BeginTransaction();
                try
                {
                    alphaCtx.Alphas.Add(new Alpha { Name = "To Rollback" });
                    alphaCtx.SaveChanges();
                    // 以現有 DbConnection 建立 BetaDbContext
                    var optBuilder = new DbContextOptionsBuilder<BetaDbContext>();
                    if (alphaCtx.Database.ProviderName.Contains("Sqlite"))
                        optBuilder.UseSqlite(cn);
                    else optBuilder.UseSqlServer(cn);
                    var b = new BetaDbContext(optBuilder.Options);
                    // 呼叫 Datadata.UseTransaction() 參與同一交易
                    b.Database.UseTransaction(trn.GetDbTransaction());
                    b.Betas.Add(new Beta { Name = "Duplicated" });
                    b.Betas.Add(new Beta { Name = "Duplicated" });
                    b.SaveChanges();
                    trn.Commit();
                }
                catch (Exception ex)
                {
                    sb.AppendLine(ex.Message + "\n" + ex.InnerException?.Message);
                    trn.Rollback();
                }
                sb.AppendLine(Check());
                return sb.ToString();
            }
        }
    }
}

Program.cs 如下:

using System.Text.Encodings.Web;
using System.Text.Unicode;
using ext_trans_dbctx;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

/*
https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli
dotnet ef migrations add InitSql --context AlphaDbContext --output-dir Migrations\AlphaSql -- --dbType sql
dotnet ef migrations add InitSql --context BetaDbContext --output-dir Migrations\BetaSql -- --dbType sql
dotnet ef migrations add InitSqlite --context AlphaDbContext --output-dir Migrations\AlphaSqlite -- --dbType sqlite
dotnet ef migrations add InitSqlite --context BetaDbContext --output-dir Migrations\BetaSqlite -- --dbType sqlite
*/

if (builder.Configuration.GetValue<string>("dbType", "sql") == "sqlite")
{
    var cs = "data source=" + Path.Combine(builder.Environment.ContentRootPath,
        "demo.sqlite");
    builder.Services.AddDbContext<AlphaDbContext>(options =>
    {
        options.UseSqlite(cs);
    });
    builder.Services.AddDbContext<BetaDbContext>(options =>
    {
        options.UseSqlite(cs);
    });
}
else
{
    var cs = @"Data Source=(localdb)\MSSQLLOcalDB;Initial Catalog=TransTest;Integrated Security=True;";
    builder.Services.AddDbContext<AlphaDbContext>(options =>
    {
        options.UseSqlServer(cs);
    });
    builder.Services.AddDbContext<BetaDbContext>(options =>
    {
        options.UseSqlServer(cs);
    });
}
builder.Services.AddScoped<TransTester>();

var app = builder.Build();
app.MapGet("/", () =>
    Results.Content(
        @"<a href=transcope target=res>TransactionScope</a> 
        <a href=sharetrans target=res>Shared Transaction</a> <br />
        <iframe name=res style='width: 640px; height: 150px'></iframe>",
        "text/html"));
app.MapGet("/transcope", (TransTester tester) => tester.TestTranScope());
app.MapGet("/sharetrans", (TransTester tester) => tester.TestSharedTrans());
app.Run();

先使用 SQL LocalDB 測試,用昨天介紹的方法建立 TransTest.mdf,使用以下指令建立 Migrations 並建好資料表:

dotnet ef migrations add InitSql --context AlphaDbContext --output-dir Migrations\AlphaSql -- --dbType sql
dotnet ef migrations add InitSql --context BetaDbContext --output-dir Migrations\BetaSql -- --dbType sql

實測 TransactionScope 跟共用 Transaction 都成功,Alpha 的寫入動作在 Beta 寫入失敗時 Rollback,二資料表僅有 Init 一筆資料。

接著測試 SQLite,將 SQL 的 Migragions 程式移除 (將專案資料夾的 Migrations 目錄刪除),執行以下指令為 SQLite 建立 Migrations。

dotnet ef migrations add InitSqlite --context AlphaDbContext --output-dir Migrations\AlphaSqlite -- --dbType sqlite
dotnet ef migrations add InitSqlite --context BetaDbContext --output-dir Migrations\BetaSqlite -- --dbType sqlite

TransactionScope 因 SQLite EF Core Proivder 不支援失敗,共享 Transaction 則成功。

實驗完畢。

因程式檔較多,我把範例丟到 Github 了,想試玩的同學請自取。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK