Паттерн Options в .NET

·9 минут чтения
Паттерн Options в .NET

Паттерн Options в .NET

Практически любому приложению необходима конфигурация. Строки подключения, API-ключи, настройки SMTP, Feature Flags, параметры кэша, таймауты, уровни логирования — всё это должно настраиваться без перекомпиляции приложения. В .NET за работу с конфигурацией отвечает интерфейс IConfiguration. Несмотря на то, что это удобный низкоуровневый API, использование его напрямую во всём приложении быстро приводит к плохо поддерживаемому коду. Паттерн Options решает эту проблему, предоставляя конфигурацию в виде строго типизированных объектов, которые легко интегрируются с Dependency Injection. В этой статье мы разберём, как работает паттерн Options, зачем он был создан и в каких случаях использовать IOptions<T>, IOptionsSnapshot<T> и IOptionsMonitor<T>.


Какую проблему решает паттерн Options?

Самый простой способ получить настройки — внедрить IConfiguration напрямую в сервис.

public class OrderService
{
    private readonly IConfiguration _configuration;

    public OrderService(IConfiguration configuration)
    {
        _configuration = configuration;
    }

    public void Process()
    {
        var timeout = _configuration.GetValue<int>("Database:Timeout");
    }
}

Такой подход работает, но имеет несколько недостатков.

Во-первых, зависимость получается слишком общей. По конструктору невозможно понять, какие именно параметры конфигурации использует сервис.

Во-вторых, ключи конфигурации превращаются в строковые литералы. Компилятор не сможет обнаружить опечатку или переименование ключа.

_configuration["Database:Timeout"]

В-третьих, связанные настройки оказываются разбросаны по всему приложению вместо того, чтобы быть объединёнными в одну модель.

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

Options паттерн решает все эти проблемы.


Конфигурация в .NET

Паттерн Options построен поверх системы конфигурации .NET, поэтому полезно понимать, как она устроена. IConfiguration — это фасад над одним или несколькими поставщиками конфигурации. Каждый поставщик получает данные из своего источника. Например:

  • JSON-файлы
  • Переменные окружения
  • User Secrets
  • Аргументы командной строки
  • Azure App Configuration
  • Azure Key Vault

Каждый поставщик преобразует данные в единое представление в виде пар ключ-значение, благодаря чему остальная часть приложения работает с конфигурацией через единый API. Если один и тот же ключ присутствует сразу в нескольких источниках, используется значение последнего зарегистрированного поставщика. Например, переменная окружения может переопределить значение из appsettings.json без изменения самого файла. Внутри система конфигурации ничего не знает о ваших классах. Она хранит только строковые пары ключ-значение, например:

Database:ConnectionString = ...
Database:Timeout = 30

Именно на основе этих данных паттерн Options создаёт строго типизированные объекты.


Что такое паттерн Options?

Паттерн Options преобразует конфигурацию в строго типизированный объект.

Вместо чтения отдельных параметров

_configuration["Database:Timeout"]

мы создаём обычный POCO-класс.

public class DatabaseOptions
{
    public string ConnectionString { get; set; } = "";

    public int Timeout { get; set; }
}

После этого регистрируем его.

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration("Database");

Теперь сервисы работают не с IConfiguration, а с моделью настроек.

public class OrderService
{
    private readonly DatabaseOptions _options;

    public OrderService(IOptions<DatabaseOptions> options)
    {
        _options = options.Value;
    }
}

Сервис больше ничего не знает о JSON-файлах, переменных окружения или ключах конфигурации. Он знает только те настройки, которые ему действительно необходимы. В этом и заключается главное архитектурное преимущество паттерна Options — он отделяет потребление конфигурации от способа её хранения.


Привязка конфигурации (Binding)

Возникает вопрос. Каким образом .NET создаёт объект DatabaseOptions? За это отвечает механизм привязки конфигурации (Binding). Binder считывает значения конфигурации и сопоставляет их с открытыми свойствами объекта. Например,

Database:ConnectionString
Database:Timeout

превращаются в

new DatabaseOptions
{
    ConnectionString = "...",
    Timeout = 30
};

Binder автоматически умеет преобразовывать строки в большинство распространённых типов .NET, например:

  • int
  • bool
  • enum
  • Guid
  • TimeSpan
  • DateTime

Также поддерживаются вложенные объекты.

Например,

{
    "Database": {
        "Retry": {
            "Count": 3
        }
    }
}

может быть преобразован в

public class DatabaseOptions
{
    public RetryOptions Retry { get; set; } = new();
}

public class RetryOptions
{
    public int Count { get; set; }
}

Важно понимать, что Binding лишь копирует значения конфигурации в объект. Он не выполняет никакой валидации.


Регистрация Options

Когда мы пишем

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration("Database");

никакой объект ещё не создаётся. Во время регистрации мы лишь описываем правила, по которым объект будет создан позже. Другими словами, мы регистрируем не экземпляр объекта, а инструкции по его созданию. Сам объект будет создан только тогда, когда какой-либо сервис впервые запросит его из контейнера Dependency Injection. Такой подход называется ленивой инициализацией (Lazy Initialization).


Как создаются Options

Когда сервис запрашивает IOptions<T>, IOptionsSnapshot<T> или IOptionsMonitor<T>, контейнер Dependency Injection разрешает соответствующую зависимость. Если требуется создать новый объект настроек, IOptionsFactory<T> выполняет следующий конвейер:

Поставщики конфигурации
      IConfiguration
   Создание TOptions
 Выполнение всех Configure
 Выполнение всех PostConfigure
 Выполнение всех Validation
 Возврат готового объекта

Каждый этап отвечает за свою задачу.

  • Configure выполняет первоначальную настройку объекта.
  • BindConfiguration является одной из операций Configure.
  • PostConfigure вызывается после завершения всех Configure.
  • Validation проверяет, что итоговый объект корректен.

IOptionsFactory<T> отвечает только за создание объекта. Кэширование он не выполняет. То, как именно будут кэшироваться настройки, зависит от того, используется IOptions<T>, IOptionsSnapshot<T> или IOptionsMonitor<T>.


Зачем нужен PostConfigure?

Иногда одни настройки зависят от других. Например,

Host = localhost
Port = 8080

Вместо того чтобы хранить в конфигурации

BaseUrl = http://localhost:8080

это значение можно вычислить после завершения привязки.

builder.Services
    .PostConfigure<DatabaseOptions>(options =>
    {
        options.BaseUrl = $"http://{options.Host}:{options.Port}";
    });

Метод PostConfigure() позволяет вычислять производные значения после того, как все настройки уже были заполнены.

IOptions<T>

IOptions<T> — самый простой способ получить доступ к настройкам. При первом запросе IOptions<T> из контейнера Dependency Injection объект настроек создаётся с помощью IOptionsFactory<T>, затем кэшируется и повторно используется на протяжении всего времени жизни приложения.

Первый запрос
IOptionsFactory<T>
Создание DatabaseOptions
Кэширование
Возврат объекта

Все последующие запросы
Возврат объекта из кэша

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

  • название приложения;
  • значения по умолчанию для пагинации;
  • различные лимиты;
  • любые другие настройки, изменение которых требует перезапуска приложения.

Важно понимать, что сами поставщики конфигурации могут обнаружить изменения в источнике данных. Однако IOptions<T> игнорирует эти изменения и всегда возвращает один и тот же экземпляр объекта.


IOptionsSnapshot<T>

IOptionsSnapshot<T> предназначен для сервисов со временем жизни Scoped. В отличие от IOptions<T>, новый объект настроек создаётся один раз для каждой области видимости (scope) и повторно используется до её завершения. В ASP.NET Core область видимости обычно соответствует одному HTTP-запросу.

HTTP-запрос №1
Создание DatabaseOptions
Повторное использование
в рамках запроса №1

HTTP-запрос №2
Создание нового DatabaseOptions
Повторное использование
в рамках запроса №2

Обратите внимание, что объект не создаётся заново при каждой инъекции. Если несколько сервисов внутри одной области видимости получают IOptionsSnapshot<T>, все они будут работать с одним и тем же экземпляром настроек. Такое поведение гарантирует согласованность данных. Представим, что запрос начался с настройками

Timeout = 30

Во время обработки запроса конфигурация изменилась.

Timeout = 60

Текущий запрос продолжит использовать первоначальный объект настроек и только новые запросы получат обновлённую конфигурацию. Благодаря этому один запрос никогда не увидит частично изменённые настройки. Недостатком такого подхода является то, что привязка конфигурации и её валидация выполняются при создании каждой новой области видимости, поэтому IOptionsSnapshot<T> работает немного менее эффективно, чем IOptions<T>.


IOptionsMonitor<T>

Некоторые сервисы работают на протяжении всего времени жизни приложения и не имеют собственной области видимости. Например:

  • BackgroundService;
  • Kafka и RabbitMQ Consumer;
  • любые Singleton-сервисы.

Если такие сервисы используют IOptions<T>, они никогда не узнают об изменении конфигурации. Для таких сценариев существует IOptionsMonitor<T>. Он подписывается на уведомления об изменении конфигурации. Когда конфигурация изменяется, создаётся новый объект настроек, который заменяет предыдущий.

Изменение конфигурации
IOptionsFactory<T>
Создание нового DatabaseOptions
Замена объекта в кэше

Важно отметить, что существующий объект не изменяется. Вместо этого создаётся полностью новый экземпляр. Это сделано намеренно. Представим, что текущие настройки имеют значения:

Timeout = 30
Retries = 5

Если бы объект изменялся “на месте”, другой поток мог бы увидеть следующее состояние:

Timeout = 60
Retries = 5

а немного позже

Timeout = 60
Retries = 10

Такое состояние никогда не существовало в реальной конфигурации. Замена всего объекта целиком позволяет избежать подобных ситуаций. Каждый потребитель всегда работает с полностью согласованным экземпляром настроек.


Какой интерфейс выбрать?

Каждый интерфейс решает свою задачу.

ИнтерфейсВремя жизниПоддержка обновленийКогда использовать
IOptions<T>SingletonНетSingleton-сервисы, которым не нужны изменения конфигурации
IOptionsSnapshot<T>ScopedНовые настройки для каждой области видимостиScoped-сервисы
IOptionsMonitor<T>SingletonДаДолгоживущие Singleton-сервисы

Не существует универсального варианта. Выбирайте интерфейс в зависимости от времени жизни сервиса, который использует настройки.


Валидация настроек

Binding лишь копирует значения конфигурации в объект, но он никак не проверяет их корректность. Например,

{
  "Database": {
    "Timeout": -10
  }
}

значение -10 успешно преобразуется в тип int. Для Binder это корректное значение. Но является ли отрицательный таймаут допустимым — уже зависит от бизнес-логики приложения. Именно эту задачу решает валидация.


ValidateDataAnnotations()

Самый простой способ проверить настройки — использовать атрибуты Data Annotations.

public class DatabaseOptions
{
    [Required]
    public string ConnectionString { get; set; } = "";

    [Range(1, 300)]
    public int Timeout { get; set; }
}

Затем достаточно включить валидацию при регистрации.

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations();

При создании объекта настроек инфраструктура Options выполнит проверку по указанным атрибутам. Такой подход хорошо подходит для простой проверки отдельных свойств.


Пользовательская валидация

Некоторые правила невозможно описать с помощью атрибутов. Предположим, что приложение поддерживает SSL. Если SSL включён, обязательно должен быть указан путь к сертификату.

public class DatabaseOptions
{
    public bool UseSsl { get; set; }

    public string? CertificatePath { get; set; }
}

Такое правило затрагивает сразу несколько свойств. В подобных случаях можно реализовать интерфейс IValidateOptions<T>.

public class DatabaseOptionsValidator
    : IValidateOptions<DatabaseOptions>
{
    public ValidateOptionsResult Validate(
        string? name,
        DatabaseOptions options)
    {
        if (options.UseSsl &&
            string.IsNullOrWhiteSpace(options.CertificatePath))
        {
            return ValidateOptionsResult.Fail(
                "CertificatePath is required when SSL is enabled.");
        }

        return ValidateOptionsResult.Success;
    }
}

После этого зарегистрировать валидатор.

builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>,
    DatabaseOptionsValidator>();

Такой подход позволяет реализовывать любую, даже достаточно сложную логику проверки.


ValidateOnStart()

По умолчанию объект настроек создаётся лениво. Если его никто не запрашивает, он никогда не будет создан. Это означает, что и валидация выполнится только в момент первого получения объекта из контейнера Dependency Injection. Иногда это приводит к тому, что ошибка конфигурации обнаруживается уже во время работы приложения. Метод ValidateOnStart() позволяет изменить это поведение.

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations()
    .ValidateOnStart();

Важно понимать, что ValidateOnStart() не выполняет валидацию сразу. Он лишь регистрирует специальный сервис, который запускается во время старта приложения и инициирует создание и проверку объектов настроек. Упрощённо этот процесс выглядит следующим образом.

Запуск Host
Startup Validation
Создание объекта настроек
Configure
PostConfigure
Validation
Начало обработки запросов

Если проверка завершится ошибкой, будет выброшено исключение OptionsValidationException, а приложение не сможет запуститься. Такой подход соответствует принципу Fail Fast — лучше обнаружить ошибку конфигурации во время запуска, чем спустя несколько часов после начала работы приложения.


Распространённые ошибки

Использование IConfiguration во всех сервисах

Если сервис напрямую зависит от IConfiguration, он получает доступ ко всей конфигурации приложения. Это делает зависимости менее очевидными. Лучше внедрять только те настройки, которые действительно необходимы сервису.


Использование IOptionsSnapshot<T> в Singleton-сервисах

IOptionsSnapshot<T> имеет время жизни Scoped. Следовательно, его нельзя внедрять в Singleton-сервисы. В подобных случаях следует использовать IOptionsMonitor<T>.


Ожидание автоматического обновления IOptions<T>

Даже если поставщики конфигурации обнаружили изменения, IOptions<T> продолжит возвращать первоначальный экземпляр объекта. Если сервис должен видеть изменения конфигурации без перезапуска приложения, следует использовать IOptionsMonitor<T>.


Хранение вычисляемых значений в конфигурации

Конфигурация должна содержать только исходные данные. Если значение можно вычислить на основе других параметров, лучше сделать это в PostConfigure().


Заключение

Паттерн Options — это гораздо больше, чем три интерфейса. Он предоставляет полноценный механизм создания, настройки, валидации и использования строго типизированных объектов конфигурации. Понимание этого механизма позволяет правильно выбирать нужный интерфейс и избегать распространённых ошибок. Полную картину можно представить следующим образом.

Поставщики конфигурации
     IConfiguration
Configuration Binder
     Configure
   PostConfigure
    Validation
 IOptionsFactory<T>
          ├────────► IOptions<T>
          ├────────► IOptionsSnapshot<T>
          └────────► IOptionsMonitor<T>

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