Строго типизированная конфигурация в ASP.NET Core
Система конфигурации в .NET очень гибкая. Она позволяет загружать параметры из разных мест: JSON файлы, YAML файлы, переменные окружения, Azure Key Vault и многое другое. В статье предлагается использовать конечный объект IConfiguration
в приложении чтобы настроить строгую типизацию.
Строго типизированная конфигурация с помощью простых объектов описывает часть вашей конфигурации вместо обычного хранения пар “ключ-значение” в IConfiguration
.
Допустим, вы настраиваете интеграцию со Slack, и для отправки сообщений в канал используете вебхуки. Вам понадобится URL вебхука, и какие-нибудь дополнительные параметры, например имя приложения, которое будет использоваться для отправки сообщений в канал:
Этот объект можно привязать к вашей конфигурации в Program.cs используя метод расширения Configure<T>()
. Когда вам понадобится этот объект в контроллере, вы можете внедрить зависимость IOptions<SlackApiSettings>
в его контроллер. Например, чтобы внедрить конфиги в Minimal API эндпоинт и вернуть JSON с ними можно сделать так:
Под капотом система конфигурации ASP.NET Core создаёт новый объект SlackApiConfiguration
и пытается сопоставить каждое свойство в объекте со значениями в секции IConfiguration
.
Чтобы получить объект конфигурации, обратитесь к IOptions<T>.Value
, как показано в обработчике эндпоинта.
Избегание зависимости IOptions
Некоторым людям (мне в том числе) не нравится зависимость эндпоинтов от IOptions
вместо объекта конфигурации напрямую. Вы можете избежать зависимости от IOptions<T>
сопоставив объект конфигурации вручную, как описано здесь, вместо использования метода расширения Configure<T>
. Более простой подход (по моему мнению) это явно зарегистрировать объект SlackApiSettings
в приложении и делегировать его определение на объект IOptions
. Например:
Теперь в контроллеры можно внедрять “сырой” объект настроек, без зависимости от пакета Microsoft.Extensions.Options
. Я думаю, что это более предпочтительный способ, потому что в этом случае интерфейс IOptions<T>
не нужен.
Обычно это работает хорошо, хотя тут есть пара нюансов:
- В примере выше не будет работать “перезагрузка файла” для конфигурации, так как я использовал Singleton (можно использовать Scoped, если вам нужна эта функция)
- При регистрации IOption появляется дополнительный уровень косвенности, вместо регистрации объекта SlackApiSettings напрямую в механизме внедрения зависимостей. Лично мне нравится такой подход, но вы можете использовать IOptions. Есть еще один подход, описанный в этом посте.
Наличие отличной поддержки загрузки конфигурации из разных источников это хорошо, но что будет, если вы ошибётесь в конфигурации, например допустите опечатку в JSON файле?
Чаще всего я сталкивался с проблемой, возникающей из-за того, что секреты необходимо хранить вне системы контроля версий. В таком случае я ожидаю, что секреты будут доступны на продакшн-сервере, но если они не были корректно настроены, в приложении конфигурация получит значения типа “по умолчанию”. Ошибки конфигурации сложно отловить, ведь их можно воспроизвести только на сервере.
Что случится, если сопоставление проваливается?
Есть несколько случаев, когда что-то может пойти не так при сопоставлении строго типизированных объектов с конфигурацией. Я покажу несколько примеров ошибок в JSON конфигурации, используя пример обработчика, написанный выше.
Опечатка в названии секции
При сопоставлении конфигурации вы указываете имя секции, откуда брать значения. Если думать в терминах файла appsettings.json, секция — это название ключа объекта в JSON. "Logging"
и "SlackApi"
это секции в приведённом ниже .json файле:
Чтобы связать SlackApiSettings
с секцией "SlackApi"
, можно сделать:
Но что если в названии секции будет допущена опечатка? Например вместо SlackApi
укажем SlackApiSettings
:
Вызов эндпоинта даст:
Все ключи получили значение по умолчанию, но никаких ошибок не произошло. Сопоставление произошло, но с пустой секцией конфигурации. Наверное, это плохо, потому что ваш код ожидает, что в webhookUrl будет валидный Uri.
Примечание переводчика: Вообще, чтобы решить эту проблему можно вместо Configuration.GetSection использовать Configuration.GetRequiredSection. Тогда при попытке сопоставить объект с несуществующей секцией возникнет исключение.
Опечатка в названии свойства
Что произойдёт, если название секции верно, но неверно название свойства?
Например, что если WebhookUrl
будет записан в файле как Url
?
Посмотрим на результат:
Так как название секции правильное, DisplayName
и ShouldNotify
попали в объект конфигурации правильно. Но WebhookUrl
содержит null, так как в конфигурации нет такого поля (Url
вместо него). И снова никаких сообщений о том, что поле не обработалось корректно.
Несвязываемые поля
Эта ошибка встречается не слишком часто, но о ней всё же стоит знать. Если вы используете в объекте конфигурации поля без сеттера, они не свяжутся. Например, если мы изменим объект следующим образом:
и снова посмотрим на ответ эндпоинта, мы получим объект со значениями по умолчанию так как парсер не сможет установить значение в объект:
Несовместимые типы данных
И последняя ошибка в этом посте происходит, когда парсер пытается связать поля с несовместимыми типами данных. В конфигурации всё представлено в виде строк, но парсер может преобразовывать простые типы. Например "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 и настроим валидацию в приложении:
Обратите внимание, что здесь я использовал DataAnnotations, но можно использовать другие фреймворки для валидации [п/п: У автора есть статья про подключение FluentValidation к этому механизму, её перевод.]
Тестирование конфигурации на старте приложения
Мы можем проверить валидацию, использовав любой из примеров с ошибками выше. Например, если мы допустим опечатку в названии поля, то при запуске приложения до обработки любых запросов получим исключение:
Теперь, если в конфиге встретится ошибка, вы узнаете об этом сразу, не дожидаясь того, что приложение упадёт в рантайме. Оно просто не запустится, а если вы используете окружение вроде Kubernetes, проверки состояния не пройдут и на боевом сервере останется рабочая версия, пока вы не почините ошибки конфигурации.
Вывод
Система конфигурации в ASP.NET Core очень гибкая и позволяет использовать строгую типизацию. Кроме того, из-за этой гибкости, некоторые ошибки могут возникать только в определённых окружениях. По умолчанию эти ошибки будут появляться только при попытке получить доступ к объекту конфигурации.
В этом посте я показал как использовать ValidateOnStart()
метод, появившийся в .NET 6 для того, чтобы проверять конфигурацию на старте приложения. Это позволит как можно раньше убедиться в том, что приложение получило правильную конфигурацию.