しばやん雑記

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

Azure Functions v2 の Dependency Injection を使って設定値を上手く扱いたい

Build 2019 に合わせて Azure Functions v2 で Dependency Injection が正式にサポートされましたね。これまでも static を外して WebJobsStartup を書けば使えていましたが、DI 向けに整理されました。

ドキュメントがあるのでそっちを読んでおいてください。基本は ASP.NET Core と同じです。

そして横浜さんが日本語で書いてくれているので、読んでおけば理解できるはずです。

DI にサービスを追加する分には特に違和感なく触れるので問題ないです。特に HttpClient は扱いに気を付けないと死ぬので、DI に Singleton として追加したり HttpClientFactory を使うことで解消します。

個人的には Azure Functions v2 の最大の弱点としては、Configuration を触る公式な手段が失われたことだと考えています。これまでは ConfigurationBuilder を使って独自に解決していましたが、DI を使うと上手く解決できるはずなので、いろんなパターンで試しました。

アプリケーション設定を扱う

ここで指すのは Azure Portal から設定したり、local.settings.json に書いておく設定となります。ASP.NET Core だと Options パターンを使って解決したりします。

共通する部分としてクラスと設定を予め載せておきます。ちなみに local.settings.jsonValues 以下にはオブジェクトを書くことが出来ないので、セパレータを使ってネストを表現します。

public class SampleOptions
{
    public string Value { get; set; }
}
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "Sample:Value": "buchizo"
  }
}

これ自体は特に意味のない設定ですが、Azure Functions v2 では環境変数から読み込むか、先ほど紹介したように ConfigurationBuilder を使って独自に管理する必要がありました。

しかし、DI に対応した今ではホストが使っている IConfiguration を読めるので、簡単なコードで実現出来るようになります。上の設定を扱う方法として 2 つ試したので、それぞれ紹介します。

IConfiguration を直接受け取る

単純な方法としては IConfiguration 自体をコンストラクタで受け取ってしまうことです。以下のように書けば、用意したクラスに設定値をバインド出来ます。

public class Function1
{
    public Function1(IConfiguration configuration)
    {
        _sampleOptions = configuration.GetSection("Sample").Get<SampleOptions>();
    }

    private readonly SampleOptions _sampleOptions;

    [FunctionName("Function1")]
    public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req, ILogger log)
    {
        return new OkObjectResult($"Hello, {_sampleOptions.Value}");
    }
}

簡単な代わりに、コンストラクタでの処理がちょっとイマイチです。出来れば設定値をバインドした状態で値を受け取りたいところです。

Startup で Options として追加する

もう一つは Options パターンを使う方法です。Core MVC と同じ書き方ですが、Configure を呼び出す際には事前に IConfiguration が必要となるので、一旦 IServiceProvider を作成して取得してしまいます。

IServiceProvider を作成してしまえば、後は GetRequiredService を使ってインスタンスが得られます。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        var serviceProvider = builder.Services.BuildServiceProvider();

        var configuration = serviceProvider.GetRequiredService<IConfiguration>();

        builder.Services.Configure<SampleOptions>(configuration.GetSection("Sample"));
    }
}

メソッドの引数として IConfiguration を受け取れれば楽なんですが、この辺りは今後に期待という感じがします。おそらく正式なサンプルか実装が公開されると思います。

Configure を使って設定した値は、以下のようなコードで受け取れます。Core MVC と同じです。

public class Function1
{
    public Function1(IOptions<SampleOptions> sampleOptions)
    {
        _sampleOptions = sampleOptions.Value;
    }

    private readonly SampleOptions _sampleOptions;

    [FunctionName("Function1")]
    public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, "get")] HttpRequest req, ILogger log)
    {
        return new OkObjectResult($"Hello, {_sampleOptions.Value}");
    }
}

利用するセクション名は Startup 側に移譲されたので、コンストラクタは単純になりました。名前付き Options とかも使えるはずなので、用途に合わせて使い分けたいところです。

接続文字列を扱う

v1 から v2 への移行で大体問題になるのが接続文字列周りだと思います。環境変数から取ればよいと言われればそれまでなんですが、DI を使って綺麗に解決したいところです。

サンプルとして以下のような local.settings.json を用意しました。接続文字列がメインです。

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet"
  },
  "ConnectionStrings": {
    "DefaultSqlConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=TestDB;Integrated Security=True;Connect Timeout=30",
    "DefaultStorageConnection": "DefaultEndpointsProtocol=https;AccountName=***;AccountKey=***;EndpointSuffix=core.windows.net",
    "DefaultCosmosConnection": "AccountEndpoint=https://****.documents.azure.com:443/;AccountKey=***;"
  }
}

とりあえず代表的な 3 つのパターンでの使い方を試したので紹介しておきます。

Entity Framework Core の場合

よく使われるであろう Entity Framework Core の場合ですが、ASP.NET Core で使ってるのと同じように AddDbContext を使って利用する DbContext を追加してあげます。

Core MVC の場合は IConfiguration を受け取り済みので簡単に取得できましたが、Azure Functions の場合は IServiceProvider を受け取れるオーバーロードメソッドを使えば良いです。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContext<MyDbContext>((provider, options) =>
        {
            var configuration = provider.GetRequiredService<IConfiguration>();

            options.UseSqlServer(configuration.GetConnectionString("DefaultSqlConnection"));
        });
    }
}

これで DbContext をコンストラクタで受け取れるようになるので、SQL Database などへのアクセスが行えます。DbContext は Singleton で持つことが出来ないので、DI を使った方が安全です。

Azure Storage の場合

Blob や Queue へのアクセスを行うためのクライアントは Singleton で扱っても良いらしいので、接続文字列を取得した後に DI へ追加してあげれば解決します。

この時 AddSingletonIServiceProvider を受け取れるオーバーロードを使います。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddSingleton(provider =>
        {
            var configuration = provider.GetRequiredService<IConfiguration>();

            var storageAccount = CloudStorageAccount.Parse(configuration.GetConnectionString("DefaultStorageConnection"));

            return storageAccount.CreateCloudBlobClient();
        });
    }
}

これで CloudBlobClient を Singleton で扱えるようになります。非常に簡単ですね。

Cosmos DB の場合

最後は Cosmos DB ですが、このクライアントは Singleton で持たないとパフォーマンスが悪くなってしまうので、DI を使うのがシンプルで良いです。

考え方は Storage と同じなので説明は省略します。Cosmos DB の v3 クライアントを使っているので、Builder パターンでインスタンスを作るようになっています。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddSingleton(provider =>
        {
            var configuration = provider.GetRequiredService<IConfiguration>();

            return new CosmosClientBuilder(configuration.GetConnectionString("DefaultCosmosConnection"))
                .UseConnectionModeDirect()
                .Build();
        });
    }
}

ちなみに Cosmos DB の v3 クライアントは今月中に GA するらしいです。パフォーマンスも改善されていて、インターフェースが分かりやすくなっているので今から慣れておいて損はないです。

ここで紹介した以外のクライアントも IServiceProvider 経由で IConfiguration を取得する部分までは同じなので、特に問題なく対応できるでしょう。