Подключение валидации к строго типизированной конфигурации в .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 для того, чтобы проверять конфигурацию на старте приложения. Это позволит как можно раньше убедиться в том, что приложение получило правильную конфигурацию.