3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[C#]DIコンテナのスコープ範囲を制御する

Last updated at Posted at 2023-09-28

概要

  • ASP.NET Coreのコントローラから呼び出しているScopedサービスのクラスをバッチ処理からも呼び出そうとしたらエラーになった
  • 原因はDIコンテナのスコープ範囲(ASP.NET Coreではリクエスト単位)がバッチ処理だと違ってくる為だった
  • ASP.NET Coreと同様に、処理の呼び出し毎にDIコンテナのスコープを生成するようにしたらうまくいった

内容

次のようなASP.NET Coreのコントローラがあり、その中でMyLogicDoWorkAsyncメソッドを呼び出している。
MyLogicには様々なScopedサービスが注入されており、DIコンテナを利用してコントローラに注入される。

ASP.NET Coreではサービスのスコープ範囲は「リクエスト単位」である為、DoWorkAsyncの呼び出しも基本、1回毎に異なるスコープで呼び出されることになる。

MyController.cs
public class MyController
{
	private MyLogic _logic;

	public MyController( MyLogic logic )
	{
		_logic = logic;
	}
	
	public async Task<IActionResult> DoWork(string id)
	{
		await _logic.DoWorkAsync(id);
	}
}
MyLogic.cs
public class MyLogic 
{
	public MyLogic( /* いろんなScopedサービスをDIしている */ ) 
	{
		省略
	}

	public async Task DoWork(string id){
		省略
		(トランザクションスコープを生成して完了している)
	}
}

このMyLogicを、バッチ処理プロセス(exe)から呼び出したい、ということになった。
こんなこともあろうかと、プログラマーの方に「将来的にバッチ処理から呼び出すことがあるかもしれないので、MyLogic内ではHttpContext等、HTTPリクエストやセッション状態などに依存した処理を書かないでください」というお願いをしておいたのである。

MyLogic自身もたくさんのサービスを注入しているし、ここはやはりDIコンテナが欲しいということで、バッチ処理プロセスは 汎用ホストを使って楽に作る ことにした。
(汎用ホストは最初からDIコンテナ機能を持っている)

この時、MyLogicやその依存オブジェクトに注入されているオブジェクトのDIコンテナへの登録を、次のようにした。

Program.cs
    // データベース設定
    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についてループ処理するものであり、この処理はトランザクションスコープのタイムアウトを起こして異常終了してしまった。

MyBatch.cs
	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コンテナに登録するのも良くない。
MyDbContextMyLogicおよびその依存サービス内で「Scoped」であることが期待されている、つまりスコープの期間内で同一のインスタンスであることが期待されているオブジェクトであり、それらが注入の都度再生成されてしまったら、その方がよほどトラブルの要因になるだろう。

それでは、MyLogicが期待する「スコープの期間」とは何か?

「DoWorkAsyncを呼び出す単位でスコープが作られるのが、元々期待されていたものだ」

ということで、呼び出し側で次のように、DIコンテナのスコープを都度変更するようにした。ASP.NET Coreでリクエスト毎にスコープが作られるのを再現したような形だ。

MyLogic を直接注入するのではなく、IServiceScopeFactoryを注入し、DIのスコープ制御を行う。

MyBatch.cs
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を連続で呼び出せないのがおかしいだろう」という意見ももちろんあると思うが、私が作ったものではない為、どうかご勘弁頂きたい。

何らかの参考になれば幸いである。

参考

3
5
1

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
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?