Привет, меня зовут Андрей Рягузов, в 2ГИС я разрабатываю внутренние продукты для актуализации справочных данных на .NET.
Несколько лет назад для работы с настройками мы в команде пользовались самописным методом. Пока приложения были простыми, нас всё устраивало, однако при масштабировании начали вылезать неприятности. Так мы начали искать альтернативу.
Расскажу:
почему изначально решились на нестандартный метод;
что за сложности возникли и что мы хотели улучшить;
какие решения рассматривали;
и какие преимущества получили от «коробочных» инструментов.
Как мы конфигурируем .Net Framework и .Net-приложения
У нас два .Net Framework-приложения, которые конфигурируются похожим образом. Мы используем два источника: App.config-файл и базу данных. Оба эти источника закрыты нашим самописным сервисом конфигурации, который загружает значения в память, формирует из них объекты конфигурации и выдаёт сервисам-потребителям.
Расположение конкретной конфигурации зависит от её роли и изменяемости. Так, например, в App.config-файле хранятся статические значения, которые не меняются во время работы приложения. Обычно эти значения отвечают за контур, в котором работает приложение:
путь до базы;
адреса веб-серверов;
настройки логирования.
В базе же мы храним значения, которые не влияют на работу контура и валидные для любого из них:
Сron-джобы;
переменные;
фичи-флаги.
Когда мы писали нашу новую систему на .Net Core, то в вопросе конфигурации начали с определения источника данных и способа доступа к нему. Базу данных мы решили не рассматривать, потому что она бы хранила только конфиги и другие бы задачи не решала. Такой вариант показался расточительством ресурсов.
Оставались два других варианта.
Использовать appsettings.json: шаблонизировать и использовать его по аналогии с тем, как мы делали во фреймворке.
Использовать переменные окружения.
Решили использовать переменные окружения: так передавать значения проще, чем настраивать шаблонизацию. Для управления настройками в новом приложении мы создали класс с соответствующими свойствами и инициализировали их прямо в конструкторе с помощью метода Environment.GetEnvironmentVariable:
public class ServiceConfigs
{
public string Host { get; }
public string User { get; }
public string Token { get; }
public ServiceConfigs()
{
Host = Environment.GetEnvironmentVariable(@"HOST");
User = Environment.GetEnvironmentVariable("USER");
Token = Environment.GetEnvironmentVariable("TOKEN");
}
}
Получившийся подход напоминал работу с конфигурацией в «старых» приложениях. Мы использовали один источник, обернули его не сервисом, а простым объектом, и передали эту обёртку в наши сервисы Нам показалось это удобным, и мы продолжили тиражировать этот метод на другие приложения.
Первый сервис, на котором мы использовали такой подход, был простым. По сути это было прокси с дополнительной логикой. Уже следующие приложения были сложнее, и их логику мы хотели покрывать тестами.
Во время написания одного из Unit-тестов возник вопрос: как управлять конфигурацией без доступа к ней?
[Test]
public void DoWork_ShouldReturnTrue_ThenWorkIsValid()
{
var config = new ServiceConfigs();
var service = new Service(config);
// Act
// Assert
}
Зная, как конфигурации устроены внутри, это просто. Перед созданием объекта конфигурации достаточно установить нужное значение в переменную окружения.
[Test]
public void DoWork_ShouldReturnTrue_ThenWorkIsValid()
{
Environment.SetEnvironmentVariable("PERIOD", "10");
var config = new ServiceConfigs();
var service = new Service(config);
// Act
// Assert
}
Без сомнений, это сработает. Но если несколько тестов устанавливают одну переменную параллельно, это уже вызывает конфликты.
Теперь представим, что мы не знаем, что значения конфигурации берутся из ENV. В такой ситуации неясно, почему в тесте нужно устанавливать переменные окружения и как тест связан с установленной переменной. Непонятно, почему используется именно эта переменная и почему вызов происходит до создания конфигурации. Это намекает на связь между ними, но ничего не гарантирует.
Стало очевидно, что метод, который мы планировали тиражировать, нам не подходит. На одной из регулярных встреч, где мы обсуждаем текущие задачи, обмениваемся знаниями и опытом, задались вопросом: «Почему мы используем именно этот метод конфигурации?» В ходе обсуждения выявили несколько постоянных проблем и сложностей этого подхода, а также возможные улучшения.
Ручной маппинг. Каждое новое поле конфигурации мы инициализируем самостоятельно:
if (int.TryParse(Environment.GetEnvironmentVariable(@"MAX_AGE_DAYS"), out var maxDays))
{
MaxAgeDays = maxDays;
}
if (int.TryParse(Environment.GetEnvironmentVariable(@"DELAY_MINUTES"), out var delayValue))
{
DelayMinutes = delayValue;
}
// и еще очень много подобного кода
В примере только два поля, а на самом деле их может быть очень много. И когда много однотипного кода, то его хочется автоматизировать.
Нет структуры. Сначала мы писали всё в одном файле, но со временем он разросся до неприличных размером и взаимодействовать с ним стало крайне сложно. Связь между полями стала неочевидной, определить её можно было только по названиям.
DatabaseOne = Environment.GetEnvironmentVariable("DATABASE_ONE");
DatabaseTwo = Environment.GetEnvironmentVariable("DATABASE_TWO");
DatabaseFour = Environment.GetEnvironmentVariable("DATABASE_FOUR");
OneApiUrl = Environment.GetEnvironmentVariable(@"ONE_API_URL");
TwoApiUrl = Environment.GetEnvironmentVariable(@"TWO_API_URL");
AdfsHost = Environment.GetEnvironmentVariable(@"ADFS_HOST");
AdfsTenantId = Environment.GetEnvironmentVariable(@"ADFS_TENANT_ID");
AdfsResource = Environment.GetEnvironmentVariable(@"ADFS_RESOURCE");
AdfsAppId = Environment.GetEnvironmentVariable(@"ADFS_APP_ID");
AdfsAppKey = Environment.GetEnvironmentVariable(@"ADFS_APP_KEY");
ServiceApiUrl = Environment.GetEnvironmentVariable(@"SERVICE_API_URL");
// и еще
// и еще
// и еще...
Отсутствие валидации. Хотя у нас не возникало проблем с этим раньше, при решении вопросов с конфигурацией мы решили подстраховаться и предусмотреть возможные ошибки.
Коммиты в launchSettings. При локальной разработке мы используем файл launchSettings, в который вносим свои значения. Поскольку этот файл – часть проекта, то он попадает в репозиторий и становится доступным всем разработчикам. Если кто-то не намеренно делает коммит, изменения получают все. За последний год в одном проекте это произошло шесть раз. Кажется немного, но когда проектов больше трёх, статистика становится неприятной.
Ограниченный набор источников значений. Сейчас с этим нет проблем, но в будущем может возникнуть необходимость расширить их, аналогично тому, как приложения на .NET Framework уже конфигурируются через базу данных и файлы.
В итоге мы провели ресерч и начали искать альтернативы.
Ищем решения
Рассматривали три варианта:
Написать всё самим. Это самый привлекательный и интересный вариант. Мы сможем адаптировать это решение под наши цели, задачи и нужды. Однако это может потребовать значительных временных затрат. Кроме того, при необходимости добавления новых конфигураций, снова придётся выделять ресурсы на их реализацию.
Использовать подход от Microsoft. Во время ресерча наткнулись на цикл статей Эндрю Лока, в которых он доступно объясняет возможности этого подхода. Нам понравилось, и мы решили взять его на заметку. В его книге ASP.NET Core рассматриваются не только конфигурации, но и множество других аспектов веб-разработки.
Использовать сторонние библиотеки. Мы обнаружили ещё один подходящий вариант — библиотеку ConfigNet. Но её идеи уже реализованы в библиотеке от Microsoft, поэтому мы остановились на втором подходе.
Внедряем подход от Microsoft
Всё взаимодействие с конфигурацией предлагается вести с помощью интерфейса iConfiguration. Это абстракция над разными источниками данных, такими как файлы, переменные окружения, аргументы командной строки.
При создании стандартного приложения по шаблону WebAPI предоставляются три провайдера конфигурации:
MemoryConfigurationProvider,
EnvironmentVariableConfigurationProvider,
и JSONConfigurationProvider.
Каждый из этих источников содержит свой набор пар «ключ-значение». Их можно представить как слои, которые объединяются в единую конфигурацию. Если ключи в разных слоях совпадают, то используется значение из последнего добавленного источника.
Например, у нас есть приложение с двумя источниками: файл конфигурации с ключом VeryImportantSettings и переменные окружения. Для удобства я пометил ключ префиксом My, чуть позже станет понятно зачем.
appsettings.json
{
"VeryImportantSetting": "This is a very important setting!"
}
ENV
"environmentVariables": {
"MY_VeryImportantSetting": "appsettings lie!"
}
Оба источника содержат один и тот же ключ, но разные значения. Мы добавим их в наше приложение.
Сначала добавим файл:
builder.Configuration.AddJsonFile("appsettings.json");
Затем переменные окружения с нашим префиксом:
builder.Configuration.AddEnvironmentVariables("MY_");
Чтобы проверить результат, добавим endpoint, который будет возвращать итоговую конфигурацию. Для удобства можно использовать метод расширения GetDebugView, который выводит готовую конфигурацию в читаемом формате.
app.MapGet("/config", (IConfiguration configuration) =>
{
var config = (configuration as IConfigurationRoot).GetDebugView();
return config;
});
В нашем случае выкладка будет выглядеть так:
VeryImportantSetting=appsettings lie! (EnvironmentVariablesConfigurationProvider Prefix: 'MY_')
Она состоит из ключа, значения и поставщика, из которого эта пара получилась. Помимо названия прописывается префикс, к которому привязан этот провайдер.
Теперь изменим порядок. Сначала добавим переменные окружения, затем файл.
builder.Configuration.AddEnvironmentVariables("MY_");
builder.Configuration.AddJsonFile("appsettings.json");
builder.Configuration.GetDebugView();
VeryImportantSetting=This is a very important setting! (JsonConfigurationProvider for 'appsettings.json' (Required))
Тот же ключ, но теперь с другим значением, которое будет получено из файла. Также указаны опции, с которыми был добавлен этот файл, аналогично тому, как это было сделано с переменными окружения.
Secret Manager
Ещё один интересный источник конфигурации — Secret Manager. Он предназначен для управления секретами, такими как API-ключи, строки подключения к базам данных или другая конфиденциальная информация, используемая в разработке.
Это JSON-файл, аналогичный AppSettings, хранится локально на компьютере разработчика и не попадает в репозиторий. Чтобы начать использовать секреты, нужно выполнить команду dotnet user-secret init. Он добавляется последним, поэтому его значения приоритетны и переопределяют все предыдущие. После добавления появится новое свойство UserSecrestId — название папки на компьютере, где лежат секреты.
dotnet user-secrets init
<PropertyGroup>
<UserSecretsId>1de4633d-d945-4b73-8f0f-d72973fb04f4</UserSecretsId>
</PropertyGroup>
До добавления первого секрета директория ещё не создаётся, поэтому можно безопасно присвоить ей осмысленное имя.
При совместной разработке повторно инициализировать секреты не требуется:
dotnet user-secrets init
<PropertyGroup>
<UserSecretsId>MyServiceSecretFolder</UserSecretsId>
</PropertyGroup>
Можно сразу приступать к добавлению значений. Это можно сделать при помощи команды dotnet user-secret set.
dotnet user-secrets set "SuperSecretValue" "12345"
Иерархические ключи добавляются с помощью разделителя-двоеточия.
dotnet user-secrets set "Article:Title" "Awesome story"
dotnet user-secrets set "Article:Author" "Joe"
В файле получится следующее:
{
"Article": {
"Title": "Awesome story",
"Author": "Joe"
}
}
Для удобства работы с этим файлом можно использовать IDE: Rider и Visual Studio умеют находить связанные с проектом секреты и открывать их на редактирование.
Кастомные источники конфигураций
Если же ни один из стандартных источников не подходит, можно написать свой.
Для этого надо отнаследоваться от класса ConfigurationProvider, переопределить метод Load(), в нем получить данные, сложить их в поле Data.
internal sealed class CustomConfigProvider : ConfigurationProvider
{
public override void Load()
{
// получите данные
// сложите их в поле Data
// вы прекрасны!
}
}
Например, часть конфигурации хранится в базе данных, аналогично приложениям на .NetFramework. Для доступа к ней мы реализуем свой провайдер: наследуемся от configuration-провайдера, передаём туда источник (фабрику репозитория в методе Load), получаем значения и сохраняем их в поле Data.
public class DatabaseConfigurationProvider : ConfigurationProvider
{
private readonly IRepositoryFactory _repositoryFactory;
public DatabaseConfigurationProvider(IRepositoryFactory repositoryFactory)
{
_repositoryFactory = repositoryFactory;
}
public override void Load()
{
using var repository = _repositoryFactory.Create();
Data = repository
.GetTable<JobConfiguration>()
.ToDictionary(v => v.Key, k => k.Value);
}
}
Хотя загрузка конфигурации часто связана с операциями ввода-вывода, у метода Load нет асинхронной альтернативы. В репозитории dotnet’a есть обсуждение этой проблемы, где Microsoft честно признают, что это недоработка, но, к сожалению, они не могут её исправить из-за слишком широкого распространения текущего API.
Итак, чтобы добавить провайдер в приложение, нужен источник, который его создаст. Для этого мы реализуем интерфейс IConfigurationSource и метод Build, который возвращает экземпляр нашего провайдера.
public class DatabaseConfigurationSource : IConfigurationSource
{
private readonly IRepositoryFactory _repositoryFactory;
public DatabaseConfigurationSource(IRepositoryFactory repositoryFactory)
{
_repositoryFactory = repositoryFactory;
}
public IConfigurationProvider Build()
{
return new DatabaseConfigurationProvider(_repositoryFactory);
}
}
Затем нужно добавить этот источник в билдер конфигурации, например, обернув его в метод расширения.
public static class ConfigurationBuilderExtensions
{
public static IConfigurationBuilder AddDatabaseConfiguration(
this IConfigurationBuilder builder)
{
var tempConfig = builder.Build();
var connectionString = tempConfig.GetConnectionString("Database");
var repositoryFactory = new RepositoryFactory(connectionString);
return builder.Add(new DatabaseConfigurationSource(repositoryFactory));
}
}
На этапе построения приложения уже нужны конфигурации. Для этого можно создать промежуточную конфигурацию, получить нужные значения и добавить новый источник.
Применяем решение
Теперь с помощью интерфейса IConfiguration, заменив статический класс Environment на внедрение интерфейса IConfiguration, можно перейти от этого:
public ServiceConfigs()
{
Host = Environment.GetEnvironmentVariable(@"HOST");
}
К этому:
public ServiceConfigs(IConfiguration configuration)
{
Host = configuration.GetValue<string>("Host");
}
Это позволит нам получать значения не только из переменных окружения, но и вообще из любого доступного источника.
С помощью этой простой инъекции интерфейса мы решили следующие проблемы:
Теперь мы не ограничены одним источником данных. Благодаря различным источникам, включая Secret Manager,
Мы больше не нуждаемся в использовании Launch Settings для локальной разработки. Мы можем переопределять эти значения в секретах, что делает нашу работу более гибкой и удобной.
Дальше мы хотели решить проблему с привязкой значений к полям объекта. Для этого:
Разделили параметры на группы;
Сформировали из них секции;
Завели РОСО-классы.
Теперь можно получить экземпляр конфигурации, связывая созданную секцию с конкретным классом. Конечно, здорово… Но большинство наших сервисов создаются через DI-контейнер. Окей, можно зарегистрировать полученный объект в DI-контейнере и получить его в конструкторе сервиса:
var options = builder.Configuration
.GetSection("Article")
.Get<ArticleOptions>();
services.AddSingleton(options);
Тоже рабочее решение, но ощущается немного громоздко. Microsoft предлагает другой подход — использовать Options Pattern, стандартный способ для добавления строго типизированной конфигурации в .Net-приложениях.
Основные этапы использования Options Pattern↓
Создание класса опций. Мы уже сделали это на предыдущем шаге. Здесь, в качестве примера, два свойства для хранения значений и константа с именем «section», чтобы легче было ее найти в списке конфигурации.
public sealed class ArticleOptions
{
public const string Section = "Article";
public string Title { get; set; }
public string Author { get; set; }
}
Конфигурация опций. Настройка и регистрация в контейнере зависимостей может быть выполнена с помощью метода расширения AddOptions()
который связывает определенную секцию конфигурации с классом опций.
services
.AddOptions<ArticleOptions>()
.Bind(configuration.GetSection(ArticleOptions.Section));
И последний этап инъекция зависимостей. Для этого можно использовать три интерфейса обёртки IOptions <TOptions>, IOptionsSnapshot <TOptions> и IOptionsMonitor <TOptions>.
IOptions <TOptions> – самая простая и распространенная обёртка. Предоставляет только одно свойство value, которое предоставляет объект конфигурации. Используется для получения статических значений опций и регистрируется в DI-контейнере как singleton.
В коде это может выглядеть примерно так:
public class MyService
{
private readonly ArticleOptions _options;
public MyService(IOptions<ArticleOptions> options)
{
_options = options.Value;
}
}
Можно получить обертку, достать из нее значение, сохранить в своём классе и дальше как угодно использовать.
Следующий интерфейс — это IOptionsSnapshot <TOptions>. Он уже регистрируется как scoped, умеет работать с перезагружаемыми конфигурациями, представляет возможность получать моментальные снимки (Snapshot) значений опций и поддерживает именованные параметры.
public class MyService
{
private readonly ArticleOptions _options;
public MyService(IOptionsSnapshot<ArticleOptions> options)
{
_options = options.Value;
}
}
На самом деле, в коде его использование не сильно отличается от Options. Но где же именновые параметры?
Рассмотрим синхронизаторы importer и exporter.
public class Importer
{
public const string BatchSize = 10;
...
public void Import()
{
var entities = _provider.Get(BatchSize);
...
}
...
}
И тот, и другой синхронизаторы могут отправлять и читать значения пачками, устанавливая размер пачки с помощью константы. Но ведь речь идёт о конфигурациях. Сконфигурируем размер пачки, используя Options Pattern. Для этого создадим класс опций с одним свойством, указывающим размер пачки.
public class SyncOptions
{
public int BatchSize { get; set; }
}
Добавим две секции для импортера и экспортера, в которых укажем размер этого самого бача.
{
"Import": {
"BatchSize": "10"
},
"Export": {
"BatchSize": "20"
}
}
Мы создали классы и добавили значения в конфигурацию. Дальше нужно связать конфигурации с нужными секциями:
services
.AddOptions<SyncOptions>()
.Bind(configuration.GetSection("Import"));
services
.AddOptions<SyncOptions>()
.Bind(configuration.GetSection("Export"));
При добавлении опций одного типа сначала с одной секцией, а затем с другой, мы перезапишем первую секцию второй. Например, если мы сначала добавим опции для импорта, а затем для экспорта, значения для импорта будут перезаписаны значениями для экспорта. И именно использование именованных параметров поможет нам решить эту проблему.
Метод АddOptions имеет перегрузку, которая позволяет указать имя настраиваемой опции. Можно настроить опции для импортера и назвать их ImportOptions. Затем связать секцию с этими опциями и проделать то же самое для экспортера.
services
.AddOptions<SyncOptions>("ImportOptions")
.Bind(configuration.GetSection("Import"));
services
.AddOptions<SyncOptions>("ExportOptions")
.Bind(configuration.GetSection("Export"));
Теперь, чтобы получить необходимый набор параметров для импортера, мы используем метод Get и указываем название опции, которую мы зарегистрировали ранее и хотим получить.
public class Importer
{
private readonly int _batchSize;
...
public Importer(IOptionsSnapshot<SyncOptions> options, ...)
{
_batchSize = options.Get("ImportOptions").BatchSize;
...
}
}
Третий интерфейс, IOptionsMonitor <TOptions> — гибрид первых двух. Он регистрируется как singleton (как и IOptions), и ещё как IOptionsSnapshot поддерживает работу с перезагружаемыми конфигурациями, однако делает это не за счёт снапшотов, а предоставляя механизм отслеживания изменений значений. Также поддерживает именованные параметры.
В коде его использование отличается от предыдущих двух интерфейсов. Вместо свойства Value используется СurrentValue. Для доступа к именованным параметрам также используется метод Get, аналогично Snapshots. Кроме того, этот интерфейс предоставляет метод OnChange, который вызывается при изменении значения конфигурации. При использовании этого метода возвращается объект, реализующий интерфейс IDisposable, поэтому необходимо корректно обрабатывать его в классе.
public class MyService : IDisposable
{
private ArticleOptions _options;
private readonly IDisposable? _handler;
public MyService(IOptionsMonitor<ArticleOptions> optionsMonitor)
{
_options = optionsMonitor.CurrentValue;
_options = optionsMonitor.Get("OptionsMonitor");
_handler = optionsMonitor.OnChange(newOptions => _options = newOptions);
}
public void Dispose()
{
_handler?.Dispose();
}
}
Для отслеживания изменений можно сделать объект опции членом класса. Тогда при обращении к значению через CurrentValue или Get, будет возвращаться обновленное значение, поскольку при перезагрузки конфигурации новые объект заменит старый.
public class MyService
{
private readonly IOptionsMonitor<ArticleOptions> _optionsMonitor;
public MyService(IOptionsMonitor<ArticleOptions> optionsMonitor)
{
_optionsMonitor = optionsMonitor;
}
public string GetAuthor()
{
return _optionsMonitor.CurrentValue.Author;
}
}
Выбор между этими тремя интерфейсами зависит от сценария использования опций:
IOptions, если значения статические.
IOptionsSnapshot, если требуются значения, актуальные в определённый момент.
IOptionsMonitor, если необходимо реагировать на изменения значений.
Так с помощью Options паттерна мы:
Мы убрали ручной маппинг. Теперь секции автоматически связываются с объектами конфигурации.
Структурировали конфиги, разделив их на те самые секции.
Из нерешённых проблем осталась валидация. Существует несколько способов проверить значения конфигурации. Самый простой из них — использовать атрибуты. Всего доступно 13 атрибутов, и если этого недостаточно, то можно создать собственный.
public sealed class ArticleOptions
{
public const string Section = "Article";
[MaxLength(200)]
public string Title { get; set; }
[Required]
public string Author { get; set; }
}
Чтобы валидация с использованием атрибутов работала, при настройке значений нужно добавить вызов метода ValidateDataAnnotation.
services
.AddOptions<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section))
.ValidateDataAnnotations();
Для более сложной валидации можно реализовать интерфейс IValidationOptions и зарегистрировать его в DI-контейнере. Тогда при создании сервиса он будет вызываться автоматически
public interface IValidateOptions<TOptions> where TOptions : class
{
ValidateOptionsResult Validate(string? name, TOptions options);
}
services.AddSingleton<IValidateOptions<ArticleOptions>, ArticleValidation>();
Также возможно использовать сторонние библиотеки для валидации, например, Fluent Validator. Для этого нужно наследовать AbstractValidator и в методе Validate интерфейса IValidateOptions вызвать валидацию из базового класса.
public class ArticleOptionsValidator : AbstractValidator<ArticleOptions>,
IValidateOptions<ArticleOptions>
{
public ValidateOptionsResult Validate(string? name, ArticleOptions options)
{
var result = Validate(options);
...
}
}
У Эндрю Лока есть статья на эту тему. Если вкратце, он предлагает использовать единственную реализацию IValidationOption, передавая ей сервис-провайдер. Из этого провайдера, в зависимости от типа, извлекается нужный валидатор, и у него вызывается валидация.
public class FluentValidationOptions<TOptions>
: IValidateOptions<TOptions> where TOptions : class
{
...
var validator = scope.ServiceProvider
.GetRequiredService<IValidator<TOptions>>();
var results = validator.Validate(options);
...
}
Для добавления такой валидации можно написать метод расширения, который будет регистрировать валидатор на каждый заведённый тип. В коде он будет аналогичен ValidateDataAnnotation.
public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(
this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
{
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
provider => new FluentValidationOptions<TOptions>(
optionsBuilder.Name, provider));
return optionsBuilder;
}
services
.AddOptions<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section))
.ValidateFluentValidation();
Также валидацию можно настроить с помощью метода Validate, передавая в него лямбду с условиями срабатывания и текстом ошибки.
services
.AddOptions<ArticleOptions>()
.Bind(configuration.GetSection(ArticleOptions.Section))
.Validate(config => config.Title is not null, "Ошибочка :(");
Все три типа выполняют валидацию только при получении опций из контейнера. То есть, если загружена неверная конфигурация для сервиса, который используется раз в неделю, ошибка станет очевидной лишь через неделю. Поэтому чтобы немедленно обнаружить проблему, можно принудительно запускать валидацию на этапе сборки хоста, вызвав метод ValidateOnStart() для каждого конфигурируемого Option'а. В .NET 8 появился новый extension-метод — AddOptionsWithValidateOnStart. Он создаёт OptionsBuilder и тут же вызывает для него ValidateOnStart.
services
.AddOptions<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section))
.ValidateOnStart();
services
.AddOptionsWithValidateOnStart<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section));
Мы решили все обозначенные проблемы, но расскажу про еще одну интересную фишку – пост-конфигурацию.
Её можно настроить с помощью метода PostConfigure, передав туда лямбду, которая выполнится после всех настроек опций: валидации, привязки и другого.
services
.AddOptions<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section))
.PostConfigure(options =>
{
if (options.Author.EndsWith(" "))
{
options.Author = options.Author.TrimEnd();
}
});
Этот метод также имеет перегрузку, которая позволяет передать до пяти зависимостей. Например, можно вынести нормализацию в отдельный класс, добавить туда логику и вызвать только метод Normalizer в лямбде.
services
.AddOptions<ArticleOptions>()
.Bind(builder.Configuration.GetSection(ArticleOptions.Section))
.PostConfigure<Normalizer>((options, normalizer) =>
{
options.Author = normalizer.Normalize(options.Author);
});
При работе с именованными параметрами можно избежать написания отдельной постконфигурации для каждого из них, используя метод PostConfigureAll. Он выполнит настройку для всех опций заданного типа.
services
.PostConfigureAll<SyncOptions>(options =>
{
if (options.BatchSize < 15)
{
options.BatchSize = 15;
}
});
Если лямбды не кажутся удобными или настройка выглядит слишком сложной, можно реализовать интерфейс IPostConfigureOptions. В этом случае нужно заинжектить необходимые зависимости, написать вспомогательные методы, а затем зарегистрировать их в DI-контейнере.
public interface IPostConfigureOptions<in TOptions> where TOptions : class
{
void Validate(string? name, TOptions options);
}
services.AddSingleton<
IPostConfigureOptions<ArticleOptions>,
ArticleOptionsPostConfigure>();
Что получили
После внедрения нового метода, мы получили:
Структурированные классы опций.
Автоматический биндинг.
Options Pattern и возможность реагировать на изменение конфигурации, производить действия с этим.
Валидацию, которая позволяет обнаружить проблемы с конфигурацией ещё на этапе запуска приложения, а не в моменте, когда пользователь уже обратился с жалобами о проблемах.
Разные провайдеры. Теперь мы можем работать с разным набором источников конфигурации, меняя одно с другим. Это удобно как в бою, так и на этапе разработки.
И постфигурацию. Например, в наших приложениях мы используем её для того, чтобы добавлять в конце веб-адресов слэш.
Ну, а еще, мы исправили тест, который стал отправной точкой для всех проблем :). Теперь нет никаких магических переменных, нет необходимости устанавливать переменные окружения или еще как-то приседать. Мы честно создаём класс опций, устанавливаем в нём нужное значение, и теперь этот тест может выполняться параллельно или последовательно, независимо от окружения выполнения.
[Test]
public void DoWork_ShouldReturnTrue_ThenWorkIsValid()
{
var config = Options.Create(new ServiceConfigs
{
Period = 10
});
var config = new ServiceConfigs();
var service = new Service(config);
// Act
// Assert
}
Итого
Это не велосипед. С одной стороны, собственные решения имеют свои преимущества: можно контролировать свой код, обеспечивать стабильность и неизменяемость API, и можно настроить всё под себя. Но стоит помнить, что при этом тратится время на разработку инструментов, вместо того чтобы фокусироваться на бизнес-задачах и их решении.
Есть поддержка и дальнейшее развитие. У готовых инструментов есть активное сообщество разработчиков, которые обеспечивают их поддержку. Если возникают проблемы, то вероятно, они уже были решены. Найти ответы на вопросы можно будет на различных платформах, таких как StackOverflow, GitLab и других
Переход занимает буквально один день. Все бенефиты мы получили минимальной ценой.
Вот и всё. Если у вас остались вопросы, буду рад ответить в комментариях. Наша команда писала ещё несколько материалов: вот тут и тут. Захочешь работать с нами – откликайся на вакансии, будем рады новым крутым ребятам!
Ещё информации про RnD в 2ГИС — в нашем Телеграм-канале.
Комментарии (8)
posledam
08.08.2024 14:44Как ни прискорбно, но о базовых фичах .NET знают не многие. И городят огород.
cstrike
08.08.2024 14:44То есть у вас не было ни одного хотя бы мидл разработчика, который бы вам сказал, что вы все не правильно делаете?
enabokov
08.08.2024 14:44В одном продукте мы вынесли всю конфигурацию в Azure Blob, где храним json файлы в иерархии с возвратом по url. Этот url прописываем в appsettings проекта. Разные url для разных env и проектов. Написали провайдер и встроили в систему Configuration. Также написали UI где можно редактировать эти файлы. Основное преимущество - быстрая правка конфигурацияи без деплоймента. Если пойти дальше, можно на основе этого запилить feature flags, чтобы возвращалась конфигурация не только по url, но и по контексту.
Alexsey
Честно признаюсь - меня немного удивляет как .net разрабы умудрились проигнорировать фичу, которая в документации по ASP.NET находится в разделе fundamentals.
Proscrito
Тоже удивил такой восторг от коробочных фич. Даже завидую, сколь мало нужно людям для счастья. Мы совсем перестали радоваться простым вещам...
AgentFire
Да уж, я тут бесился, что из кастомного конфиг провайдера не то, что ServiceProvider, так даже другие, ранее по цепочке объявленные конфиг-провайдеры недоступны, а автор просто молча сделал лишний
.Build()
, и даже не упомянул об этом.