LoginSignup
3
4

Entity Framework CoreとDapper(又はADO.NET)を同一トランザクションで併用する

Last updated at Posted at 2022-11-16

Entity Framework Core(以下、EFCore)とDapperを併用したい時があります(Dapperでなくとも、ADO.NETを用いてDbConnectionを直接制御する場合も同様です)。
その場合、EFCoreではDbContext、DapperはDbConnectionを使うことになる為、同一トランザクションでの併用時には注意が必要となります。

主に問題となるのは、DbContextをDI経由で受け取っている場合に、Dapperを併用したい時です。Dapperに渡すDbConnectionをどこから持ってくるべきでしょうか。

DbContext.Database.GetDbConnection()の問題

DbContext.Database.GetDbConnection()は、Microsoft.EntityFrameworkCore.Relational NuGetパッケージをインストールすると利用可能になる拡張メソッドで、DbContextが内部で使用しているDbConnectionを取得できます。

しかし、取得したDbConnectionを勝手に破棄してしまうコードは良くありません。その後、DbContextが動作する際に例外が発生してしまいます。

やってはいけない
using var conn = DbContext.Database.GetDbConnection(); // ダメ、絶対

また、取得したDbConnectionがOpenされているかどうかは分からないので、OpenされていなければOpenするとします。

本当にこれでよいのか確信が持てない
var conn = DbContext.Database.GetDbConnection();
if (conn.State != ConnectionState.Open)
{
	conn.Open();
}

しかし今度は、勝手にCloseしてよいのか、もしくは、Closeせずに放置したらどうなるのかという疑問が出てきます。

このあたりについては以下で議論がされていますが、皆が混乱しているようにしか見えず、実際どうすべきなのかについて明確な指針になっていないように思います。

上記のスレッドをなんとなくまとめると、以下のようになります。

  • GetDbConnection()から取得したDbConnectionを破棄してはならない。それはDbContextに委ねる。
  • そのDbConnectionをOpenしたなら、Openしたコードが責任をもってCloseする(ベストプラクティス)。
  • Closeしなくても問題ないかもしれないが、Closeしないでおくと、無駄に開かれっぱなしになる可能性が高いし、最終的にちゃんとCloseされるかどうかも明言はされていない。

なんとも微妙です。ただ、Npgsqlの場合、DbContext自身は「必要になったときにDbConnectionをOpenし、必要なくなったらこまめにCloseする」ようになっているようです。

使っていない時、常にconn.StateはClosedになっている(Npgsqlで確認)
var conn = DbContext.Database.GetDbConnection();
// conn.State == ConnectionState.Closed

var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
// conn.State == ConnectionState.Closed

emp.empname = "社員1234";
// conn.State == ConnectionState.Closed

DbContext.SaveChanges();
// conn.State == ConnectionState.Closed

であれば、外側から勝手にOpen/Closeしても問題ないのかもしれませんが、確信は持てません。

実際、Npgsqlの場合、トランザクション中のconn.Stateはトランザクション開始時点からずっとOpenになっていました。

トランザクション中、conn.StateはOpenになっている(Npgsqlで確認)
var conn = DbContext.Database.GetDbConnection();
// conn.State == ConnectionState.Closed

using var trans = DbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);
// conn.State == ConnectionState.Open

var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
// conn.State == ConnectionState.Open

emp.empname = "社員1234";
// conn.State == ConnectionState.Open

DbContext.SaveChanges();
// conn.State == ConnectionState.Open

なので、もし、取得したDbConnectionがすでにOpen済だった場合は、勝手にCloseしてはダメだろうと思います。そう考えると、外部から使う場合、このような感じで「接続が閉じていれば開いてから閉じる。開いていればそのまま使って、閉じない」が比較的安全かもしれません。

閉じていれば開いて閉じる。開いていれば何もしない。
var conn = DbContext.Database.GetDbConnection();
bool alreadyOpened = (conn.State == ConnectionState.Open);
if (!alreadyOpened)
{
	// 接続を開く
	conn.Open();
}

// connに対して処理
... 中略 ...

if (!alreadyOpened)
{
	// 接続を閉じる
	conn.Close();
}

但し、Dapperを使う場合、Dapperはこれを自動でやってくれるようです。

つまり、Dapperは、「接続が閉じていれば開いてから閉じるし、開いていればそのまま使う(そして閉じない)」ということのようです(実際にどうかまでは確かめていません)。

であれば、Dapperを使う場合には次のようにシンプルに使えそうです。

var conn = DbContext.Database.GetDbConnection();

// connに対して処理
var list = conn.Query<Emp>(query);

これで問題なく動作するようにも思えますが、いくら探しても「これで大丈夫だよ」という記述が見つからず、不安が残ります。

同様の考え方で記事を書いている方もいらっしゃいますが、この方のやり方では「BeginTransactionを含むすべての処理の前に、GetDbConnection()で取得したDbConnectionをOpenする」つまり「コード側で完全にOpen/Closeのタイミングを制御する」ようにしており、この記事とは微妙に違います。この方のやり方が正しいかどうかも確証がありません。

上記記事で紹介されている方法
var conn = _dbContext.Database.GetDbConnection();

// ここでまず接続をオープンしてしまう
conn.Open();
// DbConnectionのBeginTransaction()を使ってトランザクションを開始
// (なぜ_dbContext.Database.BeginTransaction()を使わないかは不明)
using var transaction = conn.BeginTransaction();
try
{
    _dbContext.Database.UseTransaction(transaction as DbTransaction);

    _dbContextに対する処理();
    connに対する処理();

    // コミット
    transaction.Commit();
}
catch (Exception)
{
    // 例外発生時はロールバック
    transaction.Rollback();
    throw;
}
finally
{
    // 必ずクローズ
    conn.Close();
}

いちおう、DbContext.Database.BeginTransaction() を使った場合についても試してみましたが、問題なく動いています。
私はこちらの方が好みです。

上記記事で紹介されている方法のDbContext.Database.BeginTransaction()版
var conn = _dbContext.Database.GetDbConnection();

// 接続をオープン(しなくてもこの後のBeginTransaction()がOpenしてくれるが、明示的にしておく)
conn.Open();
// DbContext.Database.BeginTransaction()を使ってトランザクションを開始
using var transaction = _dbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);
try
{
    _dbContextに対する処理();
    connに対する処理();

    // コミット
    transaction.Commit();
}
catch (Exception)
{
    // 例外発生時はロールバック
    transaction.Rollback();
    throw;
}
finally
{
    // 必ずクローズ(CloseせずにほっといてもDbContextがCloseしてくれはするはずだが、少しでも早くCloseした方がパフォーマンスは良い)
    conn.Close();
}

トランザクションの統合についての注意

EFCoreとDapperを併用する場合、気を付けなければいけないのはトランザクションの統合です。
GetDbConnection()を用いて同一のDbConnectionを使う場合には、トランザクションも同一のものになるため、トランザクション的な問題はありません。

EFCoreとDapperを併用したトランザクション処理(DbConnection共有)

// DbContextのトランザクションを開始
using var trans = DbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);

// DbContextに対して何かしらの更新処理
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DbContextからDbConnectionを取得
var conn = DbContext.Database.GetDbConnection();

// DbConnectionを使ってさっき更新した社員情報を読み取る
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// emp2.empname は、"社員1234更新"

// トランザクションのコミット
trans.Commit();

しかし、何度も述べている通り、GetDbConnectionから取得したDbConnectionをDapperで使いまわすことで問題が起こらないとは、今のところ断言することができません(どなたかご存じだったらコメントで教えてください)。

また、このやり方だと、もし各処理が異なるリポジトリに分かれていた場合、リポジトリ内で接続のopen/closeをすべきかの判断に困ることになります。トランザクションが開始されていたらcloseしてはいけませんが、かといって、openされていなければopenしないと使えません。コンテキストに依存させたくはないですが、コンテキストを無視することもできないという状況です。

そういうこともあって、すでにあるDbContextとは別に、新しくDbConnectionを生成してDapperに渡しているケースもあるでしょう。その場合、何も考えずにトランザクションで使うと、おかしいことになります。

EFCoreとDapperを併用したトランザクション処理(DbConnectionを共有しない)
// DbContextのトランザクションを開始
using var trans = DbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);

// DbContextに対して何かしらの更新処理
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DbConnectionを生成
using var conn = new NpgsqlConnection(connectionString);

// DbConnectionを使ってさっき更新した社員情報を読み取る
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// ★emp2.empname は、"社員1234"のまま

trans.Commit();

トランザクションのIsolationLevelはReadCommittedですので、外部からはコミット前のデータが取得されます。よって、トランザクション外にあるconnの取得結果は、コミット前のデータとなります。

データを集計しつつDB更新をしていくようなバッチ処理では、これが大きな問題となる場合があります。
あるデータを集計し、DBを更新、その結果をDapperを使って別のテーブルと結合し、また集計し、更新…といった処理をしている時に、Dapperから更新前のデータを参照してしまったのではまずいのです。

かといって、トランザクション処理をせずに都度コミットしてしまうと、何か問題が起きた時にロールバックできません。

トランザクションスコープ

これを解決するのが、トランザクションスコープです。
トランザクションスコープは、複数のデータベース接続を一つのトランザクション管理下に置きます。
(但し、DBプロバイダが対応している場合のみ。NpgsqlやSqlServerは対応しているようです。この記事ではNpgsqlで確認しています)

トランザクションスコープの利用
// トランザクションスコープの開始
using var tscope = new System.Transactions.TransactionScope(
    System.Transactions.TransactionScopeOption.Required,
    new System.Transactions.TransactionOptions
    {
        IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
        Timeout = System.Transactions.TransactionManager.DefaultTimeout,
    }
);

// DbContextで何かしらの更新を行う
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DbConnectionを生成してさっき更新した社員情報を読み取る
using var conn = new NpgsqlConnection(connectionString);
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// emp2.empname は、"社員1234更新" を取得できる

// トランザクションスコープの確定
tscope.Complete();

この方法のよいところは、もし各処理が異なるリポジトリクラスに分かれたとしても、各自でDbConnectionを生成してopen/closeして問題ない、というところです。なぜなら、TransactionScopeは、スコープ中のDbConnectionのopen/closeに影響されない為です。

Pending transactions started using Transact-SQL or BeginTransaction are automatically rolled back when the connection is reset if connection pooling is enabled. If connection pooling is off, the transaction is rolled back after SqlConnection.Close is called. Transactions started through System.Transactions are controlled through the System.Transactions infrastructure, and are not affected by SqlConnection.Close.

Transact-SQL 又は BeginTransaction を使って開始された保留中のトランザクションは、コネクションプーリングが有効な場合、そのコネクションがリセットされた時に自動的にロールバックされます。コネクションプールが無効な場合、SqlConnection.Closeが呼ばれた後にロールバックされます。System.Transactions を通して開始されたトランザクションは、System.Transactionsインフラストラクチャによってコントールされ、SqlConnection.Close による影響を受けません。

これはとてもありがたい仕様です。実際、こういうユースケースの為に作られたものなのかもしれません。上記はSqlConnectionについての記述ですが、Npgsqlで試したみたところ、私かにTransactionScope中のDbConnection.Closeはトランザクションをロールバックさせませんでした。

ちなみに、Npgsqlでの話ですが、上記の間、実際にはDBにどのようなリクエストが投げられているのかと思い、PostgreSQLのログを見てみました。

[3412] LOG:  statement: BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED
[3412] LOG:  execute <unnamed>: SELECT ~省略~
[3412] DETAIL:  parameters: $1 ~省略~
[3412] LOG:  execute <unnamed>: UPDATE ~省略~
[3412] DETAIL:  parameters: $1 = '社員1234更新'  ~省略~
[3412] LOG:  execute <unnamed>: SELECT * FROM emp WHERE empcd = '1234'
[3412] LOG:  statement: COMMIT

なんと、DbContextとDbConnectionがそれぞれ、同じプロセスIDで動いています。
DbContextとDbConnectionは同一の接続文字列を用いている為、賢いNpgsqlがうまいことしてくれるようです。

他のDBプロバイダでも同様になるかは不明です。場合によっては分散トランザクションに昇格してしまってパフォーマンスが落ちるかもしれません。
尚、tscope.Complete()が呼ばれずにtscopeがDispose()されると、トランザクションは自動的にロールバックされます。

DbConnectionもDIしている場合はどうするのか

これまでの例では、Dapper用のDbConnectionは、DbContextから取得するか、その都度生成していました。
しかし、ASP.NET CoreではDbContextをDIするのが普通ですので、同じノリでDbConnectionもDIしているケースもあるのではないでしょうか。

Startup.cs
services.AddTransient<IDbConnection>(p => new NpgsqlConnection(connectionString));

この記事を読んだ後だと、この方法があまり良い方法ではないことがお分かりになるかと思います。
DIによって生成されたDbConnectionを受け取っても、それはTransactionScopeの範囲外となってしまいますので、EFCoreとの併用が難しくなります。

TransactionScopeを使って複数の接続をトランザクション・ブロックに入れたければ、TransactionScopeの開始後にDbConnectionを生成する必要があります。

つまり、DbConnectionをインスタンスとしてDIするのではなく、接続文字列をDIするか、DbConnectionを生成するファクトリを作ってそれをDIする方が、より汎用性が高いと思われます。

具体的には次のようなことです。

DbConnection生成用デリゲート
public delegate System.Data.IDbConnection CreateMyDbConnection();
Startup.cs
services.AddSingleton<CreateMyDbConnection>(p => () => new NpgsqlConnection(connectionString));
使用例
class MyClass {
	private readonly CreateMyDbConnection _createDbConn;

	public MyClass(CreateMyDbConnection createDbConn)
	{
		_createDbConn = createDbConn;
	}
	
	public string GetSomething()
	{
		// DIしたデリゲートからDbConnectionを生成
		using var conn = _createDbConn();
	}

}

ただ、実際にはもっとシンプルな例のことの方が多いでしょうし、DbConnection自体をDIして問題にならないケースもあるでしょう。

試してはいないのですが、いっそ、以下のようなDbConnection取得用デリゲートを定義して、内部ではDbContextからGetDbConnection()をするようにした上でDapperを使ってOpen/Closeの煩わしさを回避すれば、いろんな問題が片付くような気もしています。

DbConnection取得用デリゲート
public delegate System.Data.IDbConnection GetMyDbConnection();
Startup.cs
services.AddScoped<GetMyDbConnection>(p => () => p.GetRequiredService<MyDbContext>().Database.GetDbConnection());
使用例
// DbContextのトランザクションを開始
using var trans = DbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);

// DbContextに対して何かしらの更新処理
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DIしたデリゲートからDbConnectionを取得
var conn = _getDbConn();

// DbConnectionを使ってさっき更新した社員情報を読み取る
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// emp2.empname は、"社員1234更新"

// トランザクションのコミット
trans.Commit();

これでちゃんと動作するのですが、逆に「DbContextから取得したDbConnection」というDIコンテナ環境に依存したコードになってしまう気もしますね…。それだったらDbContextをDIした上で直接DbContext.Database.GetDbConnection()した方がましな気もしてきます。

結局、トランザクション管理自体が「全てのDB接続が同一かどうか」に依存してしまうので、抽象化して扱うよりも、コード側で「DbContext.Database.GetDbConnection()してDbContext.Database.BeginTransactionする」か、「接続文字列をDIして、DbConnectionを新規生成して、TransactionScopeでまとめて扱う」かを選択する方が、環境への依存がなくなってよいのかもしれません。

まとめ

この問題はややこしいので、以下にまとめます。

1.DbContext.Database.GetDbContext()をDapperに渡す方法

不安はあれどこれで問題ないようにも思います。

EFCoreとDapperを併用したトランザクション処理(DbConnection共有)

// DbContextのトランザクションを開始
using var trans = DbContext.Database.BeginTransaction(IsolationLevel.ReadCommitted);

// DbContextに対して何かしらの更新処理
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DbContextからDbConnectionを取得
var conn = DbContext.Database.GetDbConnection();

// DbConnectionを使ってさっき更新した社員情報を読み取る
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// emp2.empname は、"社員1234更新"

// トランザクションのコミット
trans.Commit();

【メリット】

  • シンプルに記述できる
  • Dapperに渡すDbConnectionの管理が不要

【デメリット】

  • 果たしてこれでEFCore的に問題ないのか不安が残る(トランザクション中のOpen/Closeはどうなっているのか?など)
  • Dapperを利用するリポジトリクラスやDAOクラスに、DbConnection(又は接続文字列)をDIする方法が使えない

※デメリット2については、DbConnectionではなくDbContextをDIすれば解決するが、それらのクラスが必要なのはDbConnectionなのに、DbContextに依存させるのもどうかとも考えてしまう。ただ、それを言い始めるとそもそもDbContextとDbConnection(ADO.NET)を一緒に使おうとしていること自体がイレギュラーな気もするので、気にせずDbContextをDIすればよいのかも。

2. DbConnectionを各自で生成する方法

方法1に不安が残る為、接続文字列やファクトリをDIするなりして各自でDbConnetionを生成するやり方です。
ただ、その代わりにトランザクションスコープを用いたトランザクション制御が必要になる為、逆にこちらの方が不安定かもしれません。とはいえ、TransactionScopeはMicrosoftが正式に推奨しているトランザクション管理方法なので、この方が安全とも言えます。

トランザクションスコープの利用
// トランザクションスコープの開始
using var tscope = new System.Transactions.TransactionScope(
    System.Transactions.TransactionScopeOption.Required,
    new System.Transactions.TransactionOptions
    {
        IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
        Timeout = System.Transactions.TransactionManager.DefaultTimeout,
    }
);

// DbContextで何かしらの更新を行う
var emp = DbContext.emp.FirstOrDefault(x => x.empcd == "1234");
emp.empname = "社員1234更新";
DbContext.SaveChanges();	// まだコミットはされていない

// DbConnectionを生成してさっき更新した社員情報を読み取る
using var conn = new NpgsqlConnection(connectionString);
var emp2 = conn.QuerySingle<emp>("SELECT * FROM emp WHERE empcd = '1234'");
// emp2.empname は、"社員1234更新" を取得できる

// トランザクションスコープの確定
tscope.Complete();

【メリット】

  • 方法1の「EFCore的に、内部のDbConnectionを勝手に外部で使いまわして大丈夫なの?」という不安への回答
  • Dapperしか使わないリポジトリクラスやDAOクラスの場合、各自で自由にDbConnectionを生成してOpen/Closeすればよく、DbContextに依存しない汎用性が高いコードを書ける

【デメリット】

  • TransactionScopeを使うまでもない例にまでTransactionScopeを使わざるを得ないパターンも出てくる為、大げさな気もする
  • TransactionScopeに対応していないDBプロバイダの場合、使えない

以上、識者の方からのアドバイスをお待ちしております。

追記:この問題をChatGPT-3に解かせてみた

私がChatGPTに出した指示:

ASP.NET CoreでEF CoreとDapperを併用し、EF Coreを使ってテーブルを更新し、その値をDapperを使って読み取り、問題があった場合にトランザクションをロールバックするコードのサンプルをください。

ChatGPT-3が提示したコード
using var transaction = await _dbContext.Database.BeginTransactionAsync();

try
{
    // EF Coreで更新処理を行う
    var user = await _dbContext.Users.FindAsync(userId);
    user.Name = "New Name";
    await _dbContext.SaveChangesAsync();

    // Dapperで読み取り処理を行う
    using var connection = new SqlConnection(_configuration.GetConnectionString("DefaultConnection"));
    await connection.OpenAsync();
    var parameters = new { Id = userId };
    var result = await connection.QueryAsync<User>("SELECT * FROM Users WHERE Id = @Id", parameters, transaction);
    
    // 確認用にコンソールに出力
    Console.WriteLine($"Updated user name: {user.Name}");
    Console.WriteLine($"Dapper read user name: {result.FirstOrDefault()?.Name}");
    
    // コミット
    await transaction.CommitAsync();
}
catch (Exception ex)
{
    // ロールバック
    await transaction.RollbackAsync();
    throw ex;
}

追加で行った私の質問:

Dapperに、_dbContext.Database.GetDbConnection()を直接利用させないのはなぜですか?

ChatGPT-3の回答
Dapperに、_dbContext.Database.GetDbConnection()を直接利用しない理由は、EF CoreとDapperがそれぞれ異なる方法で接続プールを管理しているためです。

EF Coreは、.NET FrameworkのSystem.Data.SqlClientまたは.NET CoreのMicrosoft.Data.SqlClientを使用してデータベース接続を作成します。EF Coreの接続プールは、接続文字列をキーとして、SqlConnectionオブジェクトのプールを管理します。

一方、Dapperは、.NET FrameworkのSystem.Data.SqlClientまたは.NET CoreのMicrosoft.Data.SqlClientを使用してデータベース接続を作成しますが、Dapper自体は接続プールを持っていません。そのため、Dapperで接続を使用する場合は、明示的に接続を開き、クエリの実行が完了したら接続を閉じる必要があります。

つまり、EF CoreとDapperで共通の接続プールを利用することはできません。そのため、_dbContext.Database.GetDbConnection()を直接Dapperに渡すことはできません。

この問題を回避するために、通常は_dbContext.Database.GetDbConnection().ConnectionStringを使用して、EF Coreで使用される接続文字列を取得し、それをDapperで使用するSqlConnectionのコンストラクタに渡します。これにより、EF CoreとDapperで異なる接続プールを使用することができます。

この説明には誤りがあるように思う。EF Coreで使用される接続文字列をSqlConnectionに渡しているのだから、それらは異なる接続プールではなく、同一の接続プールを使用することになると思われる。その点を指摘したところ、誤りを認めた。

最終的な回答は、「transactionをQueryAsyncやExecuteAsyncに渡して同一トランザクションにしたい場合には、SqlConnectionにEF Coreと同じ接続文字列を渡さなければならない」だった。

紆余曲折はあったものの、ChatGPTの回答はとても参考になった。
ひとつは、やはりEF CoreのDbConnectionをDapperに直接渡す方法には問題がありそうだ、ということ。もうひとつは、分散トランザクションを使わずとも、以下の方法で同一トランザクションを実現できる、ということだ(まだ本当かどうかわからないが、正しく動作する可能性は高い)。

  1. DbContextのBeginTransactionを用いてトランザクション制御を行う
  2. 接続文字列をDbContextから取得して同一接続文字列でDbConnectionを生成する
  3. DbConnectionを使ったDapperの呼び出しに、1のtransactionを引数として与える

まだ試していないのだが、これが使えるならコードは結構シンプルにできそうだ。
ChatGPTが提示するコードは、世界中のプログラマーの知恵から生成された総意に近いはずなので、この手の質問の回答はかなり参考になる(但し細部は間違っていることが多い)。

追記:ChartGPT-4に解かせてみた

ChatGPT-4を契約したので、同じ質問をGPT-4にも投げてみたところ、普通にGetDbConnection()している…。

using System;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Dapper;

public class MyEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<MyEntity> MyEntities { get; set; }

    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
    {
    }
}

public class MyRepository
{
    private readonly IServiceProvider _serviceProvider;

    public MyRepository(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task UpdateAndReadEntity()
    {
        using var scope = _serviceProvider.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<MyDbContext>();

        using var transaction = dbContext.Database.BeginTransaction();

        try
        {
            // EF Coreでデータを更新
            var entity = dbContext.MyEntities.First();
            entity.Name = "New name";
            await dbContext.SaveChangesAsync();

            // Dapperで更新されたデータを読み取る
            var connection = dbContext.Database.GetDbConnection();
            var entityFromDapper = await connection.QueryFirstOrDefaultAsync<MyEntity>("SELECT * FROM MyEntities WHERE Id = @Id", new { entity.Id }, transaction.GetDbTransaction());

            // データが正しく更新されていることをチェック
            if (entityFromDapper.Name != "New name")
            {
                throw new Exception("Name doesn't match");
            }

            await transaction.CommitAsync();
        }
        catch (Exception)
        {
            // 何か問題があった場合、トランザクションをロールバック
            await transaction.RollbackAsync();
            throw;
        }
    }
}

これについて、「GetDbConnection()で取得したconnectionがCloseされていたらどうするのか?」と確認したところ、以下の回答を得た。

通常、EF Coreはデータベース操作ごとに(つまり、各クエリやSaveChanges()呼び出しで)自動的に接続を開き、操作が完了したら接続を閉じます。

ただし、明示的なトランザクションが開始されている場合(dbContext.Database.BeginTransaction()が呼び出されている場合など)、そのトランザクションが有効な間は接続が開いたままになります。これは、同一の接続上で複数の操作を一連のトランザクションとして扱う必要があるからです。

したがって、あなたがトランザクション内でDapperを使用しているときは、EF Coreがその接続を管理しているため、接続が開いているはずです。したがって、通常は手動で接続を開く必要はありません。

なるほど、これは私自身が確認した結果とも符合する。

ということは、GetDbConnection()で問題ないということかもしれない。

TransactionScope版も作ってもらったので記載しておく(ほぼ同じコードとなった)。

TransactionScope版
using System;
using System.Data;
using System.Linq;
using System.Transactions;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Dapper;

public class MyEntity
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class MyDbContext : DbContext
{
    public DbSet<MyEntity> MyEntities { get; set; }

    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
    {
    }
}

public class MyRepository
{
    private readonly IServiceProvider _serviceProvider;

    public MyRepository(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task UpdateAndReadEntity()
    {
        using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
        using var serviceScope = _serviceProvider.CreateScope();
        var dbContext = serviceScope.ServiceProvider.GetRequiredService<MyDbContext>();

        try
        {
            // EF Coreでデータを更新
            var entity = dbContext.MyEntities.First();
            entity.Name = "New name";
            await dbContext.SaveChangesAsync();

            // Dapperで更新されたデータを読み取る
            var connection = dbContext.Database.GetDbConnection();
            if (connection.State == ConnectionState.Closed)
            {
                await connection.OpenAsync();
            }
            var entityFromDapper = await connection.QueryFirstOrDefaultAsync<MyEntity>("SELECT * FROM MyEntities WHERE Id = @Id", new { entity.Id });

            // データが正しく更新されていることをチェック
            if (entityFromDapper.Name != "New name")
            {
                throw new Exception("Name doesn't match");
            }

            scope.Complete();
        }
        catch (Exception)
        {
            // 何か問題があった場合、トランザクションはロールバックされます。
            throw;
        }
    }
}

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4