Строго типизированная конфигурация в ASP.NET Core

Система конфигурации в .NET очень гибкая. Она позволяет загружать параметры из разных мест: JSON файлы, YAML файлы, переменные окружения, Azure Key Vault и многое другое. В статье предлагается использовать конечный объект IConfiguration в приложении чтобы настроить строгую типизацию.

Строго типизированная конфигурация с помощью простых объектов описывает часть вашей конфигурации вместо обычного хранения пар “ключ-значение” в IConfiguration.

Допустим, вы настраиваете интеграцию со Slack, и для отправки сообщений в канал используете вебхуки. Вам понадобится URL вебхука, и какие-нибудь дополнительные параметры, например имя приложения, которое будет использоваться для отправки сообщений в канал:

public class SlackApiSettings {
public string WebhookUrl { get; set; }
public string DisplayName { get; set; }
public bool ShouldNotify { get; set; }
}

Этот объект можно привязать к вашей конфигурации в Program.cs используя метод расширения Configure<T>(). Когда вам понадобится этот объект в контроллере, вы можете внедрить зависимость IOptions<SlackApiSettings> в его контроллер. Например, чтобы внедрить конфиги в Minimal API эндпоинт и вернуть JSON с ними можно сделать так:

using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// связка конфигурации с секцией SlackApi
// например SlackApi:WebhookUrl и SlackApi:DisplayName
builder.Services.Configure<SlackApiSettings>(
builder.Configuration.GetSection("SlackApi"));
var app = builder.Build();
// вернуть объект конфига
app.MapGet("/", (IOptions<SlackApiSettings> options) => options.Value);
app.Run();

Под капотом система конфигурации ASP.NET Core создаёт новый объект SlackApiConfiguration и пытается сопоставить каждое свойство в объекте со значениями в секции IConfiguration.

Чтобы получить объект конфигурации, обратитесь к IOptions<T>.Value, как показано в обработчике эндпоинта.

Избегание зависимости IOptions

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

using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// Регистрируем объект IOptions
builder.Services.Configure<SlackApiSettings>(
builder.Configuration.GetSection("SlackApi"));
// Явно регистрируем объект конфигурации, делегируя определение на IOptions
builder.Services.AddSingleton(resolver =>
resolver.GetRequiredService<IOptions<SlackApiSettings>>().Value);
var app = builder.Build();

Теперь в контроллеры можно внедрять “сырой” объект настроек, без зависимости от пакета Microsoft.Extensions.Options. Я думаю, что это более предпочтительный способ, потому что в этом случае интерфейс IOptions<T> не нужен.

app.MapGet("/", (SlackApiSettings options) => options);
app.Run();

Обычно это работает хорошо, хотя тут есть пара нюансов:

  • В примере выше не будет работать “перезагрузка файла” для конфигурации, так как я использовал Singleton (можно использовать Scoped, если вам нужна эта функция)
  • При регистрации IOption появляется дополнительный уровень косвенности, вместо регистрации объекта SlackApiSettings напрямую в механизме внедрения зависимостей. Лично мне нравится такой подход, но вы можете использовать IOptions. Есть еще один подход, описанный в этом посте.

Наличие отличной поддержки загрузки конфигурации из разных источников это хорошо, но что будет, если вы ошибётесь в конфигурации, например допустите опечатку в JSON файле?

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

Что случится, если сопоставление проваливается?

Есть несколько случаев, когда что-то может пойти не так при сопоставлении строго типизированных объектов с конфигурацией. Я покажу несколько примеров ошибок в JSON конфигурации, используя пример обработчика, написанный выше.

Опечатка в названии секции

При сопоставлении конфигурации вы указываете имя секции, откуда брать значения. Если думать в терминах файла appsettings.json, секция — это название ключа объекта в JSON. "Logging" и "SlackApi" это секции в приведённом ниже .json файле:

{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"AllowedHosts": "*",
"SlackApi": {
"WebhookUrl": "http://example.com/test/url",
"DisplayName": "My fancy bot",
"ShouldNotify": true
}
}

Чтобы связать SlackApiSettings с секцией "SlackApi", можно сделать:

builder.Services.Configure<SlackApiSettings>(
Configuration.GetSection("SlackApi")
);

Но что если в названии секции будет допущена опечатка? Например вместо SlackApi укажем SlackApiSettings:

builder.Services.Configure<SlackApiSettings>(
Configuration.GetSection("SlackApiSettings")
);

Вызов эндпоинта даст:

{"webhookUrl":null,"displayName":null,"shouldNotify":false}

Все ключи получили значение по умолчанию, но никаких ошибок не произошло. Сопоставление произошло, но с пустой секцией конфигурации. Наверное, это плохо, потому что ваш код ожидает, что в webhookUrl будет валидный Uri.

Примечание переводчика: Вообще, чтобы решить эту проблему можно вместо Configuration.GetSection использовать Configuration.GetRequiredSection. Тогда при попытке сопоставить объект с несуществующей секцией возникнет исключение.

Опечатка в названии свойства

Что произойдёт, если название секции верно, но неверно название свойства? Например, что если WebhookUrl будет записан в файле как Url?

{
"SlackApi": {
"Url": "http://example.com/test/url",
"DisplayName": "My fancy bot",
"ShouldNotify": true
}
}

Посмотрим на результат:

{"webhookUrl":null,"displayName":"My fancy bot","shouldNotify":true}

Так как название секции правильное, DisplayName и ShouldNotify попали в объект конфигурации правильно. Но WebhookUrl содержит null, так как в конфигурации нет такого поля (Url вместо него). И снова никаких сообщений о том, что поле не обработалось корректно.

Несвязываемые поля

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

public class SlackApiSettings
{
public string WebhookUrl { get; }
public string DisplayName { get; }
public bool ShouldNotify { get; }
}

и снова посмотрим на ответ эндпоинта, мы получим объект со значениями по умолчанию так как парсер не сможет установить значение в объект:

{"webhookUrl":null,"displayName":null,"shouldNotify":false}

Несовместимые типы данных

И последняя ошибка в этом посте происходит, когда парсер пытается связать поля с несовместимыми типами данных. В конфигурации всё представлено в виде строк, но парсер может преобразовывать простые типы. Например "true" или "FALSE" нормально преобразуется в поле bool ShouldNotify, но если вы попытаетесь запихать туда что-нибудь ещё, например "THE VALUE", вы получите исключение, когда будете дёргать эндпоинт и парсер попытается собрать объект IOptions<T>:

Скриншот (c) Andrew Lock
Скриншот (c) Andrew Lock

Факт получения ошибки не очень хороший, но хотя бы парсер вообще кидает исключение, которое чётко даёт понять в чём проблема! Я слишком много раз попадал в ситуации, когда вызовы к внешнему API не отрабатывали только потому, что в объект конфигурации не попадала строка подключения или базовый URL из-за ошибки связывания.

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

Валидация значений IOptions

Валидация значений в IOptions появилась еще в .NET Core 2.2 с методами Validate<> и ValidateDataAnnotations(). Их проблема в том, что они не запускаются со стартом приложения, только в момент получения доступа к IOptions. Это было частичным решением проблемы, поэтому я создал NuGet пакет, который запускал валидацию на старте приложения.

К счастью, в .NET 6 появился метод ValidateOnStart(), который делает в точности то, что нам нужно — запускает валидацию при старте приложения!

Если вам интересно, как это реализовано: Фишка в использовании IHostedService для валидации. Реализацию можно посмотреть в этом PR.

Чтобы использовать такую валидацию, нужно сделать четыре вещи:

  • Переключиться на services.AddOptions<T>().Bind() вместо services.Configure<T>()
  • Добавить атрибуты валидации к объекту конфигурации
  • Вызвать ValidateDateAnnotations() OptionsBuilder’а, возвращённого из AddOptions<T>()
  • Вызвать ValidateOnStart() OptionsBuilder’а.

Метод расширения IServiceCollection.AddOptions<T>() ведёт себя как альтернативная версия Configure<T>():

  • AddOptions<T>() возвращает объект OptionsBuilder<T> вместо IServiceCollection
  • Нужно вызвать Bind() объекта OptionsBuilder<T> чтобы связать конфиг с объектом.

Использование объекта OptionsBuilder<T> открывает новые возможности для добавления нового функционала вроде валидации.

Вспомогательное расширение BindConfiguration() было добавлено в OptionsBuilder, чтобы упростить связывание секций конфигураций. В следующем блоке будет показано, как это сделать.

Добавим атрибуты валидации к SlackApiSettings и настроим валидацию в приложении:

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<SlackApiSettings>()
.BindConfiguration("SlackApi") // 👈 Связать секцию SlackApi
.ValidateDataAnnotations() // 👈 Включить валидацию
.ValidateOnStart(); // 👈 Валидировать при старте
// Явно зарегистрируем объект конфигурации,
// делегировав его объекту IOptions
builder.Services.AddSingleton(resolver =>
resolver.GetRequiredService<IOptions<SlackApiSettings>>().Value);
var app = builder.Build();
app.MapGet("/", (SlackApiSettings options) => options);
app.Run();
public class SlackApiSettings
{
[Required, Url]
public string WebhookUrl { get; set; }
[Required]
public string DisplayName { get; set; }
public bool ShouldNotify { get; set; }
}

Обратите внимание, что здесь я использовал DataAnnotations, но можно использовать другие фреймворки для валидации [п/п: У автора есть статья про подключение FluentValidation к этому механизму, её перевод.]

Тестирование конфигурации на старте приложения

Мы можем проверить валидацию, использовав любой из примеров с ошибками выше. Например, если мы допустим опечатку в названии поля, то при запуске приложения до обработки любых запросов получим исключение:

Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:
DataAnnotation validation failed for 'SlackApiSettings' members:
'DisplayName' with the error: 'The DisplayName field is required.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.OptionsMonitor`1.<>c__DisplayClass10_0.<Get>b__0()

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

Вывод

Система конфигурации в ASP.NET Core очень гибкая и позволяет использовать строгую типизацию. Кроме того, из-за этой гибкости, некоторые ошибки могут возникать только в определённых окружениях. По умолчанию эти ошибки будут появляться только при попытке получить доступ к объекту конфигурации. В этом посте я показал как использовать ValidateOnStart() метод, появившийся в .NET 6 для того, чтобы проверять конфигурацию на старте приложения. Это позволит как можно раньше убедиться в том, что приложение получило правильную конфигурацию.