ASP.NET Core, EF Core, ORM like Dapper 環境下での統一的手法によるRDB Transactionの管理とその DI 実装について
TL;DR
この記事は、
- ASP.NET Core + EF Core + ORM like Dapper 環境下で
- RDB に対する複数の ORM による操作を
- 単一のDB接続と単一のDBトランザクションとしてDI側で管理し
- ASP NET Core の Scoped = ユーザーセッションの範囲での
- 複数のDIを跨いだ DBトランザクションの Commit, Rollback を実現する
ための苦労や理屈、実装の挑戦が書かれています。
また、その成果として、 OneWorldDbClient
という(壮大な名前の)コンポートを作成し、公開 することが出来ました。
<https://github.com/SiroccoHub/OneWorldDbClient>
このコンポーネントを利用することで、
- 複数のORMを跨ぐDBトランザクション操作を、統一的な手法で、簡単に提供することが出来る
- 複数のDIを横断するDBトランザクションのロールバック機構を、簡単に導入することが出来る
- EF Core 利用者と Dapper 利用者が、これまで以上に仲良くなれる
を実現します。
Abstract for C# Engineer
ASP. NET Core は標準の O-R Mapper (ORM)として Entity Framework Core (EF Core) を採用しています。
EF Core は大変便利ですが、使い方の自由度が高く、適用可能な技術スタックのカバー範囲が広いため、非効率的な処理を無意識に書いてしまったり、業務の傾向を考えず運用環境向けに インデックス利用の最適化や運用影響を考えず DB Migration してしまったりと、なにかと課題があるため、敬遠されます。
しかしながら、EF Core を使えば、単体テストの際にデータベースを生成することは、とても簡単になります。どのみち利用することになるテーブルをイメージしたクラスを拡張すれば、単体テスト向けにDDLを更新する必要はありません。また、挿入処理、更新処理においても、型付き定義を利用する前提なので、IDEによる支援を受けることが出来ます。
一方で、Dapper は人気が高いライトなORMの代表です。Dapperは、主にテキストベースのクエリテンプレートによるクエリ作成支援と、戻り値をオブジェクトにマッピングすることに主軸を置いた、軽量なライブラリです。RDBに接続する際の手続きは大変にシンプルで、従来からの「DB接続→クエリ作成→結果受取」の手続きのモデルから逸脱しない使い方が出来るため、非常に明確な範囲で工程を簡略化することが出来ます。
Dapper ではSQL文を自分で書くことを前提としますが、それはメリットではありますが、型付きではないクエリの作成は、使い方次第でデメリットにもなります。これは、そもそもで管理対象の抽象度が違うものをマッピングしようとする、Micro- ORM と言われる各種のライブラリ全般で言えることです。
であれば、EF Core と Dapper のような ORM を、適材適所、共存して使えばいいのではないか、という期待をして当然だと思います。
この記事は、そのアプローチについて、ASP NET Core 環境下でのDI化の流れの中で考えた結果、実践の一つとして汎用的に利用可能なDIコンポーネントが出来ました、NuGet化しました、課題についてディスカッションしたい、という内容です。
Abstract for IT Architect or.
※ 後述
実装例 / 使い方
前提条件
この記事は、以下の技術スタックを利用している場合に最も有用になります。
- .NET Core 2.0 later / ASP.NET Core 2.0 later with DI approach
- Entity Framework Core 2.0 later / ORM like Dapper
- Microsoft SQL Server / Azure SQL Database
Code on GitHub:
READMEが劇的に貧弱ですが、ASP NET Core 3.0 preview 8 でのサンプル実装と、単体テストが同梱されているので、コードと本記事を通して読んでください。
Package on Nuget:
コアになる部分がこちら。実際に利用するには、以下も必要です。
Microsoft SQL Server に依存する部分だけ切り出してます。先々別の対象に向けても実装出来ると楽しいな、という思いから、別に切り出してます。
プロダクション向け開発工程および実行環境での利用
この実装は、試験的な実装ではあるものの、比較的に規模が小さく、業務データの整合性も見通しがよいプロジェクトの、プロダクション環境下での利用を既に開始しています。
プロダクション環境下での実践によるさらなる課題の発見は、本コンポーネントの品質を継続的に改善することになるでしょう。
ASP NET Core 環境下での利用例
このコンポーネントを利用するには、最初に NuGet Package を2つインストールしてください。
Install-Package OneWorldDbClient
Install-Package OneWorldDbClient.SqlServer
次に、Startup.cs で DI に OneWorldDbClientManager<TDbContext>
を登録します。 TDbContext
には、自身の業務DBの DbContext を指定します。
指定する自身の環境の DbContext は、CodeFirst で生成された DbContext でも、 Scaffold した既存の DbContext でもかまいません。
第1引数の string は、接続文字列を指定します。サンプルでは Configuration から取得していますが、別のDIから取得してもかまいません。
第2引数には、その DbContext の初期化処理を記載します。本コンポーネントはそのコンポーネント内部でDB接続とトランザクションを作成し管理します。これらをDbContextで利用するための初期化処理になります。
第3引数には、新規DB接続の作成処理を記載します。
第4引数、第5引数は、ILogger をDIから生成したものを渡します。
あとは Controller や View 、自作の DI 処理で使うだけです。
コンストラクタ経由で受けとった OneWorldDbClientManager<TDbContext>
を利用し、トランザクション処理を以下のように記載します。
提供されるこれらのメソッドを利用して、トランザクションを作成したり、参加したりすることが出来ます。
OneWorldDbClientManager<T>.BeginTranRequiredAsync()
既存のトランザクション(アンビエント・トランザクション)に参加します。既存のトランザクションが存在しない場合は、引数に指定したトランザクション分離レベルを用いて新規にトランザクションを作成します。
トランザクション分離レベルの既定値は IsolationLevel.ReadCommitted です。既存に存在するトランザクションと別のトランザクション分離レベルを指定した場合には、Exception が発生します。
OneWorldDbClientManager<T>.BeginTranRequiresNewAsync()
新規にDB接続とトランザクションを発行します。既存のトランザクションとは完全に分離されているため、既存のトランザクションで発行された変更を知ることは出来ません。また、新規に発行されたトランザクションのロールバックは、より上流や下流に存在する別のトランザクションには、影響を与えません。
このあたりは、TransactionScope() に期待する動作、たとえばアンビエント・トランザクションを想像すれば、理解は容易だと思います。
OneWorldDbTransactionScope<T>
前述のメソッドの戻り値は、 IDisposable を実装したOneWorldDbTransactionScope<T>() です。このオブジェクトが公開する以下のメンバーを利用し、DBトランザクション処理を記述します。
public readonly IDbConnection DbConnection;
public readonly IDbTransaction DbTransaction;
public readonly DbContext DbContext;
EF Core での操作では、DbContext を使うことで、一般的なスタイルでのEF Core の操作が可能です。
await txScope.DbContext.Set<SampleTable01>()
.AddAsync(new SampleTable01
{
SampleColumn01 = "identifier"
});
await txScope.DbContext.SaveChangesAsync();
DbConnection と DbTransaction は、例えば Dapper を利用する環境であれば、以下のような書き方でかまいません。
i = (await txScope.DbConnection.QueryAsync<int>(
"SELECT COUNT(*) FROM [SampleTable01] " +
"WHERE [SampleColumn01] in @Values",
new {
Values = identifierAll
},
txScope.DbTransaction))
.FirstOrDefault();
OneWorldDbTransactionScope<T>. VoteCommit() / .VoteRollback()
OneWorldDbTransactionScope<T>() を生成した場合には必ず、 Commit したいのか Rollback したいのかの意思表示が必須です。意思表示が行われなかった場合には、そのトランザクション処理を Rollback する意思があると解釈します。
これは、単なる参照処理のために当該トランザクションが利用された場合にも適用されます。
OneWorldDbTransactionScope<T>.Committable
任意のタイミングで bool 値を返す .Committable を参照することで、これまでに VoteRollback() を呼び出した業務処理があるかを確認することが出来ます。この値が true であれば、これまでの処理におて全員が VoteCommit() していることが保証されます。false の場合には、当該トランザクションは Rollback することが確定していますので、業務処理を行わずに自身も VoteRollback() して処理を抜けることが可能です。
Transaction が確定されるタイミング
DBトランザクションの振る舞いは、すべての OneWorldDbTransactionScope の意見表明が終了した時点で確定されます。
アンビエント・トランザクションとは関係なく発行した新規トランザクションは、そのスコープが終了した時点で確定されますし、アンビエント・トランザクションに参加した場合には、ルートになるトランザクションがスコープから外れた時点で、確定されます。
トランザクションに参加した処理の意見表明に、一つでも VoteRollback() が存在した場合、そのトランザクションはロールバックされます。
OneWorldDbClientManager は、通常の利用シーンとして Scoped な範囲でDI に登録されることを期待しています。これは、ASP.NET Core の単一リクエストの範囲に横断して単一のトランザクションが提供されることを意味します。
このことは、リクエストのライフサイクルを横断して、Controller と、複数の DI 処理と、View での処理を通じて、一貫性のあるDBトランザクション操作を提供することを意味します。
実装上は不適切な例ですが、例えば以下のような処理を可能にします。
- DIで提供された業務処理AでレコードAを更新する
- DIで提供された業務処理BでレコードAを参照しレコードBを更新する
- 当該処理を呼び出したあとのControllerで、業務処理DIの実行結果を参照し、全ての処理が Commit() を期待する場合に、任意の View を利用して応答する
- 任意の View 内部において、DIで提供された判定処理Cを利用し、レコードAおよびBおよび外部リソースを用いて条件判定し、その結果として、全ての業務処理をロールバックする
これらの機能を組み合わせることで、マイクロサービス的なアプローチを目指したコードの分離、業務ロジックの分離、つまり記載の分散化と、未だ強力なRDBトランザクションによる整合性の確保を、同時に実現するように、コードの構造を設計し実装していきます。
実装上の課題 / 問題の背景
実際のところ、DB接続とトランザクションを単に共有するだけなら、とても簡単です。
“Using external DbTransactions” の記載どおり、予め用意したDB接続とトランザクションを FF Core の DbContext に渡せば、EF Core 側はこれまで通り動作します。そのDB接続とトランザクションを Dapper のような ORM で利用すれば良いだけ、です。
我々は、EF Core とDapper のようなORMの良いとこ取りをしたいだけ、ですから、このシンプルな方法で適材適所、使い分けることで、何か問題が起こるのでしょうか。
我々は、この方法には以下の課題があると認識します。
- 実装が業務処理の実装者ごとに異なる実装のスタイルの一貫性が許容範囲を越えて失われ、視認性が落ちた結果、保守性が悪化する
- DB 操作がコードの各部に分散して存在することを許容するため、統一的なDBトランザクションの適用を難しくする
- 期待するDB トランザクションの範囲と条件が実装ごとに異なることを容易に許容する結果、業務システム単位でのロールバックを困難にする
また、ASP NET Core が標準環境で提供している DbContext DI からDB接続とトランザクションを引き出して利用することも、実装の手段としては選択肢にはなります。しかし、以下の課題を解決しないと考えています。
- 誰がどのようなトランザクションをどこで指定するか、そしてロールバックやコミットを発行するか、予測が不可能
- 同一の実装がコードの各所に点在することになり、結果としてグローバルに共有されるコード断片を再生産する
- ASP NET Identity の DbContext を共有する設計が可能で、この場合に、意図しない動作に波及する可能性がある(期待されるテストケースが複雑化し、迅速な導入が難しくなる)
これらの課題を、規模にあわせたベターなバランスで解決することは出来るのでしょうか、という問は、今回の実装のバックグラウンドの一つです。
Abstract for IT Architect or.
ソフトウェア開発では、迅速に安全に安価にシステムを開発するための手法を常に研究し、改良が図られ続けています。ソフトウェア開発は未だ、特に規模や品質の担保において、ケース・バイ・ケースによる最適化を必要とすることは避けられませんし、グローバルなグラウドベンダーによるソフトウェアエンジニアリングの最適化・効率化のアプローチが、大規模なソフトウェアスタックを指向する結果、小規模システムでは適切に機能しないことが起こりえます。
利用者の規模が 1万人、100万人、1億人のシステムはそれぞれに、単体のエンティティを参照するだけの処理、複合的なエンティティを参照する処理、それらを更新する必要がある処理、それら要求の割合に応じて、期待されるコストパフォーマンスを発揮するベターな設計のソフトウェアの複雑さは、変わります。
一般的に、大規模になればなるほど、ソフトウェアの複雑さは増す傾向にあります。しかし、ソフトウェアの複雑性は、規模だけに留まりません。操作対象のエンティティが単一であるよりも複数である場合のほうが、そして、参照処理よりも更新処理のケースで一般的に複雑になります。さらに、業務処理が多数の条件を参照すればするほど、複雑さは増します。
この事実は、グローバルレベルでの大規模な単純な参照処理と、単一企業内の業務ドメインや利用環境に閉じるものの人智に挑戦するような複雑な業務処理を実現するソフトウェアが、全く別の要因や影響を保持しながら、同程度のソフトウェア規模や同程度の技術的困難さなどの複雑性で、肩を並べる可能性を否定しないばかりか、積極的に肯定します。
現実に存在する業務処理には、以下の傾向があるように思います。
- 似た処理のバリエーションが多数存在するが、それぞれが微妙に違う
- エンティティの責務の範囲は、そのエンティティが影響を及ぼす業務処理に対して横断的であり、責務の範囲を限定することが困難
- 同様に、業務責務の範囲は、一貫性を担保したいトランザクションの範囲に対して部分的であり、また、何度も参照される可能性がある
- 結果として、業務処理そのものが階層的ではなくメッシュになり、コードの構造に対して、横断的かつ縦断的になる
イノセントなOOPが説明する抽象化と、現実に存在する人間が整理した業務の仕様とその抽象化、そしてコードの構造が期待する抽象化の構造は、それぞれが得意とする抽象的概念のカバー領域は、未だに統一はされていませんし、今後にそれを期待するのも難しいでしょう。
我々は、その差分を補うため、OOPアプローチを拡張し、ジェネリクス、属性プログラミング、依存性注入、関数型アプローチを導入し、関数型・圏論界隈と距離を縮め、様々なアプローチで、生産性が高く、ロバストで、変更に強いコードの構造を模索し続けています。
今回の実装では、それらのアプローチが目指している抽象化の役割を活用し、今よりかはベターで実践が可能で、現実的な工数で実現が可能な解法の一つを模索するという視点を中心に据えています。
結果として今回の実装の挑戦は、メッシュ化する業務手続き処理の構造と、プログラムコンポーネント間での参照ツリーによる実装限界と複雑さの増大を越えていくためのDI化の流れのなかで、RDBのトランザクションを最大限に活用したDIの使い方の一例を提供することが出来るはずだ、と考えています。