將多個 EF Core DbContext 包成 Transaction
2 |
之前介紹過將 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 了,想試玩的同學請自取。
Tutorial of how to implementation transaction include multiple EF Core DbContext.
Comments
# by Ho.Chun
想請問下 "兩條連線到同一台 SQL Server 中的同一個 DB",這樣算分散式交易嗎 ? "兩條連線到同一台 SQL Server 中的不同個 DB",這樣算分散式交易嗎 ? "兩條連線到不同台 SQL Server",這樣算分散式交易嗎 ?
# by Jeffrey
to Ho.Chun,我的經驗,不同版本 Provider 行為可能不同(例如:ODP.NET 11.2 兩條連線字串相同的連線也會啟動分散式交易,但 12+ 跟 SQL 不會),以實測為準。參考:https://blog.darkthread.net/blog/oracle-msdtc-case/