概要
- ASP.NET Coreのコントローラから呼び出しているScopedサービスのクラスをバッチ処理からも呼び出そうとしたらエラーになった
- 原因はDIコンテナのスコープ範囲(ASP.NET Coreではリクエスト単位)がバッチ処理だと違ってくる為だった
- ASP.NET Coreと同様に、処理の呼び出し毎にDIコンテナのスコープを生成するようにしたらうまくいった
内容
次のようなASP.NET Coreのコントローラがあり、その中でMyLogic
のDoWorkAsync
メソッドを呼び出している。
MyLogic
には様々なScopedサービスが注入されており、DIコンテナを利用してコントローラに注入される。
ASP.NET Coreではサービスのスコープ範囲は「リクエスト単位」である為、DoWorkAsync
の呼び出しも基本、1回毎に異なるスコープで呼び出されることになる。
public class MyController
{
private MyLogic _logic;
public MyController( MyLogic logic )
{
_logic = logic;
}
public async Task<IActionResult> DoWork(string id)
{
await _logic.DoWorkAsync(id);
}
}
public class MyLogic
{
public MyLogic( /* いろんなScopedサービスをDIしている */ )
{
省略
}
public async Task DoWork(string id){
省略
(トランザクションスコープを生成して完了している)
}
}
このMyLogic
を、バッチ処理プロセス(exe)から呼び出したい、ということになった。
こんなこともあろうかと、プログラマーの方に「将来的にバッチ処理から呼び出すことがあるかもしれないので、MyLogic
内ではHttpContext等、HTTPリクエストやセッション状態などに依存した処理を書かないでください」というお願いをしておいたのである。
MyLogic
自身もたくさんのサービスを注入しているし、ここはやはりDIコンテナが欲しいということで、バッチ処理プロセスは 汎用ホストを使って楽に作る ことにした。
(汎用ホストは最初からDIコンテナ機能を持っている)
この時、MyLogic
やその依存オブジェクトに注入されているオブジェクトのDIコンテナへの登録を、次のようにした。
// データベース設定
services.AddDbContext<MyDbContext>((provider, options) =>
{
string connectionString = configuration.GetConnectionString("MyDb");
options.UseNpgsql(connectionString);
});
// サービス1
services.AddScoped<IMyService1>, MyService1>();
// サービス2
services.AddScoped<IMyService2>, MyService2>();
// MyLogic
services.AddScoped<MyLogic>();
// バッチ処理本体
services.AddScoped<MyBatch>();
そして、このMyLogicをDIコンテナから取り出し、バッチ処理で実行した。
class MyBatch
{
private MyLogic _logic;
public MyBatch(MyLogic logic)
{
_logic = logic;
}
public async Task ExecuteAsync()
{
await _logic.DoWorkAsync("1234");
}
}
// 汎用ホストの初期化処理は省略
var batch = host.Services.GetRequiredService<MyBatch>();
await batch.ExecuteAsync();
これは問題なく動作した。C#は本当に素晴らしい。
しかし実際にはバッチ処理は、複数のidについてループ処理するものであり、この処理はトランザクションスコープのタイムアウトを起こして異常終了してしまった。
public async Task ExecuteAsync()
{
// 複数のリストについて全て実行
string[] idlist = { "1234", "2345", "3456", "4567", "5678", "6789" };
foreach( var id in idlist)
{
await _logic.DoWorkAsync(id); // 何回目かのループでトランザクションスコープタイムアウト
}
}
ここには詳しく書けないが、ソースコードを見る限り、トランザクションスコープはDoWorkAsync
の中で閉じており、await
を使った処理の為にTransactionScopeAsyncFlowOption.Enabled
も付与されている。問題は無いように見える。
どうにもはっきりした原因が分からないのだが、この処理ではトランザクションスコープ内でEF CoreとDapperを併用しながらSaveChangesInterceptor
を用いるなど、複数の事象が絡んでいる為か、トランザクションのステータスの整合性が合わなくなっているようだ。
エラーとしては、以下のようなものがSaveChangesInterceptor
の中のMyDbContext
へのクエリ発行時に起きている。
System.Transactions.TransactionException: The operation is not valid for the state of the transaction
at System.Transactions.Transaction.EnlistVolatile(ISinglePhaseNotification singlePhaseNotification, EnlistmentOptions enlistmentOptions)
at Npgsql.NpgsqlConnection.EnlistTransaction(Transaction transaction)
at Npgsql.NpgsqlConnection.<>c__DisplayClass41_0.<<Open>g__OpenAsync|0>d.MoveNext()
何にせよ、単体でDoWorkAsync
を呼び出すのは問題ないのに、複数回連続でループするとおかしくなるというのは、スコープが正しく制御できていないものと思われる。
(ちなみにこのMyLogicやその周辺処理は私が作ったものではない。)
考えてみると、現状、バッチ処理のMyLogic
のインスタンスは1つであり、ループ内で何度もDoWorkAsync
を呼び出している。その為、内部で利用しているMyDbContext
も、一度生成されたものが使いまわされていることになる。
恐らくその為、トランザクションスコープが終わった後もMyDbContext
の中で何らかのトランザクション状態がリセットされずに残り、継続されてしまっている為に、途中でトランザクションがタイムアウトする、という事態になっているように思われる(推測)。
MyDbContext
はループ中も使いまわせば良いと思っていたが、それが問題を起こすのであれば、ループ毎に再生成しなければならない。
しかし、だからといってMyDbContext
をTransientでDIコンテナに登録するのも良くない。
MyDbContext
はMyLogic
およびその依存サービス内で「Scoped」であることが期待されている、つまりスコープの期間内で同一のインスタンスであることが期待されているオブジェクトであり、それらが注入の都度再生成されてしまったら、その方がよほどトラブルの要因になるだろう。
それでは、MyLogic
が期待する「スコープの期間」とは何か?
「DoWorkAsyncを呼び出す単位でスコープが作られるのが、元々期待されていたものだ」
ということで、呼び出し側で次のように、DIコンテナのスコープを都度変更するようにした。ASP.NET Coreでリクエスト毎にスコープが作られるのを再現したような形だ。
MyLogic
を直接注入するのではなく、IServiceScopeFactory
を注入し、DIのスコープ制御を行う。
class MyBatch
{
- private MyLogic _logic;
+ private IServiceScopeFactory _serviceScopeFactory;
- public MyBatch(MyLogic logic)
+ public MyBatch(IServiceScopeFactory serviceScopeFactory)
{
- _logic = logic;
+ _serviceScopeFactory = serviceScopeFactory;
}
public async Task ExecuteAsync()
{
// 複数のリストについて全て実行
string[] idlist = { "1234", "2345", "3456", "4567", "5678", "6789" };
foreach( var id in idlist)
{
+ // 1回のDoWorkAsync毎にスコープを作る
+ using var serviceScope = _serviceScopeFactory.CreateScope();
+
+ // MyLogicを、今回作ったスコープ内で取得する(MyLogicに注入されるScopedサービスもこのスコープで取得される)
+ var _logic = serviceScope.ServiceProvider.GetRequiredService<MyLogic>();
await _logic.DoWorkAsync(id); // 正常に動作するようになった
}
}
}
1回のDoWorkAsync毎にスコープを作ってMyLogicをDIコンテナから取得することで、MyLogicに注入されている各Scopedサービスも、今回作ったスコープで生成されるようになった。
そして、このループ処理もエラーを起こさず実行されるようになった。
「そもそもDoWorkAsyncを連続で呼び出せないのがおかしいだろう」という意見ももちろんあると思うが、私が作ったものではない為、どうかご勘弁頂きたい。
何らかの参考になれば幸いである。
参考