Каждый программист представлял — ну или может хочет представить — себя пилотом самолета, когда у тебя есть огромный проект, к нему огромная панель датчиков, метрик и переключателей, с помощью которых можно легко настроить всё как надо. Ну, по крайней мере, не бежать самому поднимать шасси вручную. И метрики, и графики — это всё хорошо, но сегодня хочу рассказать про те самые тумблерочки и кнопочки, которые могут менять параметры поведения самолета, конфигурировать его.

Важность конфигураций сложно недооценить. Все используют те или иные подходы в конфигурировании своих приложений, и в принципе ничего сложного в этом нет, но так ли всё просто? Предлагаю посмотреть на «до» и «после» в конфигурации и разобраться в деталях: как что работает, какие новые возможности у нас есть и как ими пользоваться в полной мере. Кто не знаком с конфигурированием в .NET Core — получат основы, а кто знаком — получат пищу для обдумывания и использования новых подходов в своей ежедневной работе.

Конфигурация до .NET Core


В 2002 году был представлен .NET Framework, и так как это было время хайпа XML, разработчики из Microsoft решили «а давайте он будет везде», и как результат — мы получили XML-конфигурации, которые живы и сейчас. Во главе стола у нас статический класс ConfigurationManager, через который мы получаем строковые представления значений параметров. Сама же конфигурация выглядела примерно вот так:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <appSettings>
    <add key="Title" value=".NET Configuration evo" />
    <add key="MaxPage" value="10" />
  </appSettings>
</configuration>

Задача была решена, разработчики получили вариант настройки, при этом лучше, чем INI файлы, но со своими особенностями. Так, для примера, поддержка разных значений настроек для разных видов окружений приложения реализуется с помощью XSLT-трансформаций файла конфигурации. Мы можем определять свои XML-схемы для элементов и атрибутов, если хотим что-то более сложное в плане группировки данных. Пары ключ-значение имеют строго строковый тип, и если нам необходимо число либо дата, то «тут давай как-то сам»:

string title = ConfigurationManager.AppSettings["Title"];
int maxPage = int.Parse(ConfigurationManager.AppSettings["MaxPage"]);

В 2005 году добавили секции конфигураций, они позволяли группировать параметры, строить свои схемы, избежать конфликтов именования. Также представили *.settings файлы и специальный дизайнер для них.



Появилась возможность получать сгенерированный, строго типизированный класс, который представляет данные конфигурации. Дизайнер позволяет удобно редактировать значения, доступна сортировка по колонкам редактора. Получение данных происходит с помощью свойства Default сгенерированного класса, которое предоставляет Singleton объект конфигурации.

DateTime date = Properties.Settings.Default.CustomDate;
int displayItems = Properties.Settings.Default.MaxDisplayItems;
string name = Properties.Settings.Default.ApplicationName;

Также добавили области применения значений параметров конфигурации. Область User отвечает за данные пользователя, которые могут быть им изменены и сохранены во время исполнения программы. Сохранение происходит в отдельный файл по пути %AppData%\*Название приложения*. Область Application позволяет получать значения параметров без возможности переопределения пользователем.

Несмотря на благие намерения, всё в целом стало словно сложнее.

  • По факту это те же XML-файлы, которые стали быстрее разрастаться в объемах и, как следствие, их стало неудобно читать.
  • Конфигурация читается из XML-файла один раз, и для применения изменений данных конфигурации нам необходимо перезагружать приложение.
  • Классы, сгенерированные из *.settings-файлов, помечались модификатором sealed, поэтому нельзя было унаследовать этот класс. Кроме того, этот файл можно было изменить, но если происходит перегенерация — мы теряем всё, что писали сами.
  • Работа с данными только по схеме ключ-значение. Для получения структурного подхода в работе с конфигурациями нам необходимо дополнительно реализовывать это самостоятельно.
  • Источником данных может быть только файл, внешние провайдеры не поддерживаются.
  • Плюс, мы имеем человеческий фактор — приватные параметры попадают в систему контроля версий и становятся раскрытыми.

Все эти проблемы остаются в .NET Framework и по сей день.

Конфигурация в .NET Core


В .NET Core конфигурирование переосмыслили и создали всё с нуля, убрали статический класс ConfigurationManager и решили множество проблем, что были «до». Что же мы получили нового? Как и было ранее — этап формирования данных конфигурации и этап потребления этих данных, но с более гибким и расширенным жизненным циклом.

Настройка и наполнение данными конфигурации


Так, для этапа формирования данных мы можем использовать множество источников, не ограничивая себя только файлами. Настройка конфигурации происходит через IConfgurationBuilder — основу, в которую мы можем добавлять источники данных. Доступны NuGet пакеты для различных типов источников:
Формат Метод расширения для добавления в IConfigurationBuilder источника NuGet пакет
JSON AddJsonFile Microsoft.Extensions.Configuration.Json
XML AddXmlFile Microsoft.Extensions.Configuration.Xml
INI AddIniFile Microsoft.Extensions.Configuration.Ini
Аргументы командной строки AddCommandLine Microsoft.Extensions.Configuration.CommandLine
Переменные окружения AddEnvironmentVariables Microsoft.Extensions.Configuration.EnvironmentVariables
Пользователь- ские секреты AddUserSecrets Microsoft.Extensions.Configuration.UserSecrets
KeyPerFile AddKeyPerFile Microsoft.Extensions.Configuration.KeyPerFile
Azure KeyVault AddAzureKeyVault Microsoft.Extensions.Configuration.AzureKeyVault

Каждый источник добавляется как новый слой и переопределяет параметры по совпадающим ключам. Вот пример Program.cs, который идет по умолчанию в шаблоне ASP.NET Core приложения (версия 3.1).

public static IHostBuilder CreateHostBuilder(string[] args) => 
    Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => 
        { webBuilder.UseStartup<Startup>(); });

Основной фокус хочу обратить на CreateDefaultBuilder. Внутри метода мы увидим, как происходит первоначальная настройка источников.

public static IWebHostBuilder CreateDefaultBuilder(string[] args)
{
    var builder = new WebHostBuilder();

    ...

    builder.ConfigureAppConfiguration((hostingContext, config) =>
    {
        IHostingEnvironment env = hostingContext.HostingEnvironment;

        config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
              .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

        if (env.IsDevelopment())
        {
            Assembly appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
            if (appAssembly != null)
            {
                config.AddUserSecrets(appAssembly, optional: true);
            }
        }

        config.AddEnvironmentVariables();

        if (args != null)
        {
            config.AddCommandLine(args);
        }
    })
            
    ...

    return builder;
}

Так мы получаем, что базой для всей конфигурации будет appsettings.json файл; дальше, если имеется файл для конкретного окружения, то он будет иметь более высокий приоритет, и тем самым переопределит совпадающие значения базового. И так с каждым последующим источником. Порядок добавления влияет на финальное значение. Визуально всё выглядит вот так:



Если вы хотите использовать свой порядок, то его можно просто очистить и определить, как нужно вам.

Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); })
    .ConfigureAppConfiguration((context,
                                builder) =>
     {
         builder.Sources.Clear();
         
         //Свой порядок источников
     });

Каждый источник конфигурации состоит из двух частей:

  • Реализация IConfigurationSource. Предоставляет источник значений конфигурации.
  • Реализация IConfigurationProvider. Конвертирует исходные данные в результирующие ключ-значение.

Реализовав эти компоненты, мы можем получить собственный источник данных для конфигурации. Вот пример того, как можно реализовать получение параметров из базы данных через Entity Framework.

Как использовать и получать данные


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

{
  "Features" : {
    "Dashboard" : {
      "Title" : "Default dashboard",
      "EnableCurrencyRates" : true
    },
    "Monitoring" : {
      "EnableRPSLog" : false,
      "EnableStorageStatistic" : true,
      "StartTime": "09:00"
    }
  }
}

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

Features:Dashboard:Title Default dashboard
Features:Dashboard:EnableCurrencyRates true
Features:Monitoring:EnableRPSLog false
Features:Monitoring:EnableStorageStatistic true
Features:Monitoring:StartTime 09:00

Получить значение мы можем с помощью объекта IСonfiguration. Для примера, вот как мы можем получить параметры:

string title = Configuration["Features:Dashboard:Title"];
string title1 = Configuration.GetValue<string>("Features:Dashboard:Title");
bool currencyRates = Configuration.GetValue<bool>("Features:Dashboard:EnableCurrencyRates");
bool enableRPSLog = Configuration.GetValue<bool>("Features:Monitoring:EnableRPSLog");
bool enableStorageStatistic = Configuration.GetValue<bool>("Features:Monitoring:EnableStorageStatistic");
TimeSpan startTime = Configuration.GetValue<TimeSpan>("Features:Monitoring:StartTime");

И это уже неплохо, у нас есть хороший способ получить данные, которые приведены к необходимому типу данных, но как-то не так круто, как хотелось бы. Если получать данные, как приведено выше, то мы в итоге будем иметь повторяющийся код, допускать ошибки в названии ключей. Вместо отдельных значений можно собрать полноценный объект конфигурации. В этом нам поможет привязка данных к объекту через метод Bind. Пример класса и получения данных:

public class MonitoringConfig
{
    public bool EnableRPSLog { get; set; }
    public bool EnableStorageStatistic { get; set; }
    public TimeSpan StartTime { get; set; }
}

var monitorConfiguration = new MonitoringConfig();
Configuration.Bind("Features:Monitoring", monitorConfiguration);

var monitorConfiguration1 = new MonitoringConfig();
IConfigurationSection configurationSection = Configuration.GetSection("Features:Monitoring");
configurationSection.Bind(monitorConfiguration1);

В первом случае мы привязываем по названию секции, а во втором получаем секцию и привязываем уже из нее. Секция позволяет работать с частичным представлением конфигурации — так можно контролировать набор данных, с которым мы работаем. Секции также используются в стандартных методах расширения — так, получение строки подключения использует секцию «ConnectionStrings».

string connectionString = Configuration.GetConnectionString("Default");

public static string GetConnectionString(this IConfiguration configuration, string name)
{
    return configuration?.GetSection("ConnectionStrings")?[name];
}

Options — типизированное представление конфигурации


Создание объекта конфигурации вручную и привязка к данным — непрактично, но есть решение в виде использования Options. Options служат для получения строго типизированного представления конфигурации. Класс представления должен быть публичным с конструктором без параметров и публичными свойствами для присвоения значения, объект наполняется через рефлексию. Подробнее можно рассмотреть в исходниках.

Для начала использования Options нам необходимо зарегистрировать тип конфигурации с использованием метода расширения Configure для IServiceCollection с указанием секции, которую мы будем проецировать на наш класс.

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
}

После этого мы можем получать конфигурации посредством внедрения зависимости на интерфейсы IOptions, IOptionsMonitor, IOptionsSnapshot. Сам объект MonitoringConfig мы можем получить у интерфейса IOptions через свойство Value.

public class ExampleService
{
    private IOptions<MonitoringConfig> _configuration;
    public ExampleService(IOptions<MonitoringConfig> configuration)
    {
        _configuration = configuration;
    }
    public void Run()
    {
        TimeSpan timeSpan = _configuration.Value.StartTime; // 09:00
    }
}

Особенностью интерфейса IOptions является то, что в контейнере внедрения зависимостей конфигурация регистрируется как объект с жизненным циклом Singleton. При первом запросе значения по свойству Value инициализируется объект с данными, которые существуют, пока существует этот объект. IOptions не поддерживает обновление данных. Для поддержки обновлений есть интерфейсы IOptionsSnapshot и IOptionsMonitor.

IOptionsSnapshot в контейнере внедрения зависимостей регистрируется с жизненным циклом Scoped, что дает уже возможность получать новый объект конфигурации на запрос с новой областью контейнера. Для примера, в течение одного веб-запроса мы будем получать один и тот же объект, но для нового запроса мы получим уже новый объект с обновлёнными данными.

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

public class ExampleService
{
    private IOptionsMonitor<MonitoringConfig> _configuration;
    public ExampleService(IOptionsMonitor<MonitoringConfig> configuration)
    {
        _configuration = configuration;
        configuration.OnChange(config =>
        {
            Console.WriteLine("Конфигурация изменилась");
        });
    }
    
    public void Run()
    {
        TimeSpan timeSpan = _configuration.CurrentValue.StartTime; // 09:00
    }
}

Также есть возможность получать IOptionsSnapshot и IOptionsMontitor по имени — это необходимо, если у вас одному классу может соответствовать несколько секций конфигурации, и вы хотите получить конкретную. Для примера, у нас есть вот такие данные:

{
  "Cache": {
    "Main": {
      "Type": "global",
      "Interval": "10:00"
    },
    "Partial": {
      "Type": "personal",
      "Interval": "01:30"
    }
  }
}

Тип, который будет использоваться для проекции:

public class CachePolicy
{
    public string Type { get; set; }
    public TimeSpan Interval { get; set; }
}

Регистрируем конфигурации с указанием конкретного имени:

services.Configure<CachePolicy>("Main", Configuration.GetSection("Cache:Main"));
services.Configure<CachePolicy>("Partial", Configuration.GetSection("Cache:Partial"));

Получать значения мы можем следующим образом:

public class ExampleService
{
    public ExampleService(IOptionsSnapshot<CachePolicy> configuration)
    {
        CachePolicy main = configuration.Get("Main");
        TimeSpan mainInterval = main.Interval; // 10:00
            
        CachePolicy partial = configuration.Get("Partial");
        TimeSpan partialInterval = partial.Interval; // 01:30
    }
}

Если посмотреть в исходники метода расширения, которым мы регистрируем тип конфигурации, то можно увидеть, что по умолчанию используется имя Options.Default, которое является пустой строкой. Так что неявно мы всегда передаем имя для конфигураций.

public static IServiceCollection Configure<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
            => services.Configure<TOptions>(Options.Options.DefaultName, config);

Поскольку конфигурация может быть представлена классом, мы также можем добавить валидацию значений параметров — для этого надо разметить свойства с помощью атрибутов валидации из пространства имен System.ComponentModel.DataAnnotations. Например, укажем, что значение для свойства Type должно быть обязательным. Но также нам необходимо указать при регистрации конфигурации, что валидация должна происходить в принципе. Для этого есть метод расширения ValidateDataAnnotations.

public class CachePolicy
{
    [Required]
    public string Type { get; set; }
    public TimeSpan Interval { get; set; }
}

services.AddOptions<CachePolicy>()
        .Bind(Configuration.GetSection("Cache:Main"))
        .ValidateDataAnnotations();

Особенность такой валидации в том, что она произойдет только в момент получения объекта конфигурации. Это затрудняет возможность понять, что конфигурация не валидна при старте приложения. Для этой проблемы существует задача на GitHub. Одним из решений подобной проблемы может послужить подход, представленный в статье Adding validation to strongly typed configuration objects in ASP.NET Core.

Недостатки Options и как их обойти


Конфигурация через Options также имеет свои недостатки. Для использования нам необходимо добавлять зависимость, и каждый раз для получения объекта значения нам необходимо обращаться к свойству Value/CurrentValue. Добиться более чистого кода можно посредством получения чистого объекта конфигурации без обертки Options. Самым простым вариантом решения проблемы может быть дополнительная регистрация в контейнере зависимости чистого типа конфигурации.

services.Configure<MonitoringConfig>(Configuration.GetSection("Features:Monitoring"));
services.AddScoped<MonitoringConfig>(provider => provider.GetRequiredService<IOptionsSnapshot<MonitoringConfig>>().Value);

Решение прямолинейное, мы не заставляем конечный код знать про IOptions, но мы теряем гибкость для дополнительных действий с конфигурацией, если таковые нам необходимы. Для решения этой проблемы мы можем использовать паттерн «Мост», который позволит нам получить дополнительный слой, в котором мы можем производить дополнительные действия перед получением объекта.

Для достижения цели нам необходимо отрефакторить текущий код примера. Так как класс конфигурации имеет ограничение в виде конструктора без параметров, передать в конструктор объект IOptions / IOptionsSnapshot / IOptionsMontitor мы не можем, для этого мы отделим чтение конфигурации от финального представления.

Для примера представим, что мы хотим указать свойство StartTime класса MonitoringConfig строковым представлением минут со значением «09», которое не подходит под стандартный формат.

public class MonitoringConfigReader
{
    public bool EnableRPSLog { get; set; }
    public bool EnableStorageStatistic { get; set; }
    public string StartTime { get; set; }
}

public interface IMonitoringConfig
{
    bool EnableRPSLog { get; }
    bool EnableStorageStatistic { get; }
    TimeSpan StartTime { get; }
}

public class MonitoringConfig : IMonitoringConfig
{
    public MonitoringConfig(IOptionsMonitor<MonitoringConfigReader> option)
    {
        MonitoringConfigReader reader = option.Value;
        
        EnableRPSLog = reader.EnableRPSLog;
        EnableStorageStatistic = reader.EnableStorageStatistic;
        StartTime = GetTimeSpanValue(reader.StartTime);
    }
    
    public bool EnableRPSLog { get; }
    public bool EnableStorageStatistic { get; }
    public TimeSpan StartTime { get; }
    
    private static TimeSpan GetTimeSpanValue(string value) => TimeSpan.ParseExact(value, "mm", CultureInfo.InvariantCulture);
}

Для возможности получить чистую конфигурацию нам необходимо зарегистрировать ее в контейнере внедрения зависимостей.

services.Configure<MonitoringConfigReader>(Configuration.GetSection("Features:Monitoring"));
services.AddTransient<IMonitoringConfig, MonitoringConfig>();

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

Обеспечение безопасности данных


Немаловажной задачей конфигурации является обеспечение безопасности данных. Файловые конфигурации небезопасны, поскольку данные хранятся в открытом виде, который легко прочитать; зачастую файлы находятся в той же директории, что и приложение. По ошибке можно закоммитить значения в систему контроля версий, что может рассекретить данные, а представьте, если это публичный код! Ситуация настолько распространена, что есть даже готовый инструмент для поиска подобных утечек — Gitleaks. Есть отдельная статья, которая приводит статистику и разнообразие раскрытых данных.

Часто проект должен иметь отдельные параметры для различных сред окружения (Release/Debug и др.). Для примера, как один из вариантов решения, можно использовать подстановку финальных значений с помощью инструментов непрерывной интеграции и доставки, но этот вариант никак не защитит данные во время разработки. Защитить разработчика призван инструмент User Secrets. Он входит в пакет SDK для .NET Core (начиная с 3.0.100). В чем же основной принцип работы этого инструмента? Для начала мы должны инициализировать наш проект для работы командой init.

dotnet user-secrets init

Команда добавляет элемент UserSecretsId в .csproj файл проекта. Имея этот параметр, мы получаем приватное хранилище, которое будет хранить обычный JSON файл. Отличие в том, что он не находится в директории вашего проекта, поэтому доступен будет только на текущем компьютере. Путь для Windows %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json, а для Linux и MacOS ~/.microsoft/usersecrets/<user_secrets_id>/secrets.json. Мы можем добавить значение из примера выше с помощью команды set:

dotnet user-secrets set "Features:Monitoring:StartTime" "09:00"

Полный список доступных команд можно посмотреть в документации.

Безопасность данных в продакшене лучше всего обеспечить с использованием специализированного хранилища, как например: AWS Secrets Manager, Azure Key Vault, HashiСorp Vault, Consul, ZooKeeper. Для подключения некоторых уже есть готовые NuGet пакеты, а для некоторых их легко реализовать самостоятельно, поскольку есть доступ к REST API.

Заключение


Современные проблемы требуют современных решений. Вместе с уходом от монолитов к динамическим инфраструктурам, подходы в конфигурации тоже претерпели изменения. Появилась необходимость в независимости от расположения и типа источников данных конфигурации, необходимость в оперативном реагировании на изменение данных. Вместе с .NET Core мы получили хороший инструмент для реализации всевозможных сценариев конфигурирования приложений.