Подключение валидации к строго типизированной конфигурации в .NET 6+
Эта статья — перевод статьи Эндрю Лока.
Строго типизированная конфигурация в 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>:
Факт получения ошибки не очень хороший, но хотя бы парсер вообще кидает исключение, которое чётко даёт понять в чём проблема! Я слишком много раз попадал в ситуации, когда вызовы к внешнему 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 для того, чтобы проверять конфигурацию на старте приложения. Это позволит как можно раньше убедиться в том, что приложение получило правильную конфигурацию.