alexeyfv

Опубликовано

- 3 мин чтения

Что не так с паттерном Options в C#

C# ASP.NET Паттерны проектирования
img of Что не так с паттерном Options в C#

В .NET существует так называемый паттерн Options, который упрощает работу с конфигурацией приложений. Чтобы использовать его, разработчику нужно выполнить три шага: подключить нужный провайдер конфигурации, настроить сервисы через метод расширения Configure, а затем внедрить IOptions<T>, IOptionsMonitor<T> или IOptionsSnapshot<T> через конструктор.

Microsoft предоставляет стандартные провайдеры конфигурации, например, работу с JSON через AddJsonFile. Однако провайдера для получения конфигурации из базы данных нет. В этой статье рассмотрим, как можно это реализовать.

Проблема

Представим следующую ситуацию. Есть сервис Configuration Updater, который обновляет конфигурацию в базе данных. И есть сервис Configuration Consumer, который читает эту конфигурацию.

Диаграмма работы Configuration Updater и Consumer с базой данных

Класс конфигурации AppConfig выглядит так:

   public class AppConfig
{
    public int Id { get; init; }
    public int Version { get; private set; }
    public required string Guid { get; set; }

    public void Update(string guid)
    {
        Guid = guid;
        Version++;
    }
}

Сервис Configuration Updater каждую секунду обновляет запись в базе данных:

   var db = new DatabaseContext("DataSource=./../Database/db.sqlite");

while (true)
{
    var guid = Guid.NewGuid().ToString();

    var appConfig = db.AppConfigs.FirstOrDefault();

    if (appConfig is null)
    {
        db.AppConfigs.Add(new AppConfig() { Guid = guid, });
    }
    else
    {
        appConfig.Update(guid);
    }

    db.SaveChanges();

    Console.WriteLine("Configuration updated: {0}", guid);

    await Task.Delay(1000);
}

А Configuration Consumer каждую секунду читает конфигурацию:

   public class ConsumerClass(IOptionsMonitor<AppConfig> _optionsMonitor)
{
    public async Task DoWork()
    {
        while (true)
        {
            var config = _optionsMonitor.CurrentValue;
            Console.WriteLine("Consume config: {0}", config.Guid);
            await Task.Delay(1000);
        }
    }
}

Теперь нужно реализовать получение конфигурации из базы данных. Примеры есть, например:

Идея одна: нужно создать два класса — источник конфигурации и сам провайдер.

Класс DatabaseConfigurationProvider читает конфигурацию из базы, сериализует её в JSON и загружает в словарь Data через базовый JsonConfigurationProvider:

   public class DatabaseConfigurationProvider(string _connectionString, JsonConfigurationSource source) : JsonConfigurationProvider(source)
{
    public static string Prefix => nameof(AppConfig);

    public override void Load()
    {
        using var db = new DatabaseContext(_connectionString);

        var appConfig = db.AppConfigs.FirstOrDefault() ?? throw new InvalidOperationException("Configuration");

        var json = JsonSerializer.Serialize(new { AppConfig = appConfig });

        if (string.IsNullOrWhiteSpace(json)) return;

        var bytes = Encoding.UTF8.GetBytes(json);

        using var stream = new MemoryStream(bytes);

        Load(stream);
    }
}

Класс DatabaseConfigurationSource используется для регистрации провайдера в composition root:

   public class DatabaseConfigurationSource(string _connectionString) : JsonConfigurationSource
{
    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        Console.WriteLine("Build configuration source");
        EnsureDefaults(builder);
        return new DatabaseConfigurationProvider(_connectionString, this);
    }
}

public static class DatabaseConfigurationExtensions
{
    public static IConfigurationBuilder AddDatabaseConfiguration(this IConfigurationBuilder builder, string connectionString) =>
        builder.Add(new DatabaseConfigurationSource(connectionString));
}

Этот код компилируется и работает, но только один раз — при старте приложения. Причина в том, что конфигурация читается только при инициализации. В примерах предлагают решить это через Timer или ChangeToken для периодического обновления.

Но здесь возникают проблемы:

  1. Лишние запросы к базе: Конфигурация читается даже тогда, когда в базе ничего не поменялось.
  2. Несогласованность данных: Конфигурация в базе уже обновилась, а таймер ещё не сработал. Значит, потребитель получит устаревшую версию.

Предложенное решение

Если нужно всегда получать актуальные данные, можно проще: реализовать IOptionsMonitor<AppConfig> и зарегистрировать его как singleton:

   public class DatabaseConfigurationMonitor(string _connectionString) : IOptionsMonitor<AppConfig>
{
    public AppConfig CurrentValue => Get();

    public AppConfig Get(string? name) => Get();

    private AppConfig Get() =>
        new DatabaseContext(_connectionString)
            .AppConfigs
            .FirstOrDefault() ?? throw new InvalidOperationException("Configuration");

    public IDisposable? OnChange(Action<AppConfig, string?> listener)
    {
        throw new NotImplementedException();
    }
}

public static class DatabaseConfigurationExtensions
{
    public static IServiceCollection ConfigureDatabaseOptionsMonitor(this IServiceCollection collection, string connectionString) =>
        collection.AddSingleton<IOptionsMonitor<AppConfig>>(provider => new DatabaseConfigurationMonitor(connectionString));
}

Теперь при каждом обращении к _optionsMonitor.CurrentValue будет получена самая свежая версия из базы.

Минус подхода: если понадобится заменить IOptionsMonitor<T> на IOptions<T>, придётся реализовать его отдельно.

Выводы

В приложениях, использующих паттерн Options, нет готового решения для работы с базой данных. Обычно приходится писать свои классы на основе ConfigurationProvider, что может приводить к лишним запросам и устаревшим данным.

Более простое и надёжное решение — реализовать IOptionsMonitor<T> или IOptions<T>, чтобы получать актуальную конфигурацию напрямую из базы.