.NET Core の設定情報の仕組みをしっかり理解したい方向け基本のキ【その2】

f:id:ecb_mkobayashi:20190924113406p:plain

こんにちは、アーキテクトの小林です。

前回の記事では、さまざまなプロバイダから提供される情報がフラットな KeyValue データ構造に変換され、後勝ち方式でカスケーディングされていることをご紹介しました。

今回の記事では、フラットな設定情報から階層構造を持ったクラスのプロパティに値をバインドする方法と、設定ファイルが更新されたときに自動的に設定情報を再読み込みさせる方法を紹介します。

まだ前回の記事をお読みでない方は以下をお読みください。 blog.ecbeing.tech

最初から用意されている appsettings.json 設定をバインドする

さて、ASP.NET Core Web アプリケーションを新たに作成すると、appsettings.json に最初から以下の設定が用意されています。

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

この中の Logging の設定情報は、Program.cs の CreateDefaultBuilder メソッドの中で Logger の設定として使われています。

WebHost.cs 191行目付近

.ConfigureLogging((hostingContext, logging) =>
{
    logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
    logging.AddConsole();
    logging.AddDebug();
    logging.AddEventSourceLogger();
}).

この設定によって、Logger の出力条件が調整できるようになっているわけですが、この設定にアプリケーションからアクセスしたいというシチュエーションを考えてみましょう。
キーも値もフラットに展開された単なる文字列ですので、特に扱いにくいことはありません。

Key Value
Logging:LogLevel:Default Information
Logging:LogLevel:Microsoft Warning
Logging:LogLevel:Microsoft.Hosting.Lifetime Information

設定キーを ConfigKeys というクラスの定数で定義すれば、このようにアクセスできます。

Startup.cs

public static class ConfigKeys
{
    public const string LoggingLogLevelDefault = "Logging:LogLevel:Default";
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
        System.Diagnostics.Debug
            .WriteLine(Configuration[ConfigKeys.LoggingLogLevelDefault]);
    }
    ... 省略 ...
}

確かにこれでも目的は達成できます。でも、LogLevel という値は .NET では enum で宣言されていました。
これが文字列になってしまっていますので、もっとスマートに対処したいところです。次のように値を受け取るクラスを宣言して、便利なバインダーをつかって復元しましょう。

Startup.cs

public class LoggingOptions
{
    public LogLevel LogLevel { get; set; }
}

public class LogLevel
{
    public Microsoft.Extensions.Logging.LogLevel Default { get; set; }
    public Microsoft.Extensions.Logging.LogLevel Microsoft { get; set; }
    // ドットを含むキー名を使ってしまうとバインドできません!
    // public Microsoft.Extensions.Logging.LogLevel MicrosoftHostingLifetime { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
        var logging = new LoggingOptions();    // ★★ ココで受け側クラスを生成
        Configuration.Bind("Logging", logging);// ★★ ココで受け側クラスにバインド
        System.Diagnostics.Debug.WriteLine(logging.LogLevel.Default);
    }
    ... 省略 ...
}

ここでは、LoggingOptions というクラスに LogLevel プロパティを宣言し、LogLevel というクラスに Default と Microsoft というプロパティを宣言しました。

Logging:LogLevel:Default という:(コロン)で区切られた階層を再現するようなクラスを宣言したわけです。

その後に IConfiguration の Bind メソッド(注:これは拡張メソッドです)を呼び出しています。 このメソッドを呼ぶことによって、内部で自動的に LogLevel クラスのインスタンスを生成したうえで、対応するプロパティの値のバインドも行ってくれるのです。

Microsoft.Extensions.Logging.LogLevel の enum のプロパティの値に対しても自動的にマッピングしてくれました。もし、enum に存在しない文字列を設定に記述した場合に例外が発生しますので設定ミスも防止できます。

注意しなればならない点は、Logging:LogLevel:Microsoft.Hosting.Lifetime という.(ドット)を含んだ設定名です。この名前のプロパティは宣言できないため、バインドさせることができませんでした。

プロパティの値のバインドの流れは、受け側のクラスが備えるプロパティに一致する設定キーがあるかどうかで判断しています。設定キーに存在しないプロパティプロパティに対応しない設定キーは無視されますので「スペルミスでバインドされていなかった」というバグには十分に気を付ける必要があります。(※双方の大文字・小文字は区別されません)

設定情報をアプリケーション全体で使えるようにする

設定情報をアプリケーション全体で使えるようにしたい場合、どんな方法があるのでしょうか?

一般的な方法として以下の4種類の方法があります。それぞれについてメリットとデメリットを考察してみましょう。

  • IConfiguration のまま使う
  • スタティックなシングルトンクラスにする
  • シングルトンとして DI コンテナに登録する
  • オプションパターンで DI コンテナに登録する

IConfiguration のまま使う

IConfiguration は最初から DI コンテナに登録されていますので、すでにどこでも使える状態になっています。
先ほど用意した ConfigKeys も使って HomeController のコンストラクタに入れてみましょう。

HomeController.cs

public class HomeController : Controller
{
    private readonly IConfiguration _configuration; // ★★ココ
    private readonly ILogger<HomeController> _logger;

    public HomeController(ILogger<HomeController> logger, 
                          IConfiguration configuration)  // ★★ココ
    {
        _configuration = configuration;  // ★★ココ
        _logger = logger;
    }

    public IActionResult Index()
    {
        // ★★設定を使う
        System.Diagnostics.Debug
            .WriteLine(_configuration[ConfigKeys.LoggingLogLevelDefault]);
        return View();
    }
    ... 省略 ...
}

メリット

  • 余計なクラスを追加しなくて良い
  • 設定ファイルが更新された場合でも常に最新の設定情報にアクセスできる

デメリット

  • 設定がすべて文字列なので自分でパースする必要がある
  • 設定キーにアクセスするたびに内部で ConfigurationProvider を下から遡るループ処理が回っている

この方法は、バインドを行わないために常に最新の設定情報にアクセスできます。詳細は割愛しますが、ConfigurationProvider には設定情報の変更(ファイル等の変更を監視)をイベントとして通知する仕組みが備わっているので、そのコンテナである IConfiguration には常に最新の設定情報が提供されるようになっています。
ただ、設定プロバイダがたくさん登録されていて、あちこちのプログラムが頻繁に設定にアクセスするような場合には、内部で実行されるループ処理によって確実に性能を落としてしまうことでしょう。 個人レベルで使うようなコンパクトなアプリケーションであればシンプルで良いかもしれません。

スタティックなシングルトンクラスにする

先ほどの LoggingOptions クラスをシングルトンにしてしまえば、どこからでも使える状態にできます。
LoggingOptions.Current にアクセスすれば、ありとあらゆるコードから設定情報にアクセスできるようになります。

Startup.cs

public class LoggingOptions
{
    public static LoggingOptions Current = new LoggingOptions();
    public LogLevel LogLevel { get; set; }
    private LoggingOptions() {}
}

public class LogLevel
{
    public Microsoft.Extensions.Logging.LogLevel Default { get; set; }
    public Microsoft.Extensions.Logging.LogLevel Microsoft { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
        Configuration.Bind("Logging", LoggingOptions.Current); // ★★ココ
    }
    ... 省略 ...
}

メリット

  • 設定情報が文字列ではなく型に変換されるのでパースする手間が省ける
  • 設定情報に高速にアクセスできる

デメリット

  • あちこちにグローバルオブジェクトに依存したクラスがつくられる
  • アプリケーション起動時の設定情報に固定化されるため実行中の設定ファイルの変更が反映されない

この方法は、IConfiguration の情報をシングルトンのインスタンスにバインドしたことで高速にアクセス可能になりました。これと引き換えに、設定ファイルを変更しても値が反映されないというデメリットが出てしまいました。
また、グローバルオブジェクトへの依存によって ASP.NET MVC が DI を組み込んでクラス間の依存関係を疎結合にしようとする作戦を台無しにしてしまっているように感じます。

シングルトンとして DI コンテナに登録する

LoggingOptions をそのままシングルトンにするとグローバルオブジェクトに依存してしまいますが、DI コンテナにシングルトンとして登録すると、グローバルオブジェクトへの依存を回避できます。
Startup クラスの ConfigureServices メソッドで services.AddSingleton としてインスタンスを登録するのがポイントです。

Startup.cs

public class LoggingOptions
{
    public LogLevel LogLevel { get; set; }
}

public class LogLevel
{
    public Microsoft.Extensions.Logging.LogLevel Default { get; set; }
    public Microsoft.Extensions.Logging.LogLevel Microsoft { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // ★★ココから
        var loggingOptions = new LoggingOptions();
        Configuration.Bind("Logging", loggingOptions);
        services.AddSingleton(loggingOptions);
        // ★★ココまで
        services.AddControllersWithViews();
    }
    ... 省略 ...
}

HomeController.cs

public class HomeController : Controller
{
    private readonly LoggingOptions _loggingOptions;  // ★★ココ
    private readonly ILogger<HomeController> _logger;

    public HomeController(ILogger<HomeController> logger,
                          LoggingOptions loggingOptions)  // ★★ココ
    {
        _loggingOptions = loggingOptions;  // ★★ココ
        _logger = logger;
    }

    public IActionResult Index()
    {
        // ★★設定を使う
        System.Diagnostics.Debug
            .WriteLine(_loggingOptions.LogLevel.Default);
        return View();
    }
    ... 省略 ...
}

メリット

  • 設定情報が文字列ではなく型に変換されるのでパースする手間が省ける
  • 設定情報に高速にアクセスできる

デメリット

  • アプリケーション起動時の設定で固定化されるので実行中の設定ファイルの変更が反映されない

この方法は、スタティックなシングルトンクラスを使う方法よりもデメリットを一つ減らすことができました。グローバルオブジェクトではなく、コンストラクタで引き渡したオブジェクトへの依存になりますので、ユニットテストもしやすく依存関係をクリーンに保つことができました。

オプションパターンで DI コンテナに登録する

さて、最後に紹介するのが一番おすすめの方法です。これまでの3つの方法と比べて大きなデメリットがありません。
オプションパターンを利用する場合は services.Configure メソッドを使います。

Startup.cs

public class LoggingOptions
{
    public LogLevel LogLevel { get; set; }
}

public class LogLevel
{
    public Microsoft.Extensions.Logging.LogLevel Default { get; set; }
    public Microsoft.Extensions.Logging.LogLevel Microsoft { get; set; }
}

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // ★★ココ
        services.Configure<LoggingOptions>(Configuration.GetSection("Logging"));
        services.AddControllersWithViews();
    }
    ... 省略 ...
}

services.Configure メソッドで登録したクラスは、そのクラス名で DI コンテナから受け取ることができません。
代わりに IOptions<LoggingOptions>IOptionsMonitor<LoggingOptions>IOptionsSnapshot<LoggingOptions> で受け取ることができます。
以下の例では、IOptionsMonitor を使って受け取っています。

HomeController.cs

public class HomeController : Controller
{
    private readonly IOptionsMonitor<LoggingOptions> _loggingOptions; // ★★ココ
    private readonly ILogger<HomeController> _logger;

    public HomeController(ILogger<HomeController> logger,
                          IOptionsMonitor<LoggingOptions> loggingOptions) // ★★ココ
    {
        _loggingOptions = loggingOptions; // ★★ココ
        _logger = logger;
    }

    public IActionResult Index()
    {
        // ★★設定を使う
        System.Diagnostics.Debug
            .WriteLine(_loggingOptions.CurrentValue.LogLevel.Default);
        return View();
    }
    ... 省略 ...
}

メリット

  • 設定情報が文字列ではなく型に変換されるのでパースする手間が省ける
  • 設定情報に高速にアクセスできる

デメリット

  • IOptionsMonitor.CurrentValue というように設定へのアクセスにワンクッション増える

ここで使用した IOptionsMonitor は、設定ファイルの変更があったときに LoggingOptions のインスタンスを作り直してくれます。
Web アプリケーションを実行中に設定ファイルを書き換えることがある場合にはとても重宝する仕組みです。

ちなみに、ここに挙げたデメリットは ConfigureServices で以下のひと工夫を入れることで解決することができます。
アプリケーションのコントローラークラスやサービスクラスがオプションパターンに依存しなくて済みますので結構おすすめです。

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<LoggingOptions>(Configuration.GetSection("Logging"));
    // ★★ココから
    services.AddTransient<LoggingOptions>(
        x => x.GetService<IOptionsMonitor<LoggingOptions>>().CurrentValue
    );
    // ★★ココまで
    services.AddControllersWithViews();
    }

オプションパターンの三種類のインターフェース

オプションパターンの三種類のインターフェースの挙動について以下の表にまとめました。

オプションパターン 挙動
IOptions インスタンスはシングルトンです。アプリケーション実行中に設定ファイルを変更しても反映されません。
IOptionsMonitor インスタンスはシングルトンですが、アプリケーション実行中に設定ファイルを変更した場合に設定が反映されます
IOptionsSnapshot インスタンスはWEBリクエストスコープです。アプリケーション実行中に設定ファイルを変更したものが反映されますが、リクエスト毎にインスタンスの生成とバインド処理が動きます

オプションパターンの良い点(と言えるかどうかは微妙ですが)は、設定情報を受け取る側のクラスが、インスタンスのライフサイクルを指定することができるという点です。
常にアプリケーション起動時の設定で良いのであれば、IOptions で受け取り、最新の設定が必要であれば IOptionsMonitor で受け取ると良いと思います。
IOptionsSnapshot は毎回バインドするオーバーヘッドがあるので、これを選択する必要がある状況は思い浮かびませんが、リクエスト毎に変化する設定情報がある場合に使うと良いのかもしれません。

まとめ

今回は、フラットな KeyValue データ構造に変換された設定情報を、独自クラスのプロパティにバインドし、設定ファイルが更新されたときに自動的に設定情報を再読み込みさせる方法についてご紹介いたしました。

これまで .NET Framework を扱ってきた人にとって、.NET Core で採用されている設定情報の仕組みをいきなり理解するのはとても難しいと感じますが、前回と今回の記事を読んでいただいたことによって、その仕組みがおおむね理解できたのではと思っています。

次回はこれらの基本を踏まえた応用編として、データベースから設定情報を読み込む独自の ConfigurationProvider を作成して紹介してみたいと思います!


~ecbeingでは、フレームワークのコアまで覗いてメカニズムを理解するような、ディープなエンジニアも大募集中です!~ careers.ecbeing.tech