Как работает конфигурация в .NET Core

Давайте отложим разговоры о DDD и рефлексии на время. Предлагаю поговорить о простом, об организации настроек приложения.


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


public IConfiguration Configuration { get; set; }
public IHostingEnvironment Environment { get; set; }

public Startup(IConfiguration configuration, IHostingEnvironment environment)
{
   Environment = environment;
   Configuration = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json")
            .AddJsonFile($"appsettings.{Environment.EnvironmentName}.json")
            .Build();
}

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


Как было раньше


Как и у любой истории, у этой статьи есть начало. Одним из первых вопросов после перехода на ASP.NET Core были трансформации конфигурационных файлов.


Вспомним как это было ранее c web.config


Конфигурация состояла из нескольких файлов. Основным был файл web.config, и к нему уже применялись трансформации (web.Development.config и др.) в зависимости от конфигурации сборки. При этом активно использовались xml-атрибуты для поиска и трансформации секции xml-документа.


Но как мы знаем в ASP.NET Core файл web.config заменен на appsettings.json и привычного механизма трансформаций больше нет.


Что нам говорит google?

Результатом поиска " Трансформации в ASP.NET Core " в google стал следующий код:


public IConfiguration Configuration { get; set; }
public IHostingEnvironment Environment { get; set; }

public Startup(IConfiguration configuration, IHostingEnvironment environment)
{
   Environment = environment;
   Configuration = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json")
            .AddJsonFile($"appsettings.{Environment.EnvironmentName}.json")
            .Build();
}

В конструкторе класса Startup мы создаем объект конфигурации с помощью ConfigurationBuilder. При этом мы явно указываем какие источники конфигурации мы хотим использовать.


И такой:


public IConfiguration Configuration { get; set; }
public IHostingEnvironment Environment { get; set; }

public Startup(IConfiguration configuration, IHostingEnvironment environment)
{
   Environment = environment;
   Configuration = new ConfigurationBuilder()
            .AddJsonFile($"appsettings.{Environment.EnvironmentName}.json")
            .Build();
}

В зависимости от переменной окружения выбирается тот или иной источник конфигурации.


Данные ответы часто встречаются на SO и других менее популярных ресурсах. Но не покидало ощущение. что мы идем не туда. Как быть если я хочу использовать переменные окружения или аргументы командной строки в конфигурации? Почему мне нужно писать этот код в каждом проекте?


В поисках истины пришлось забраться вглубь документации и исходного кода. И я хочу поделиться полученным знанием в данной статье.


Давайте разберемся, как работает конфигурация в .NET Core.


Конфигурация


Конфигурация в .NET Core представлена объектом интерфейса IConfiguration.


public interface IConfiguration
{
   string this[string key] { get; set; }

   IConfigurationSection GetSection(string key);

   IEnumerable<IConfigurationSection> GetChildren();

   IChangeToken GetReloadToken();
}

  • [string key] индексатор, который позволяет по ключу получить значение параметра конфигурации
  • GetSection(string key) возвращает секцию конфигурации, которая соответствует ключу key
  • GetChildren() возвращает набор подсекций текущей секции конфигурации
  • GetReloadToken() возвращает экземпляр IChangeToken, который можно использовать для получения уведомлений при изменении конфигурации

Конфигурация представляет собой набор пар "ключ-значение". При чтении из источника конфигурации (файл, переменные окружения) иерархические данные приводятся к плоской структуре. Например json-объект вида


{
 "Settings": {
  "Key": "I am options"
 }
}

будет приведен к плоскому виду:


Settings:Key = I am options

Здесь ключом является Settings:Key, а значением I am options.
Для наполнения конфигурации используются провайдеры конфигурации.


Провайдеры конфигурации


За чтение данных из источника конфигурации отвечает объект интерфейса
IConfigurationProvider:


public interface IConfigurationProvider
{
   bool TryGet(string key, out string value);

   void Set(string key, string value);

   IChangeToken GetReloadToken();

   void Load();

   IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath);
}

  • TryGet(string key, out string value) позволяет по ключу получить значение параметра конфигурации
  • Set(string key, string value) используется для установки значения параметра конфигурации
  • GetReloadToken() возвращает экземпляр IChangeToken, который можно использовать для получения уведомлений при изменении источника конфигурации
  • Load() метод который отвечает за чтение источника конфигурации
  • GetChildKeys(IEnumerable<string> earlierKeys, string parentPath) позволяет получить список всех ключей, которые предоставляет данный поставщик конфигурации

Из коробки доступны следующие провайдеры:


  • Json
  • Ini
  • Xml
  • Environment Variables
  • InMemory
  • Azure
  • Кастомный провайдер конфигурации

Приняты следующие соглашения использования провайдеров конфигурации.


  1. Источники конфигурации считываются в том порядке, в котором они были указаны
  2. Если в разных источниках конфигурации присутствуют одинаковые ключи (сравнение идет без учета регистра), то используется значение, которое было добавлено последним.

Если мы создаем экземпляр web-сервера используя CreateDefaultBuilder, то по умолчанию подключаются следующие провайдеры конфигурации:



  • ChainedConfigurationProvider через этот провайдер можно получать значения и ключи конфигурации, которые были добавлены другими провайдерами конфигурации
  • JsonConfigurationProvider использует в качестве источника конфигурации json-файлы. Как можно заметить, в список провайдеров добавлены три провайдера данного типа. Первый использует в качестве источника appsettings.json, второй appsettings.{environment}.json. Третий считывает данные из secrets.json. Если выполнить сборку приложения в конфигурации Release, третий провайдер не будет подключен, потому что не рекомендуется использовать секреты в Production-среде
  • EnvironmentVariablesConfigurationProvider получает параметры конфигурации из переменных окружения
  • CommandLineConfigurationProvider позволяет добавлять аргументы командой строки в конфигурацию

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


Если в провайдере CommandLineConfigurationProvider имеется элемент с ключом key и в провайдере JsonConfigurationProvider имеется элемент с ключом key, элемент из JsonConfigurationProvider будет заменен элементом из CommandLineConfigurationProvider так как он регистрируется последним и имеет больший приоритет.


Вспомним пример из начала статьи
public IConfiguration Configuration { get; set; }
public IHostingEnvironment Environment { get; set; }

public Startup(IConfiguration configuration, IHostingEnvironment environment)
{
   Environment = environment;
   Configuration = new ConfigurationBuilder()
            .AddJsonFile("appsettings.json")
            .AddJsonFile($"appsettings.{Environment.EnvironmentName}.json")
            .Build();
}

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


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


Для того, чтобы написать свой поставщик конфигурации необходимо реализовать интерфейсы IConfigurationProvider и IConfigurationSource. IConfigurationSource новый интерфейс, который мы еще не рассматривали в данной статье.


public interface IConfigurationSource
{
    IConfigurationProvider Build(IConfigurationBuilder builder);
}

Интерфейс состоит из единственного метода Build, который принимает в качестве параметра IConfigurationBuilder и возвращает новый экземпляр IConfigurationProvider.


Для реализации своих поставщиков конфигурации нам доступны абстрактные классы ConfigurationProvider и FileConfigurationProvider. В этих классах уже реализована логика методов TryGet, Set, GetReloadToken, GetChildKeys и остается реализовать только метод Load.


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


Создадим класс YamlConfigurationProvider и сделаем его наследником FileConfigurationProvider.


public class YamlConfigurationProvider : FileConfigurationProvider
{
    private readonly string _filePath;

    public YamlConfigurationProvider(FileConfigurationSource source) 
        : base(source)
    {
    }

    public override void Load(Stream stream)
    {
        throw new NotImplementedException();
    }
}

В приведенном фрагменте кода можно заметить некоторые особенности класса FileConfigurationProvider. Конструктор принимает экземпляр FileConfigurationSource, который содержит в себе IFileProvider. IFileProvider используется для чтения файла, и для подписки на событие изменения файла. Также можно заметить, что метод Load принимает Stream в котором открыт для чтения файл конфигурации. Это метод класса FileConfigurationProvider и его нет в интерфейсе IConfigurationProvider.


Добавим простую реализацию, которая позволит считать yaml-файл. Для чтения файла я воспользуюсь пакетом YamlDotNet.


Реализация YamlConfigurationProvider
 public class YamlConfigurationProvider : FileConfigurationProvider
{
    private readonly string _filePath;

    public YamlConfigurationProvider(FileConfigurationSource source) 
        : base(source)
    {
    }

    public override void Load(Stream stream)
    {
        if (stream.CanSeek)
        {
            stream.Seek(0L, SeekOrigin.Begin);
            using (StreamReader streamReader = new StreamReader(stream))
            {
                var fileContent = streamReader.ReadToEnd();
                var yamlObject = new DeserializerBuilder()
                    .Build()
                    .Deserialize(new StringReader(fileContent)) as IDictionary<object, object>;

                Data = new Dictionary<string, string>();

                foreach (var pair in yamlObject)
                {
                    FillData(String.Empty, pair);
                }
            }
        }
    }

    private void FillData(string prefix, KeyValuePair<object, object> pair)
    {
        var key = String.IsNullOrEmpty(prefix)
            ? pair.Key.ToString() 
            : $"{prefix}:{pair.Key}";

        switch (pair.Value)
        {
            case string value:
                Data.Add(key, value);
                break;

            case IDictionary<object, object> section:
            {
                foreach (var sectionPair in section)
                    FillData(pair.Key.ToString(), sectionPair);

                break;
            }
        }
    }
}

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


Реализация YamlConfigurationSource
public class YamlConfigurationSource : FileConfigurationSource
{
    public YamlConfigurationSource(string fileName)
    {
        Path = fileName;
        ReloadOnChange = true;
    }

    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        this.EnsureDefaults(builder);
        return new YamlConfigurationProvider(this);
    }
}

Тут важно отметить, что для инициализации свойств базового класса необходимо вызвать метод this.EnsureDefaults(builder).


Для регистрации кастомного провайдера конфигурации в приложении необходимо добавить экземпляр провайдера в IConfigurationBuilder. Можно вызвать метод Add из IConfigurationBuilder, но я сразу вынесу логику инициализации YamlConfigurationProvider в extension-метод.


Реализация YamlConfigurationExtensions
public static class YamlConfigurationExtensions
{
    public static IConfigurationBuilder AddYaml(
        this IConfigurationBuilder builder, string filePath)
    {
        if (builder == null)
            throw new ArgumentNullException(nameof(builder));

        if (string.IsNullOrEmpty(filePath))
            throw new ArgumentNullException(nameof(filePath));

        return builder
            .Add(new YamlConfigurationSource(filePath));
    }
}

Вызов метода AddYaml
public class Program
{
    public static void Main(string[] args)
    {
        CreateWebHostBuilder(args).Build().Run();
    }

    public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
        WebHost.CreateDefaultBuilder(args)
            .ConfigureAppConfiguration((context, builder) =>
            {
                builder.AddYaml("appsettings.yaml");
            })
            .UseStartup<Startup>();
}

Отслеживание изменений


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


  • Поставщик конфигурации отслеживает изменение источника конфигурации
  • Если произошло изменение конфигурации, создается новый IChangeToken
  • При изменении IChangeToken вызывается перезагрузка конфигурации

Посмотрим как реализовано отслеживание изменений в FileConfigurationProvider.


ChangeToken.OnChange(
    //producer
    () => Source.FileProvider.Watch(Source.Path), 
    //consumer
    () => {                         
        Thread.Sleep(Source.ReloadDelay);
        Load(reload: true);
    });

В метод OnChange статического класса ChangeToken передается два параметра. Первый параметр это функция которая возвращает новый IChangeToken при изменении источника конфигурации (в данном случае файла), это т.н producer. Вторым параметром идет функция-callback (или consumer), которая будет вызвана при изменении источника конфигурации.
Подробнее о классе ChangeToken.


Не все провайдеры конфигурации реализуют отслеживание изменений. Этот механизм доступен для потомков FileConfigurationProvider и AzureKeyVaultConfigurationProvider.


Заключение


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


Данная статья затрагивает лишь основы. Помимо основ нам доступны IOptions, сценарии пост-конфигурации, валидация настроек и многое другое. Но это уже другая история.


Проект приложения с примерами из данной статьи вы можете найти в репозитории на Github.
Делитесь в комментариях, кто какие подходы по организации конфигурации использует?
Спасибо за внимание.


upd.: Как верно подсказал AdAbsurdum, в случае работы с массивами не всегда будет происходит замена элементов при слиянии конфигурации из двух источников.
Рассмотрим пример. При чтении массива из appsettings.json получим такой плоский вид:


array:0=valueA

При чтении из appsettings.Development.json:


array:0=valueB
array:1=value

В итоге в конфигурации будет:


array:0=valueB
array:1=value

Все элементы с уникальными индексами (array:1 в примере) будут добавлены в итоговый массив. Элементы из разных источников конфигурации, но имеющие одинаковый индекс (array:0 в примере) подвергнутся слиянию, и будет использован элемент, который был добавлен последним.

Поделиться публикацией

Комментарии 23

    +1

    Вы как-то лихо смешиваете ASP.NET Core и .NET Core в статье.

      +1
      На самом деле получается довольно интересно.
      Nuget пакеты с инфраструктурой конфигурации ни как не зависят от ASP.NET Core и таргетятся на .NETStandard 2.0. Таким образом их можно использовать не только в ASP.NET Core приложениях, но и в любых .NET Core и .NET Framework приложениях.
      Слегка смущает лишь то, что весь код лежит в репозитории ASP.NET Core.
        0
        Вы как-то лихо смешиваете ASP.NET Core и .NET Core в статье.


        Действительно лихо. Причина в том, что как вы уже писали пакет Microsoft.Extensions не зависит от ASP.NET Core. Но проблемы, которые я описывал касаются использования конфигурации в ASP.NET Core. В приложениях .NET Core и .NET Framework они могут и не возникнуть, потому что у нас нет волшебного метода CreateDefaultBuilder, который скрывает за кулисами всю рутинную работу.
      +1
      Данная статья затрагивает лишь основы. Помимо основ нам доступны IOptions, сценарии пост-конфигурации, валидация настроек и многое другое. Но это уже другая история.

      Самая интресная история.
        +2
        Я работаю над продолжением этой истории. Если у вас есть вопросы по расширенным сценариям конфигурации, напишите, я попробую их разобрать.
          0
          Да просто напишите, как правильно делать Default опции, переключать конфиги в зависимости от ASPNETCORE_ENVIRONMENT, как управляеть переменными окружения и конфигами в тестах. про --launch-profile тоже.
          В общем, про IRL-использование и хорошии практики, а не пересказ docs.microsoft.com/ru-ru/aspnet/core/fundamentals/configuration/?view=aspnetcore-2.2
        +1
        Мой вопрос напрямую не относится к теме статьи но все же.
        Может быть вы знаете по какой причине разработчики ASP.NET Core ввели конфигурацию на основе переменных окружения? Насколько я знаю такой тип источника конфигурации не пользовался популярностью в .NET Framework? Вполне возможно что я пропустил что-то важное или же очевидное но никак не могу понять этот момент
          0
          К сожалению не могу ответить за разработчиков ASP.NET Core. Мне помнится в .NET Framework не было удобных инструментов для работы с переменными окружения. Или писать свою реализацию сервиса для получения настроек из переменной окружения. Или делать наследника ConfigurationBuilder, и делать чтение переменных там. Поправьте если ошибаюсь.
            0
            Насколько я помню есть ConfigurationManager, который так же доступен и в .NET Core
              0
              Доступен-доступен, доставить System.Configuration.ConfigurationManager, именно так и называется пакет, если память не подводит и вуаля, там единственный момент — в случае ASP Core он почитывает все из app.config (т.е. его производной), а не из web.config
            +1
            Например, если вы собрали контейнер с каким-нибудь сервисом, а потом вам нужно этот контейнер запустить для тестов и подсунуть ему в качестве строки подключения фейковую БД для тестов, которую вы тут же рядом подняли в соседнем контейнере. Через переменные окружения это можно легко переопределить. У нас в связке с gitlab это многие используют.

            Опять же передавать всякие секреты в различных окружениях тоже можно.
              +4
              Не могу быть уверен, что послужило причиной, конечно, но в том же Kubernetes есть стандартные механизмы прокидывания настроек — config maps и secret references — доступ к которым можно получать в т.ч. через переменные окружения. Т.е. подход распространенный в облачном мире, решили упростить жизнь разработчикам.
              0

              Жалко только обязательных параметров без извращение с ioption не сделать никак…

                0
                Можно написать валидатор www.stevejgordon.co.uk/asp-net-core-2-2-options-validation
                  0

                  И это то, что я сказал во второй части предложения.

                    0

                    Как по мне, так использование опций с DataAnnotation-атрибутами это самое лучшее решение. За некоторыми нюансами, что ошибка валидации будет происходит где-то в рантайме, только при запросе опций через DI. Но это обещают исправить в .NET Core 3.0


                    Если нет желания использовать опции, можно реализовать что-то типа IStartupFilter и там проверять конфигурацию. Но мне кажется это то еще извращение.

                      –1

                      Я иногда вообще не хочу использовать DI. Не говоря про то, что у меня консольное приложение, без MVC. Зачем мне туда тащить эти IOption непонятно.


                      В итоге я отказался от всей этой конфигурации вообще и просто паршу JSON руками:


                      public class Config
                      {
                          private readonly JObject _json;
                      
                          public Config(string file)
                          {
                              _json = JObject.Parse(File.ReadAllText(file));
                          }
                      
                          public T GetSection<T>(string sectionName = null) where T : class
                          {
                              if (sectionName == null)
                              {
                                  sectionName = typeof(T).Name;
                              }
                              return _json[sectionName]?.ToObject<T>() ??
                                     throw new InvalidOperationException($"Cannot find section {sectionName}");
                          }
                      }

                      public class EthereumBaseSettings
                      {
                          [JsonProperty(Required = Required.Always)]
                          public string AccountAddress { get; }
                      
                          [JsonProperty(Required = Required.Always)]
                          public string PrivateKey { get; }
                      
                          [JsonProperty(Required = Required.Always)]
                          public string AccountPassword { get; }
                      
                          [JsonProperty(Required = Required.Always)]
                          public string RootContractAddress { get; }
                      
                          [JsonProperty(Required = Required.Always)]
                          public string ParityConnectionString { get; }
                      
                          public EthereumBaseSettings(string accountAddress,
                                                      string privateKey,
                                                      string accountPassword,
                                                      string rootContractAddress,
                                                      string parityConnectionString)
                          {
                              AccountAddress = accountAddress;
                              PrivateKey = privateKey;
                              AccountPassword = accountPassword;
                              RootContractAddress = rootContractAddress;
                              ParityConnectionString = parityConnectionString;
                          }
                      }

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

                0
                Если в разных источниках конфигурации присутствуют одинаковые ключи (сравнение идет без учета регистра), то используется значение, которое было добавлено последним.

                Это не всегда так. Для массивов будет просто добавление элемента в массив.
                  0

                  Да, вы отчасти правы. Для массивов, элементы с одинаковыми индексами будут заменены, с уникальными индексами будут добавлены. Добавлю в статью.

                    0

                    На сколько помню ничего не заменяется. Просто добавляется. Например в случае массива в appsettings.json и appsettings.Development.json

                      0

                      Это легко проверить.
                      К примеру при чтении массива из appsettings.json получим такой плоский вид:


                      array:0=valueA
                      array:1=value

                      При чтении из appsettings.Development.json:


                      array:0=valueB

                      В итоге в конфигурации будет:


                      array:0=valueB
                      array:1=value
                  0
                  В зависимости от переменной окружения выбирается тот или иной источник конфигурации.


                  Получается, что при сборке бинарников приложения там будут файлы конфигураций для всех environment и нужно как-то вручную удалять лишние?

                  Есть ли способы комбинирования настроек через переменные окружения и через файлы?
                  Например чтобы при запуске локально настройки брались из файла, а при запуске в тестовом окружении их переменных окружения?
                    0
                    Получается, что при сборке бинарников приложения там будут файлы конфигураций для всех environment и нужно как-то вручную удалять лишние?

                    Да, действительно при публикации приложения, все файлы .json попадают в выходную папку, не смотря на то, что в их свойствах стоит опция Do not copy. Чтобы решить эту проблему можно добавить в .csproj MSBuild conditional constructs с , но это не выглядит прямым решением проблемы.

                    Есть ли способы комбинирования настроек через переменные окружения и через файлы?

                    Есть. Вспомним пример:


                    {
                     "Settings": {
                      "Key": "I am options"
                     }
                    }

                    Будет приведен к плоскому виду:


                    Settings:Key = I am options

                    Если создать переменную окружения с ключом Settings__Key, она заменит настройку из файла. Подробнее здесь.

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

                  Самое читаемое