.NET Core の設定情報をデータベースに格納して実行時に上書きできるようにする

f:id:ecb_mkobayashi:20190924113406p:plain

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

前回の記事で「データベースから設定情報を読み込む独自の ConfigurationProvider を作成して紹介してみたい」と書いておきながらも、コロナの影響やプロジェクトに忙殺されてしまったこともありまして、すっかり間が空いてしまいました。

今回は応用編となっていまして、ややディープなネタになっております。.NET Core の設定情報の基本から知りたい方は、以下の記事から先にお読みいただければと思います。

blog.ecbeing.tech blog.ecbeing.tech

目的と背景

さて、今回は設定情報をデータベースに保存して実行時にオーバーライドする機構を追加してみたいと思います。最初に「なぜ設定情報をデータベースに保存する必要があるのか?」という目的と背景について整理しましょう。

昨今のWebアプリケーションは、Dockerに代表されるような「イミュータブル・インフラストラクチャ」で作成され「本番環境に手を加えない」のが常識となっています。

Webアプリケーションをイミュータブル(不変)にしておけば、ブルーグリーンデプロイメントやカナリアリリースなどといった安全でダウンタイムのないリリース手法を容易に採用可能となるだけでなく、人為的ミスの入り込む余地を排除することが可能になります。これは、DevOpsを推進していく為には欠かすことのできない取り組みであると考えています。

しかし、これまで紹介してきた.NET Coreの設定情報を読み込む仕組みは、appsettings.jsonや環境変数などの変更が必要になりますので、イミュータブルではない実行環境が出来てしまいます。一方で「問題の調査のためにログレベルをちょっと調整したい」という状況になったとき、わざわざログレベルを変更した新しいアプリケーションをデプロイし、調査が終わったら元に戻すのはずいぶんと大袈裟な作業になります。

そこで、設定情報をデータベースに格納してオーバーライドできる仕組みを用意しておきたいわけです。この仕組みを用意しておけば、実行中のすべてのWebアプリケーションの挙動を設定によってすみやかに切り替え可能になります。

設計

これまでの記事で、さまざまな ConfigurationProvider から提供される情報をフラットにした KeyValue データの構造体が作成され、設定情報は IOptionsMonitor を使うことで、アプリケーションの実行中であっても設定を反映可能であることを紹介しました。

今回は新しく DBConfigurationProvider を作成して追加し、環境変数やコマンドラインパラメータよりも優先して反映される設定項目をデータベースに持つことを実現してみたいと思います。データベースは、リレーショナルデータベース・キーバリューストア・ドキュメントデータベースなど、格納先は何でも良いようにするために DBConfigurationProvider を抽象クラスとして実装し、継承先で実際のクエリの発行をするようにしたいと思います。

f:id:ecb_mkobayashi:20201028160754p:plain
抽象クラスDBConfigurationProvider

カスタムの ConfigurationProvider の作成手順は Microsoft Docs に記事がありますので、これを参考に作成していくことになります。この記事には、変更時に再度読み込む機能は実装されていませんとありますが、これを改良してデータベースの変更をポーリングで検知し、再度読み込む機能を追加する方針とします。

なお、ASP.NET Core で初期状態で構成されている ConfigurationProvider の全体に対して DBConfigurationProvider を挿入する位置関係は以下の図のようになります。

f:id:ecb_mkobayashi:20201028161428p:plain
DBConfigurationProviderの挿入位置

設定情報の変更の監視を行うポーリング間隔をアプリケーション起動時に指定できるようにします。 また、ポーリング時に設定の変更があったことを検知できるようにする必要がありますので、設定項目ごとに変更日時やタイムスタンプを保持おくようにします。データベースはSQL Serverとして、テーブルは以下のように設計してみました。

テーブル名: DBConfiguration

名前 データ型 補足説明
Key nvarchar(250) 設定項目名
Value nvarchar(max) 設定値
LastModified rowversion 自動インリメントされる数値


なお、SQL Serverには、rowversionというカラムを定義可能で、これを使えばレコードの更新時に自動的に数値がインクリメントされるため、更新を検知しやすいので使ってみました。

もし初期化時にデータベースにアクセスできなかった場合は、アプリケーションの起動は失敗させます。 また、アプリケーション起動後の設定情報のポーリング処理でエラーが発生した場合は、アプリケーションが停止しないようにします。

処理内容をフローチャートにすると以下のようになります。

f:id:ecb_mkobayashi:20201028161904p:plain
処理のフローチャート

実装と解説

新しい ConfigurationProvider を作成して追加する場合は IConfigurationSource インターフェースの実装が必要です。まずは DBConfigurationSource クラスを作成してみました。このクラスにはリロードを実施するかどうかの設定と、ポーリング間隔の情報を保持してもらいます。また、例外が発生したときのハンドラーを OnLoadException で定義できるようにしてみました。このあたりの実装内容については、FileConfigurationSource を参考にしています。.NET Core はオープンソースなので、Microsoftのエキスパートなエンジニアたちが作り上げてきたソースコードを参考にできる点が素晴らしいですね。

public abstract class DBConfigurationSource : IConfigurationSource
{
    // ポーリングによる変更監視をするかどうかを設定します
    public bool ReloadOnChange { get; set; } = true;
    // ポーリングの実行間隔をミリ秒で指定します
    public int Interval { get; set; } = 5000;
    // 例外ハンドラを設定します
    public Action<DBConfigurationExceptionContext> OnLoadException { get; set; }
    // 抽象メソッド
    public abstract IConfigurationProvider Build(IConfigurationBuilder builder);
}

// OnLoadExceptionのパラメータ
public class DBConfigurationExceptionContext
{
    public DBConfigurationProvider Provider { get; set; }

    public Exception Exception { get; set; }

    public bool Ignore { get; set; }
}

つづいて、DBConfigurationProvider クラスの実装です。このクラスは ConfigurationProvider から継承していて、Data プロパティを通じてASP.NET Core のフレームワークに KeyValue データを供給します。この KeyValue データには、データベースから読み取った情報を格納しますが、その取得処理の実装は LoadFromDataSource という抽象メソッドに担ってもらいます。

public abstract class DBConfigurationProvider : ConfigurationProvider
{
    private readonly System.Timers.Timer _reloadTimer = new System.Timers.Timer();

    public DBConfigurationProvider(DBConfigurationSource source)
    {
        if (source == null)
        {
            throw new ArgumentNullException(nameof(source));
        }
        Source = source;

        // ポーリングの起動タイマーを設定します。
        if (Source.ReloadOnChange)
        {
            _reloadTimer.AutoReset = false;
            _reloadTimer.Interval = Source.Interval;
            _reloadTimer.Elapsed += (s, e) => { Load(reload: false); };
        }
    }

    public DBConfigurationSource Source { get; }
    // 最終更新日をlong型で確保します。
    public long LastModified { get; set; } = long.MinValue;
    // 初回起動の判定フラグです。
    public bool IsInitialized { get; private set; } = false;
    // 抽象メソッド
    public abstract bool LoadFromDataSource();

    public override void Load()
    {
        Load(reload: false);
    }
    
    // ポーリングのタイマーで実行されるリロード処理です
    private void Load(bool reload)
    {
        if (reload)
        {
            Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }

        try
        {
            if (LoadFromDataSource() || reload)
            {
                OnReload();
            }

            IsInitialized = true;
        }
        catch (Exception e)
        {
            HandleException(ExceptionDispatchInfo.Capture(e));
        }
        finally
        {
            if (IsInitialized)
            {
                _reloadTimer.Start();
            }
        }
    }

    // 例外をスローするか否かをOnLoadExceptionハンドラーの決定に委ねます
    private void HandleException(ExceptionDispatchInfo info)
    {
        bool ignoreException = false;
        if (Source.OnLoadException != null)
        {
            var exceptionContext = new DBConfigurationExceptionContext
            {
                Provider = this,
                Exception = info.SourceException
            };
            Source.OnLoadException.Invoke(exceptionContext);
            ignoreException = exceptionContext.Ignore;
        }
        if (!ignoreException)
        {
            info.Throw();
        }
    }
}

抽象クラスDBConfigurationSourceとDBConfigurationProvider クラスの実装クラスを作成します。このクラスはSQL Serverへの接続して、更新されたレコードを確認するクエリを実行し、Dataプロパティに詰め込む、という処理を実行しています。

public class SqlConfigurationSource : DBConfigurationSource
{
    public string ConnectionString { get; set; }
    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new SqlConfigurationProvider(this);
    }
}

public class SqlConfigurationProvider : DBConfigurationProvider
{
    public SqlConfigurationProvider(SqlConfigurationSource source) : base(source) { }

    public override bool LoadFromDataSource()
    {
        bool result = false;
        using (var con = new SqlConnection(((SqlConfigurationSource)Source).ConnectionString))
        using (var cm = con.CreateCommand())
        {
            // 更新されたレコードを取得します。rowversion列はbigintに変換します
            cm.CommandText = "SELECT [Key], [Value], CONVERT(bigint, [LastModified]) as [LastModified] FROM [DBConfiguration] WHERE [LastModified] > @LastModified";
            cm.Parameters.AddWithValue("LastModified", LastModified);
            con.Open();
            using (var reader = cm.ExecuteReader())
            {
                while (reader.Read())
                {
                    Data[reader.GetString("Key")] = reader.GetString("Value");
                    long rowModified = (long)reader["LastModified"];
                    LastModified = rowModified > LastModified ? rowModified : LastModified;
                    result = true;
                }
            }
            con.Close();
        }

        return result;
    }
}

最後に、以下のようにProgram.csのCreateHostBuilderメソッドを書き換えて、SqlConfigurationSource をアプリケーションの初期化処理に組み込みます。

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((hostingContext, config) =>
        {
            config.Add(new SqlConfigurationSource
            {
                // データベース接続文字列です。ここでは動作確認のため SQL Server LocalDB を使っています。
                ConnectionString = @"Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=c:\your\localdb\data.mdf;Integrated Security=True;Connect Timeout=30",
                Interval = 5000,
                ReloadOnChange = true,
                OnLoadException = (context) => {
                    if (!context.Provider.IsInitialized)
                    {
                        context.Ignore = false;
                        Console.WriteLine(context.Exception.ToString());
                    }
                    else
                    {
                        context.Ignore = true;
                        Console.WriteLine(context.Exception.ToString());
                    }
                }
            });
        })
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();

        });
    }

試験

組み込みが終わったら実際に期待通りに動くことを試験しましょう。 試験観点は以下の通りです。

  • アプリケーション起動時
    • データベースに接続できない場合は例外が発生してアプリケーション起動が失敗すること。
    • 同一キーの設定項目が存在する場合は上書きされていること。
    • 同一キーの設定項目が存在しない場合は新しい設定項目として参照可能であること。
  • アプリケーション起動後の設定項目変更時
    • 新しい設定レコードを追加した場合に数秒後に設定に追加されていること。
    • 設定レコードを更新した場合に数秒後に設定が上書きされていること。
  • アプリケーション起動後のポーリングにおける例外発生の挙動
    • データベースへのクエリで例外が発生してもアプリケーションは利用可能であること。
    • データベースへのクエリで例外発生後に、例外の原因を解消した場合は設定が反映されること。

本来であればこれらのテストはユニットテストで実現したかったのですが、今回は手動でテストしてしまいました。 ASP.NET Core に組み込まれる部品をユニットテストする場合は、こちらの記事を参考すると良さそうです。 なかなかのボリュームの記事となっていまして、そのうち紹介できれば...と思っております。

まとめ

今回はデータベースをポーリングして変更を検知し、設定項目に実行時に反映するという機能について実装してみました。

.NET Coreの設定情報には拡張しやすい機構が最初から備わっていて、これを使えばアプリケーションをイミュータブルに保ちつつ、動的に設定を変更していくことが可能となりました。

データベース接続文字列を引き渡し方や、ポーリングのクエリ発行時に発生した例外のログ出力方法など、もうひと工夫できそうな点もありますので、まだ理想的と言えるものではありませんが、実現方法のヒントになればと思います。

~ecbeingでは、.NET Core で新サービスの開発や既存サービスの成長に携わりたい勉強熱心なエンジニアを大募集中です!~ careers.ecbeing.tech