🥕

[ASP.NET Core] DI で非同期の初期化処理を行う

2021/04/15に公開

何らかの初期化処理がasyncになってしまうケースの対策を考えます。

なお、以下ガイドラインにもあるように、asyncは本来対応していないので原則持ち込まないのが望ましいそうです。どうしても何らかトリッキーになります。
https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection-guidelines

Recommendations
async/await and Task based service resolution isn't supported. Because C# doesn't support asynchronous constructors, use asynchronous methods after synchronously resolving the service.

AddSingletonの用例

以下は同期的処理で何かデータを準備して、ASP.NET Coreのコントローラに送り込む例です。これを起点に考えます。

public interface IMyData
{
    public string Value { get; }
}

public class MyData : IMyData
{
    public string Value { get; }
    
    public MyData()
    {
        Value = File.ReadAllText("foo.txt");
    }
}
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IMyData, MyData>();
    }
}
[ApiController]
public class MyController : ControllerBase
{
    public MyController(IMyData myData, ILogger<MyController> logger)
    {
        logger.LogInformation(myData.Value);
    }
}

初期化処理が非同期になってしまう場合

上記シングルトンサービス(MyData)にasyncが持ち込まれてしまうケースを考えます。下記はだいぶ例が悪いですがasyncになってしまった例を示しています。

.Result とか .GetAwaiter().GetResult() 等で逃げる手もありますが、それはやりたくないという前提です。

public interface IMyData
{
    public Task InitializeAsync();
    public string Value { get; }
}

public class MyData : IMyData
{
    private string? value;
    public string Value => value ?? throw new InvalidOperationException("Not initialized!");
    
    public MyData()
    {        
    }
    
    public async Task InitializeAsync()
    {
        value = await File.ReadAllTextAsync("foo.txt");
    }
}

非同期処理はMainで

調べた中で一番簡単だった方法を示します。

Startup.csを見渡す限りはasyncを書く場所が無さそうに見えますが、時々存在を忘れがちのProgram.cs (Mainメソッド) のことを思い出します。C# 7.1からMainをasyncにできるようになっているのを活用できます。いつもCreateHostBuilder(args).Build().Run();とイディオム的に書いているのを引き剥がして、間にasync初期化を挟みます。

参考: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-5.0#call-services-from-main

public class Program
{
    public static async Task Main(string[] args)
    {
        var host = CreateHostBuilder(args).Build();

        using var serviceScope = host.Services.CreateScope();
	var service = serviceScope.ServiceProvider.GetRequiredService<IMyData?>();
	await service.InitializeAsync();
        
	await host.RunAsync();
    }
    
    public static IHostBuilder CreateHostBuilder(string[] args) => /*省略*/ ;
}

これで一件落着。

テストで要注意

ASP.NET Coreの結合テストに便利なのが WebApplicationFactory です。
https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-5.0

実際にコントローラのアクションメソッドにHTTPリクエストを投げるようなテストを簡単に作成できます。xUnit.netの例です。

public class IntegrationTest : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> factory;

    public IntegrationTest(WebApplicationFactory<Startup> factory)
    {
        this.factory = factory;
    }

    [Fact]
    public async Task Get()
    {
        var client = factory.CreateClient();

        var response = await client.GetAsync("/hello");

        response.EnsureSuccessStatusCode();
        Assert.Equal("Hello world!", await response.Content.ReadAsStringAsync());
    }
}

しかしこのテストの実行からは IMyData.InitializeAsync の呼び出し処理は通らないため、意図した結果になりません。本体プロジェクトのMainメソッドに突っ込んでいたわけで、テストからだと通らないのは納得です。

今のところきれいな解がわからないのですが、テストクラスにてMain同様の処理を書いてあげれば回避できます。

public class IntegrationTest : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> factory;

    public IntegrationTest(WebApplicationFactory<Startup> factory)
    {
        this.factory = factory;
	
        // InitializeAsyncを呼んでおく
        var myData = (IMyData)factory.Services.GetService(typeof(IMyData));
        myData.InitializeAsync().Wait();
    }
    
    // 以下略
}    

結局ここはasyncを同期処理で潰しているではないかという感じですが、テストなのでまあいいかという妥協ですね...。https://github.com/pengweiqhca/Xunit.DependencyInjection を使用すると初期化部分は(async以外)きれいにまとめることはできます。

トリッキーさが増しますが、コンストラクタではなくasyncなテストメソッドを起点に初期化を走らせる解決法もあるかもしれません。

使いどころ

例に挙げた File.ReadAllTextAsync であればもちろんこんな方法を取るまでもなく、Asyncを取ってFile.ReadAllTextを使えば解決です。

個人的async出現シーンの代表格HttpClientですが、.NET 5から同期処理版のSendができました。従って今後は同様にasync回避で行けます。https://docs.microsoft.com/ja-jp/dotnet/api/system.net.http.httpclient.send?view=net-5.0

AWSSDK.NETは、.NET CoreからはasyncなAPIしか使えなくなってしまいました。一例としてS3のGetObjectは.NET Frameworkでしか使えず、GetObjectAsyncのみ、等です。初期化時にS3にアクセスしたいといった設計ですと何らかasyncと付き合う必要があります。
https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/S3/MIS3GetObjectStringString.html

Note:
For .NET Core this operation is only available in asynchronous form. Please refer to GetObjectAsync.

参考文献

https://thomaslevesque.com/2018/09/25/asynchronous-initialization-in-asp-net-core-revisited/
https://stackoverflow.com/questions/56077346/asp-net-core-call-async-init-on-singleton-service
https://github.com/thomaslevesque/Extensions.Hosting.AsyncInitialization

Discussion