🔧

[ASP.NET Core Options]設定ファイルの動的切り替え

2022/11/05に公開

C#のASP.NET Coreを使った開発をする事があったので、その際に得られたオプション周りの知見を残しておきます。

ことわり

引き継いだプログラムがASP.NET Coreなので触った感じで書いています。
ASPも詳しくないし、ASP.NETも詳しくないし、ASP.NET Coreも詳しくありません(それぞれどのくらい違うのか知らない)。
なので、ここで書くことは他でも通用するかもしれないし、しないかもしれません。

あとここで書いているコードはコピペではなく写経なので、コンパイルエラーがあるかも。

参考資料

きっと上記の参考資料を熟読すればより詳しい知識が得られると思います。
ここでは実使用の簡単な説明と、オプションを階層化して一部だけ上書きする方法について記載します。

概要

サービスの登録は

  • 常駐クラスはAddSingletonで登録
    • この場合必要なオプションはIOptionsMonitorで受け取る
  • セッションごとに生成/破棄するクラスはAddScopedで登録
    • 必要な時だけ作られるので、無駄なメモリを消費しない
  • オプションはAddOptionsで登録
    • 設定ファイルの変更を反映するタイミングにより受け取り方が違う
      • 起動時の設定を使いたい: IOptionsで受け取り
      • セッションごとに最新の設定を使いたい: IOptionsSnapshotで受け取り
        • ただしAddSingletonで登録したクラスには使えない。(実行時例外発生)
      • 常に最新の設定を使いたい: IOptionsMonitorで受け取り
        • IOptionsSnapshotと違ってこちらは常駐なので、AddSingletonで登録したクラスでも使える
  • 設定ファイル内に継承機能をつけたれば、処理を自作する
    • セッションURLで指定した値を使って、設定ファイルを分岐しつつ、一部のデータは共通で使いたい
    • AddScopedで使う設定ファイルを分岐する処理をラムダ式で登録する
  • サービス登録できるのは型なので、stringの内容を登録したければ、ラッパークラスを作り登録する
  • 設定ファイルはappsettings.json<appsettings.%ASPNETCORE_ENVIRONMENT%.json<secrets.jsonの順で上書きされていきます
    • secrets.jsonIOPtionsSnapshotIOptionMonitorでの動的設定変更が反映されないので注意

サービス登録

各クラスをサービス登録します。これにより依存性の注入DIが使えるようになります。
登録方法はいっぱいあるようですが、主に

  • AddSingleton: 常駐のクラスを登録
  • AddScoped: リクエスト毎に生成と破棄を行うクラスを登録
  • AddOptions: オプションクラスを登録

を使っています。

// メイン エントリーポイント
public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }
    public static IHostBuilder CreateHostBuilder(string[] args)
    {
        // 深くは理解してない
        return Host.CreateDefaultBuilder(args)
                   .ConfigureServices((hostContext, services) =>
                   {
                        services.AddHttpContextAccessor()
                                .AddHttpClient();
                        // 自前クラス登録
                        services.AddScoped<IProjectCode, ProjectCode>()
                        // 自前で定義した拡張メソッドを呼び出す
                        services.ConfigureEmailSenderService(hostContext.Configuration)
                                .ConfigureScopedSelectService(hostContext.Configuration);
                   })
                   .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());
    }
}

// 常駐 Singleton登録のサービス
public static class EmailSenderServiceExtensions
{
    public static IServiceCollection ConfigureEmailSenderService(
        this IServiceCollection services, IConfiguration configuration)
    {
        // オプションの登録
        services.AddOptions<EmailSenderOptions>().Bind(configuration.GetSection(EmailSenderOptions.SectionName));
        // サービスの登録
        services.AddSingleton<IEmailSender, EmailSender>(); // 第1引数のインターフェースが要求されたときに使われる
                                                            // 第2引数は実装(newされるクラス)
    }
}

// リクエスト毎のScoped登録のサービス
public static class ScopedSelectServiceExtensions
{
    public static IServiceCollection ConfigureScopedSelectService(
        this IServiceCollection services, IConfiguration configuration)
    {
        // ここで登録するものはリクエストパラメーターによって処理が分岐する想定
        // リクエストURLに{project}があり、それにより分岐します

        services.AddScoped(IProcessor, Processor)();

        // オプションの登録
        // 各プロジェクトごとに設定を持ちますが、既定の設定としてルートのオプションを使用して部分的に書き換えたい
        services.AddScoped(provider =>
        {
            return ProjectOptions.GetOptions<BTSOptions>(configuration, provider, BTSOptions.SessionName);
        });
        services.AddScoped(provider =>
        {
            return ProjectOptions.GetOptions<PackageOptions>(configuration, provider, PackageOptions.SessionName);
        });
    }
}

各サービス(のコンストラクタ)

APIエンドポイントとなるコントローラーやIServiceCollection servicesに登録した処理の部分です。
この引数の型をもとに対応したインスタンスがサービスから渡されます。

// API エンドポイントグループ
[APIController] // コントローラーとして扱う(書いてないクラスもあるな...)
[Route("api/[Controller])"] // このクラスは`http://yourhost/api/subscription/...`に対応
public class SubscrptionController : ControllerBase
{
    private readonly ILogger<SubscriptionController> m_logger;
    // コンストラクタ
    public SubscriptionController(
        ILogger<SubscriptionController> logger // ロガーはデフォルトでサービスに登録されているものを使う
        )
    {
        m_logger = logger;
    }
    // API エンドポイント
    [HttpGet("{project}")] // Method Get で `/api/subscription/{project}`のリクエスト対応
                           // {project}は任意の文字列を受け取り、パラメーターとして扱う
    public async Task<IActionResult> GetList(
        [FromServices] IProcessor processor, // サービスに登録されているものを取得
        [FromQuery] SubscriptionParameters subscriptionParameters, // リクエストパラメータを取得
        CancellationToken cancellationToken = default // 非同期キャンセル用
        )
    {
        processor.Run(subscriptionParameters.address, subscriptionParameters.size)
        // :
        // なんか処理
        return NotFound();
    }
}

// スコープサービス
public class Processor : IProcessor, IDisposable
{
    private readonly ILogger<Processor> m_logger;
    private readonly string m_projectCode;
    private readonly BTSOptions m_btsOptions;
    private readonly PackageOptions m_packageOptions;
    // コンストラクタ
    public Processor(
        ILogger<Processor> logger,
        IProjectCode projectCode, // リクエストパラメーターの{project}の取得用にstringをラッピングしたクラス
        IOptions<BTSOptions> btsOptions, // プログラム起動時のオプションで取得(動的変更なし)
        IOptionsSnapshot<PackageOptions> packageOptions // リクエスト開始時のオプションで取得
    )
    {
        m_logger = logger;
        m_projectCode = projectCode.Value;
        m_btsOptions = btsOptions.Value;
        m_packageOptions = packageOptions.Value;
    }

    // 処理
    public Task Run(string address, int size)
    {
        // :
    }

    // 後処理
    public void Dispose()
    {
        // :
    }
}

// 常駐サービス
public class EmailSender : IEmailSender, IDisposable
{
    private readonly ILogger<EmailSender> m_logger;
    private readonly IOptionsMonitor<EmailSenderOption> m_options; // IOptionsMonitorのまま保持する
    // コンストラクタ
    public EmailSender(
        ILogger<EmailSender> logger,
        IOptionsMonitor<EmailSenderOption> options, // AddSingletonで登録されたものにはIOptionsSnapshotは使えない
    )
    {
        m_logger = logger;
        m_options = options;
    }
    // 処理
    public Task<bool> SendMailAsync()
    {
        // オプションの実際の値を取得
        // IOptionsMonitorで受け取っているのでこの時点の最新が取得可能
        var opts = m_options.CurrentValue;
        // :
        // なんか処理
        return true;
    }

    // 後処理
    public void Dispose()
    {
        // :
    }
}

APIエンドポイントとなるコントローラーでは引数のアトリビュートに[FromServices]を使うとサービスに登録されているインスタンスが取得できます。
ちなみにに[FromQuery]だとリクエストパラメータから作られたインスタンスが取得できます。

オプションの受け取り方法は3つあって、

  • IOptions: 起動(初回読み込み時?)のオプション設定を受け取ることが可能です。
    • 実行の途中で変わって欲しくないオプションの取得に使います。たとえば、接続先データベースで高速化のため一部キャッシュしてるとか。
  • IOptionsSnapshot: Scopedのオプションインスタンスで、リクエスト開始時の設定を受け取ることが可能です。
    • リクエスト毎に最新のオプション設定を使いたい時に使います。
    • Scopedのインスタンスなので、Singleton登録のサービスと組み合わせはできません(起動時に例外が発生)。
  • IOptionsMonitor: Singletonのオプションインスタンスですが、最新の設定を受け取ることが可能です。(ファイルを監視していて変更があれば反映される)
    • Snapshot代わりに、Singleton登録のサービスと組み合わせてリクエスト毎に最新の設定を反映できます。
    • Scopedのサービスでも使用可能で、長時間のリクエスト処理途中でも最新の設定を受け取ることが可能です。(どのタイミングで反映されるのだろう?)

各オプション

直接IConfigurationでアクセスすれば文字列操作も可能ですが、各オプションをクラスにデシリアライズして使用する方が好みです。
文字列でのアクセスだとタイポが不安だったり、リファクタリングがしづらいので。

// リクエストパラメーターに使用
// 例)/?address=abc@example.com&size=20
public record SubscriptionParameters
{
    [Required] // nullじゃないのを保証(たぶん省略できる?)
    public string address { get; init; } = string.Empty;

    [Required]
    [Range(1, 100)] // 指定できる範囲を制限する
    public int size { get; init; } = 10; // 未指定時は10とする
}

// リクエストURLの{project}をサービスに登録するラッパー
// services.AddScoped(IProjectCode, ProjectCode);
// で登録する。
// インターフェースを継承しているのは、テストでモックを使うためです。
// {
//  var projectCode = new Mock<IProjectCode>();
//  projectCode.Setup(m => m.Value).Returns("ProjectX");
//  var btsOpts = new Mock<IOptions<BTSOptions>>();
//  btsOpts.Setup(m => m.Value).Returns(new BTSOptions{
//    Url = "http://test.localhost/
//  });
//  var pkgOpts = new Mock<IOptionsSnaoshot<PackageOptions>>();
//  pkgOpts.Setup(m => m.Value.Returns(new PackageOptions{
//    Name = "test"
//  });
//  var processor = new Processor(
//    NullLogger<Processor>.Instance,
//    projectCode.Value, btsOpts.Value, pkgOpts.Value)
//  :
// }
public class ProjectCode : IProjectCode
{
    public string Value { get; init; }
    // コンストラクタ
    public ProjectCode(IHttpContextAccessor context)
    {
        Value = context.HttpContext.GetRouteData().Values["project"] as string ?? "";
    }
}

// 通常のオプション
public class EmailSenderOptions
{
    // フィールド(非プロパティ)はデシリアライズ対象外
    public const string SectionName = "EmailSender";

    public string? From { get; set; }
    public string? SmtpHost { get; set; }
    public int? SmtpPort { get; set; }
}

// 自前階層化オプション
public class BTSOptions
{
    public const string SectionName = "BTS";

    public string? Url { get; set; }
    public string? Account { get; set; }
    public string? Password { get; set; }
}

// セルフ階層化オプション
public class PackageOptions
{
    public const string SectionName = "Package";

    public string? Name { get; set; }
    public string? Summary { get; set; }

    // 孫階層継承時に使用する
    public string? TypeRegex { get; set; }
    public string? SummaryReplace { get; set; }

    // 孫階層
    public List<PackageOptions> Special { get; set; }
    // タイプにより、優先する設定があればそちらを返す
    public PackageOptions GetBy(string type)
    {
        if (Special == null)
        {
            return this;
        }
        // 一致するTypeを探す
        PackageOptions? ret = null;
        foreach (var p in Special)
        {
            if (p.TypeRegex == null))
            {
                continue;
            }
            Match m;
            if (type == p.TypeRegex) // 正規表現ではなく、完全一致
            {
                ret = p;
                break;
            }
            else if ((m = Regex.Match(type, p.TypeRegex)).Success)
            {
                if (p.SummaryReplace != null)
                {
                    // 正規表現のグループを使って置換
                    p.Summary = replaceWithMatch(p.SummaryReplace, m) };
                }
                ret = p;
                break;
            }
        }
        if (ret == null)
        {
            return this;
        }
        return ret;
    }
    // 正規表現のグループを使って置換
    private string replaceWithMatch(string input, Match m)
    {
        foreach (var k in m.Groups.Keys)
        {
            input = input.Replace("${" + k + "}", m.Groups[k].Value, System.StringComparison.InvariantCulture);
        }
        return input;
    }
}

// 自前階層化オプションの解決 --- 自分で書いたけど複雑だな(処理忘れてるな)
public static class ProjectOptions
{
    // リクエストパラメーターの{project}をもとにオプションを切り替える
    public static IOptionsSnapshot<T> GetOptions<T>(
        IConfiguration configuration, IServiceProvider provider, string sectionName
    ) where T : class
    {
        // HTTPリクエストの取得
        var context = provider.GetRequiredService<IHttpContextAccessor>();
        try
        {
            // リクエストパラメータから{project}を取り出す
            var project = context.HttpContext!.GetRouteData().Values["project"] as string;
            // Projects内の対象オプションを取得
            var rawOptions = configuration.GetSection($"Projects:{project}:{sectionName}");
            // 継承の有無確認
            var inheritParent = rawOptions.GetSection("InheritParent");
            if (bool.TryParse(inheritParent.Value, out var inherit) && inherit)
            {
                // オリジナルのものを書き換えないようにするので、一旦メモリ上に構築
                var inMemoryOptions = new Dictionary<string, string>();
                cloneToInMemory(inMemoryOptions, rawOptions);
                var inheritedOptions = new ConfigurationBuilder()
                                           .AddInMemoryCollection(inMemoryOptions)
                                           .Build()
                                           .GetSection($"Projects:{project}:{sectionName}");

                // 親のキーで探索
                var parentOptions = configuration.GetSection($"{sectionName}");
                foreach (var child in parentOptions.GetChildren())
                {
                    // 子になければ親のものを使う
                    if (!inheritedOptions.GetSection(child.Key).Exists())
                    {
                        deepCopySection(inheritedOptions, child, child.Key);
                    }
                }
                // 子のキーで探索
                var leafOptions = new Dictionary<string, IConfigurationSection>();
                getLeafDerivedOptions(leafOptions, inheritedOptions, sectionName);
                foreach (var leaf in leafOptions.Values)
                {
                    foreach (var child in inheritedOptions.GetChildren())
                    {
                        // 子になければ親のものを使う
                        if (!leaf.GetSection(child.Key).Exists())
                        {
                            deepCopySection(leaf, child, child.Key, leafOptions.Keys, inheritedOptions.Path);
                        }
                    }
                }
                rawOptions = inheritedOptions;
            }
            // マージしたオプションを使ってIOptions、IOptionsSnapshotの実態を構築
            return new OptionsManager<T>(
                new OptionsFactory<T>(
                    new IConfigureOptions<T>[]
                    {
                            new ConfigureOptions<T>(opt =>
                            {
                                rawOptions.Bind(opt);
                            })
                    },
                    Enumerable.Empty<IPostConfigureOptions<T>>()
                )
            );
        }
        catch (Exception)
        {
            context.HttpContext!.Response.StatusCode = 404;
            context.HttpContext.Response.WriteAsync("{\"message\": \"Not found\"}");
            throw;
        }
    }

    // オプションをメモリ上にコピー
    private static void cloneToInMemory(Dictionary<string, string> inMemory, IConfigurationSection src)
    {
        if (src.Value != null)
        {
            inMemory[src.Path] = src.Value;
        }
        else
        {
            foreach (var child in src.GetChildren())
            {
                cloneToInMemory(inMemory, child);
            }
        }
    }

    // オプションをディープコピー
    private static void deepCopySection(IConfigurationSection dst,
                                        IConfigurationSection src,
                                        string key,
                                        ICollection<string>? excludes = null,
                                        string? baseKey = null)
    {
        if (excludes != null && excludes.Contains(baseKey + ":" + key))
        {
            return;
        }

        if (src.Value != null)
        {
            dst[key] = src.Value;
        }
        else
        {
            foreach (var child in src.GetChildren())
            {
                deepCopySection(dst, child, key + ":" + child.Key, excludes, baseKey);
            }
        }
    }

    // オプションをディープコピー
    private static void getLeafDerivedOptions(Dictionary<string, IConfigurationSection> list,
                                              IConfigurationSection opts,
                                              string secctionName)
    {
        foreach (var child in opts.GetChildren())
        {
            if (child.Key == secctionName)
            {
                var inheritParent = child.GetSection("InheritParent");
                if (bool.TryParse(inheritParent.Value, out var inheritParent) && inheritParent)
                {
                    list[child.Path] = child; ;
                }
            }
            getLeafDerivedOptions(list, child, secctionName);
        }
    }
}

通常のオプションは単純なクラスで定義しています。

サービスに登録できるのは型なので、特定の文字列を登録するためにはstringをラップしたクラスを定義します。

自前の階層継承オプションはInheritParentプロパティによって継承の有無を判断して処理しています。
例えば、appsettings.jsonの内容にコメントを書くとこんな感じ。

# これは普通のオプション
"EmailSender": {
    "From": "noreply@example.com",
    "SmtpHost": "localhost",
    "SmtpPort": 25
},
# これらは階層構造の親として扱う
"BTS": {
    "Url": "http://localhost:3000/",
    "Account": "jenkins",
    "Password": "password"
},
"Package": {
    "Name": "my-app-ver-1.0",
    "Summary": "This is version 1.0"
},
# 本体はこちらでリクエストパラメーターで切り替え
"Projects": {
    "ABC": {
        # ここは書いてないものはルートのものを継承して、
        # {
        #   "Url": "http://localhost:3000/",
        #   "Account": "jenkins",
        #   "Password": "Shhhh"
        # }
        # として扱う
        "BTS": {
            "InheritParent": true, # ルートの"BTS"を継承する
            "Password": "Shhhh"
        },
        # ここは継承しないのでそのまま
        "Package": {
            "InheritParent": false, # ルートの"Package"は使わずここのまま
            "Name": "abc-package-v1.0",
            "Summary": "This is version 1.0"
        }
    },
    "XYZ": {
        # 未定義なら`InheritParent`がfalseとして扱い、すべてnullのオプションになる
        # "BTS": ...

        # これは type='dev_big'が指定されたら、Specialの正規表現に一致するのでそちらを優先使用し
        # {
        #   "Name": "dev-app-v0.0",
        #   "Summary": "This is version big"
        # }
        # 相当になる。
        "Package": {
            "InheritParent": true,
            # 親子間ではなく、子の中だけで分岐する用
            "Special": [
                "dev_(?<alias>.+)": {
                    "Name": "dev-app-v0.0",
                    #"Summary": ... ここではSummaryRegexとSummaryReplaceを使って構築
                    # 正規表現の名前付きグループを使って置換する
                    "SummaryRegex": "^Devlopment-(?<alias>.+)",
                    # ${key}の部分を置換する
                    "SummaryReplace": "This is version ${alias}"
                },
                "rc": {
                    "Name": "rc-app-v1.0",
                    "Summary": "This is release candidate."
                }
            ]
        }
    }
}

設定ファイルの書く場所

通常設定ファイルはプロジェクトルートフォルダのappsettings.jsonが使われます。
また環境変数ASPNETCORE_ENVIRONMENTの値と組み合わせたファイルも使われます。
例えばデバッグ実行時はDevelopmentが設定されて、appsettings.Development.jsonが使われ、
Publish後などデフォルトではProductionが設定されて、appsettings.Production.jsonが使われます。
さらに、Visual Studioのユーザーシークレットを使えば、secrets.jsonも使われます。
これらは次の順番で読み込まれ、それぞれマージされます。

  1. appsettings.json
  2. appsettings.%ASPNETCORE_ENVIRONMENT%.json
  3. secrets.json

例えば、appssettings.json

"Key": {
    "Value1": "appsettings.json",
    "Value2": "appsettings.json",
    "Value3": "appsettings.json",
}

appsettings.Development.json

"Key": {
    "Value2": "Development.json",
    "Value3": "Development.json",
    "Value4": "Development.json"
}

secrets.json

"Key": {
    "Value3": "secrets.json",
    "Value5": "secrets.json"
}

とあった場合、得られる設定は

"Key": {
    "Value1": "appsettings.json",
    "Value2": "Development.json",
    "Value3": "secrets.json",
    "Value4": "Development.json",
    "Value5": "secrets.json"
}

このようになります。

secrets.jsonの注意点

個人用の設定はsecrets.jsonを使用すると、プロジェクトフォルダ外に作成されるので誤ってバージョン管理に追加してしまうミスが防げます。
ただし注意が必要な点があります。
それは、IOptionsSnapshotIOptionsMonitorでの動的設定変更ができないことです。
設定変更のテストをしたいときはappsettings.json系を使うようにしましょう。私は小一時間無駄にしました。

あとsecretsとなってるけど、保存は平文です。
暗号化された設定を使用したい場合はAzure Key Value Configuration Provider [Document]が使えるようです。

まとめ

サービスの登録は

  • 常駐クラスはAddSingletonで登録
    • この場合必要なオプションはIOptionsMonitorで受け取る
  • セッションごとに生成/破棄するクラスはAddScopedで登録
    • 必要な時だけ作られるので、無駄なメモリを消費しない
  • オプションはAddOptionsで登録
    • 設定ファイルの変更を反映するタイミングにより受け取り方が違う
      • 起動時の設定を使いたい: IOptionsで受け取り
      • セッションごとに最新の設定を使いたい: IOptionsSnapshotで受け取り
        • ただしAddSingletonで登録したクラスには使えない。(実行時例外発生)
      • 常に最新の設定を使いたい: IOptionsMonitorで受け取り
        • IOptionsSnapshotと違ってこちらは常駐なので、AddSingletonで登録したクラスでも使える
  • 設定ファイル内に継承機能をつけたれば、処理を自作する
    • セッションURLで指定した値を使って、設定ファイルを分岐しつつ、一部のデータは共通で使いたい
    • AddScopedで使う設定ファイルを分岐する処理をラムダ式で登録する
  • サービス登録できるのは型なので、stringの内容を登録したければ、ラッパークラスを作り登録する
  • 設定ファイルはappsettings.json<appsettings.%ASPNETCORE_ENVIRONMENT%.json<secrets.jsonの順で上書きされていきます
    • secrets.jsonIOPtionsSnapshotIOptionMonitorでの動的設定変更が反映されないので注意

Discussion