しばやん雑記

ASP.NET Core とメイドさんが大好きなフリーランスのプログラマーのブログ

Task ベースの初期化が必要なクラスを .NET Core の DI で利用する

.NET Core / ASP.NET Core で追加された Dependency Injection は Task ベースの Factory は用意されておらず、ドキュメントではサービスの解決は同期的に行うよう推奨されています。

とはいえ、Task ベースでの初期化が必要な場面がちょいちょいあるので、何とかする必要があります。

async/await and Task based service resolution is not supported. C# does not support asynchronous constructors; therefore, the recommended pattern is to use asynchronous methods after synchronously resolving the service.

Dependency injection in ASP.NET Core | Microsoft Docs

例えば Managed Identity のように Access Token を取得する際にネットワークアクセスが必要な場合や、他にも SqlConnection など準備が必要なクラスを扱う場合に、同期処理にするのは避けたいです。

Azure Functions v2 で書いていたアプリを DI に移行した際に、いくつかパターンを利用しました。

今回は初期化を遅らせて、必要になるタイミング(= await が使えるメソッド)で実行する方法と、Factory を DI に追加して必要な時に Factory 経由でインスタンスを作成する方法の 2 つを使いました。

.NET Core 向けにクライアントライブラリを設計する場合に、今後は DI で使うことを考えていく必要がありそうです。IoC というより、生存期間の管理としての使い方がかなり便利です。

Managed Identity + Azure SDK (AutoRest) の例

既に何回も使っている Managed Identity と Azure SDK の組み合わせでは、SDK 側が ITokenProvider を受け取るようになっているので、そこで認証に関する処理を Task ベースで書けます。

サンプルだと Access Token を取得してから各 Management クライアントを作成するケースが多いですが、以下のように ITokenProvider を実装したクラスを用意しておくと非常に便利です。

internal class AppAuthenticationTokenProvider : ITokenProvider
{
    private readonly AzureServiceTokenProvider _tokenProvider = new AzureServiceTokenProvider();

    public async Task<AuthenticationHeaderValue> GetAuthenticationHeaderAsync(CancellationToken cancellationToken)
    {
        // Managed Identity を使って実行時に Access Token を取得
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

        return new AuthenticationHeaderValue("Bearer", accessToken);
    }
}

上の例で使っている ITokenProvider は AutoRest 向けに用意されているものなので、Azure SDK 以外のシチュエーションでも同様に使えるようになっています。

実際に Azure Function の DI で使う場合には、以下のように Startup クラスで DI に追加します。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // Key Vault 向けに用意されている Callback を指定する
        builder.Services.AddSingleton(provider => new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(new AzureServiceTokenProvider().KeyVaultTokenCallback)));

        // 作成した TokenProvider を渡して初期化する
        builder.Services.AddSingleton(provider => new DnsManagementClient(new TokenCredentials(new AppAuthenticationTokenProvider()))
        {
            SubscriptionId = "<subscription id>"
        });
    }
}

Key Vault は特別扱いされているので、特にクラスを用意する必要無く使えるので便利です。

使う側はこれまで通りで、コンストラクタインジェクションでインスタンスを受け取るだけです。

public class Function1
{
    public Function1(DnsManagementClient dnsManagementClient)
    {
        _dnsManagementClient = dnsManagementClient;
    }

    private readonly DnsManagementClient _dnsManagementClient;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // REST API の呼び出し直前に TokenProvider が実行される (async / await が使える)
        var zones = await _dnsManagementClient.Zones.ListAsync();

        return new OkResult();
    }
}

必要になるまで Access Token の取得は行われないのと、Access Token は AzureServiceTokenProvider が適切にキャッシュしてくれるのでシンプルに利用できます。

同じような処理を HttpClient で実現する場合は DelegatingHandler を使うと手っ取り早いです。

public class DemoHttpHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        await ProcessRequestAsync(request, cancellationToken);
        
        return await base.SendAsync(request, cancellationToken);
    }

    private readonly AzureServiceTokenProvider _tokenProvider = new AzureServiceTokenProvider();

    private async Task ProcessRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var accessToken = await _tokenProvider.GetAccessTokenAsync("https://management.azure.com/", cancellationToken: cancellationToken);

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    }
}

この例では Authorization ヘッダーを弄るだけですが、SendAsync では HttpRequest を自由に触れるのでリクエストのカスタマイズが簡単です。

実装した Handler を HttpClientFactory と組み合わせて使っても良いですね。

Factory を作成して DI に追加する例

Azure SDK (AutoRest) ではコンストラクタで初期化用のインターフェースが用意されていたので簡単に対応出来ましたが、用意されていない場合は Factory を作成するのが簡単です。

SqlConnection の場合は利用前に OpenAsync を実行して接続を開いておく必要がありますが、そういったケースにも Factory で対応出来ます。

public interface IAsyncDemoFactory
{
    Task<DemoClient> CreateClientAsync();
}

// 呼び出しの度に新しいインスタンスが必要な場合
public class AsyncDemoFactory : IAsyncDemoFactory
{
    public async Task<DemoClient> CreateClientAsync()
    {
        var demoClient = new DemoClient();

        // 初期化を async / await で行う必要があるクラス
        await demoClient.InitializeAsync();

        return demoClient;
    }
}

// 全体でインスタンスは 1 つだけ必要な場合
public class SingletonAsyncDemoFactory : IAsyncDemoFactory
{
    // AsyncLazy<T> (https://devblogs.microsoft.com/pfxteam/asynclazyt/)
    private readonly AsyncLazy<DemoClient> _initializer = new AsyncLazy<DemoClient>(async () =>
    {
        var demoClient = new DemoClient();

        await demoClient.InitializeAsync();

        return demoClient;
    });

    public async Task<DemoClient> CreateClientAsync()
    {
        return await _initializer;
    }
}

Factory は実際にインスタンスを作成するメソッドを提供していて、内部で初期化するメソッドを呼び出します。Factory 自体は DI で解決されるので、IOptions<T> や他のインスタンスを参照出来ます。

まだ CoreFX で提供されていないですが、ここで AsyncLazy<T> を使えば Singleton にも出来ます。

Factory は Singleton として DI に追加すれば良いので、特に難しいことはありません。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        // DI に Factory を追加
        builder.Services.AddSingleton<IAsyncDemoFactory, AsyncDemoFactory>();
    }
}

使う側ではコンストラクタで Factory を受け取り、実際に使う直前に CreateClientAsync を呼び出して新しくインスタンスを作成、もしくは既に作成されたものを取得します。

この辺りは HttpClienFactory と同じ考え方なので、既に使っている場合は理解しやすいはずです。

public class Function1
{
    public Function1(IAsyncDemoFactory asyncDemoFactory)
    {
        _asyncDemoFactory = asyncDemoFactory;
    }

    private readonly IAsyncDemoFactory _asyncDemoFactory;

    [FunctionName("Function1")]
    public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req)
    {
        // await を使ってクライアントを作成する
        var demoClient = await _asyncDemoFactory.CreateClientAsync();

        var message = await demoClient.HelloAsync();

        return new OkObjectResult(message);
    }
}

インスタンスの作成を利用直前まで遅延させることで、例外が投げられた時のハンドリングが簡単になるというメリットもあります。DI に用意された Factory を使った場合は、コンストラクタへのインジェクション時に例外が投げられるので、どうしようもなくなります。

Factory を用意せずに Lazy<T> を DI に入れる方法もありますが、保守性が下がるので止めた方が良いです。

.NET Core / ASP.NET Core の DI が async / await に対応することは直近ではまず無いはずなので、クライアントの設計と Factory で対応していくことになるかと思います。