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

Заглянем в ConfigurationManager

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

Погодите-ка, что за ConfigurationManager?

Если у вас сразу возник этот вопрос, не волнуйтесь, вы не пропустили ничего важного!

ConfigurationManager был добавлен для поддержки новой модели WebApplication в ASP.NET Core*, используемой для упрощения кода запуска приложений ASP.NET Core. Однако ConfigurationManager во многом является деталью реализации. Он был введён для оптимизации определённого сценария (который я вкратце опишу), но в большинстве случаев вы не будете (да это и не нужно) знать, что вы его используете.

Прежде чем мы перейдём, собственно, к ConfigurationManager, рассмотрим, что он заменяет и почему.

Конфигурация приложений в .NET 5

.NET 5 предоставляет несколько типов конфигурации, но два основных из них, которые вы используете непосредственно в своих приложениях, приведены ниже:

  • IConfigurationBuilder — используется для добавления источников конфигурации. Вызов Build() в построителе считывает каждый из источников конфигурации и строит окончательную конфигурацию.

  • IConfigurationRoot — представляет собой окончательную «построенную» конфигурацию.

Интерфейс IConfigurationBuilder, по сути, представляет собой обёртку для списка источников конфигурации. Поставщики конфигурации обычно включают методы расширения (например, AddJsonFile() и AddAzureKeyVault()), которые добавляют источник конфигурации в список источников.

public interface IConfigurationBuilder
{
  IDictionary<string, object> Properties { get; }
  IList<IConfigurationSource> Sources { get; }
  IConfigurationBuilder Add(IConfigurationSource source);
  IConfigurationRoot Build();
}

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

Более поздние поставщики конфигурации (переменные среды) перезаписывают значения, добавленные более ранними поставщиками конфигурации (appsettings.json, sharedsettings.json). Взято из моей книги ASP.NET в Действии (https://dmkpress.com/catalog/computer/web/978-5-97060-550-9/)
Более поздние поставщики конфигурации (переменные среды) перезаписывают значения, добавленные более ранними поставщиками конфигурации (appsettings.json, sharedsettings.json). Взято из моей книги ASP.NET в Действии (https://dmkpress.com/catalog/computer/web/978-5-97060-550-9/)

В .NET 5 и ранее интерфейсы IConfigurationBuilder и IConfigurationRoot реализуются с помощью ConfigurationBuilder и ConfigurationRoot соответственно. Если бы вы использовали эти типы напрямую, вы могли бы сделать что-то вроде этого:

var builder = new ConfigurationBuilder();
// добавляем статические значения builder.AddInMemoryCollection(new Dictionary<string, string>
{
  { "MyKey", "MyValue" },
});

// добавляем значения из json файла
builder.AddJsonFile("appsettings.json");
// создаём экземпляр IConfigurationRoot
IConfigurationRoot config = builder.Build();

// получаем значение
string value = config["MyKey"];
// получаем секцию
IConfigurationSection section = config.GetSection("SubSection");

В типичном приложении ASP.NET Core вы не создаёте ConfigurationBuilder самостоятельно или не вызываете Build(), однако это то, что происходит за кулисами. Между этими двумя типами существует чёткое разделение, и в большинстве случаев эта система конфигурации работает хорошо. Так зачем нам новый тип в .NET 6?

Проблема "частичной сборки конфигурации" в .NET 5

Основная проблема с этим подходом проявляется, когда вам нужно построить конфигурацию «частично». Это распространённая проблема, когда вы храните свою конфигурацию в таком сервисе, как Azure Key Vault, или даже в базе данных.

Например, ниже приведён рекомендуемый способ чтения секретов из Azure Key Vault внутри ConfigureAppConfiguration() в ASP.NET Core:

.ConfigureAppConfiguration((context, config) =>
{
  // "нормальная" конфигурация
config.AddJsonFile("appsettings.json");
  config.AddEnvironmentVariables();

  if (context.HostingEnvironment.IsProduction())
  {
    // построение частичной конфигурации
    IConfigurationRoot partialConfig = config.Build();
    // чтение значения из конфигурации
    string keyVaultName = partialConfig["KeyVaultName"];
    var secretClient = new SecretClient(
      new Uri($"https://{keyVaultName}.vault.azure.net/"),
      new DefaultAzureCredential());
    // добавляем ещё один источник конфигурации
    config.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());

    // Фреймворк СНОВА вызывает config.Build(),
    // чтобы построить окончательный IConfigurationRoot
  }
})

Для настройки поставщика Azure Key Vault требуется значение конфигурации, поэтому вы столкнулись с проблемой курицы и яйца: вы не можете добавить источник конфигурации, пока не создадите конфигурацию!

Решение состоит в следующем:

  • Добавить «начальные» значения конфигурации.

  • Создать «частичный» результат конфигурации, вызвав IConfigurationBuilder.Build()

  • Получить необходимые значения конфигурации из построенного IConfigurationRoot

  • Использовать эти значения, чтобы добавить оставшиеся источники конфигурации.

  • Фреймворк неявно вызывает IConfigurationBuilder.Build(), генерируя окончательный IConfigurationRoot и используя его для окончательной конфигурации приложения.

Этот танец с бубном немного странный, но формально в нём нет ничего неправильного. Тогда в чём же проблема?

Проблемой является то, что нам нужно вызвать Build() дважды: один раз для создания IConfigurationRoot с использованием только начальных источников, а затем ещё раз для создания IConfiguartionRoot с использованием всех источников, включая источник Azure Key Vault.

В реализации ConfigurationBuilder по умолчанию вызов Build() выполняет итерацию по всем источникам, загружает поставщиков и передаёт их новому экземпляру ConfigurationRoot:

public IConfigurationRoot Build()
{
  var providers = new List<IConfigurationProvider>();
  foreach (IConfigurationSource source in Sources)
  {
    IConfigurationProvider provider = source.Build(this);
    providers.Add(provider);
  }
  return new ConfigurationRoot(providers);
}

Затем ConfigurationRoot по очереди перебирает каждого из этих поставщиков и загружает значения конфигурации.

public class ConfigurationRoot : IConfigurationRoot, IDisposable
{
  private readonly IList<IConfigurationProvider> _providers;
  private readonly IList<IDisposable> _changeTokenRegistrations;

  public ConfigurationRoot(IList<IConfigurationProvider> providers)
  {
    _providers = providers;
    _changeTokenRegistrations = new List<IDisposable>(providers.Count);

    foreach (IConfigurationProvider p in providers)
    {
      p.Load();
      _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
    }
  }
  // ... остальная реализация
}

Если вы вызовете Build() дважды во время запуска приложения, всё это выполнится дважды.

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

Это настолько распространённый сценарий, что в .NET 6 был введён новый тип ConfigurationManager, позволяющий избежать этого «перепостроения».

Менеджер Конфигурации в .NET 6

В .NET 6 разработчики .NET добавили новый тип конфигурации, ConfigurationManager, как часть «упрощённой» модели приложения. Этот тип реализует как IConfigurationBuilder, так и IConfigurationRoot. Объединив обе реализации в одном типе, .NET 6 может оптимизировать этот распространённый сценарий, показанный в предыдущем разделе.

В ConfigurationManager, когда добавляется IConfigurationSource (например, когда вы вызываете AddJsonFile()), поставщик сразу загружается, и конфигурация обновляется. Это позволяет избежать загрузки источников конфигурации более одного раза в сценарии частичной сборки.

Реализовать это немного сложнее, чем кажется, из-за интерфейса IConfigurationBuilder, хранящего источники в виде IList<IConfigurationSource>:

public interface IConfigurationBuilder
{
  IList<IConfigurationSource> Sources { get; }
  // … другие члены
}

Проблема с этим с точки зрения ConfigurationManager заключается в том, что IList<> предоставляет методы Add() и Remove(). Если бы использовался простой List<>, потребители могли бы добавлять и удалять поставщиков конфигурации, а ConfigurationManager об этом бы не знал.

Чтобы обойти это, ConfigurationManager использует свою реализацию IList<>. Она содержит ссылку на экземпляр ConfigurationManager, чтобы любые изменения могли быть отражены в конфигурации:

private class ConfigurationSources : IList<IConfigurationSource>
{
  private readonly List<IConfigurationSource> _sources = new();
  private readonly ConfigurationManager _config;

  public ConfigurationSources(ConfigurationManager config)
  {
    _config = config;
  }

  public void Add(IConfigurationSource source)
  {
    _sources.Add(source);
    // добавляет источник в ConfigurationManager
    _config.AddSource(source);
  }

  public bool Remove(IConfigurationSource source)
  {
    var removed = _sources.Remove(source);
    // перезагрузка источников в ConfigurationManager
    _config.ReloadSources();
    return removed;
  }

  // ... остальная реализация
}

Используя собственную реализацию IList<>, ConfigurationManager гарантирует, что AddSource() вызывается всякий раз, когда добавляется новый источник. В этом заключается преимущество ConfigurationManager: вызов AddSource() немедленно загружает источник.

public class ConfigurationManager
{
  private void AddSource(IConfigurationSource source)
  {
    lock (_providerLock)
    {
      IConfigurationProvider provider = source.Build(this);
      _providers.Add(provider);
      provider.Load();
      _changeTokenRegistrations.Add(ChangeToken.OnChange(() => provider.GetReloadToken(), () => RaiseChanged()));
    }

    RaiseChanged();
  }
}

Этот метод немедленно вызывает Build на IConfigurationSource для создания IConfigurationProvider и добавляет его в список поставщиков.

Затем вызывается метод IConfigurationProvider.Load(). Он загружает данные в поставщик (например, из переменных среды, файла JSON или Azure Key Vault) и является «дорогостоящим» шагом, для которого всё это и затевалось! В «нормальном» случае, когда вы просто добавляете источники в IConfigurationBuilder и в случае, когда вам требуется построить его несколько раз, это более «оптимальный» подход: источники загружаются один раз, и только один раз.

Реализация Build() в ConfigurationManager теперь пустая, просто возвращает себя.

IConfigurationRoot IConfigurationBuilder.Build () => this;

Конечно, разработка программного обеспечения — это всегда компромиссы. Инкрементное создание источников при их добавлении хорошо работает, если вы только добавляете источники. Однако, если вы вызываете любую из других функций IList<>, таких как Clear(), Remove() или индексатор, ConfigurationManager должен вызвать ReloadSources()

private void ReloadSources()
{
  lock (_providerLock)
  {
    DisposeRegistrationsAndProvidersUnsynchronized();

    _changeTokenRegistrations.Clear();
    _providers.Clear();

    foreach (var source in _sources)
    {
      _providers.Add(source.Build(this));
    }

    foreach (var p in _providers)
    {
      p.Load();
      _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
    }
  }

  RaiseChanged();
}

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

Конечно, удаление источников довольно странная операция: обычно нет причин делать что-либо, кроме добавления, — поэтому ConfigurationManager оптимизирован для наиболее распространенных случаев. Кто бы мог подумать? ????

В следующей таблице приведены сводные данные об относительной стоимости различных операций при использовании ConfigurationBuilder и ConfigurationManager.

Операция

ConfigurationBuilder

ConfigurationManager

Добавление источника

Дёшево

Относительно дорого

Частичное построение IConfigurationRoot

Дорого

Очень дёшево (пустая операция)

Полное построение IConfigurationRoot

Дорого

Очень дёшево (пустая операция)

Удаление источника

Дёшево

Дорого

Изменение источника

Дёшево

Дорого

Стоит ли беспокоиться о ConfigurationManager?

Итак, после всего вышесказанного, стоит ли вам беспокоиться о том, используете ли вы ConfigurationManager или ConfigurationBuilder?

Скорее нет.

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

Однако WebHostBuilder или HostBuilder, представленные в более ранних версиях ASP.NET Core, по-прежнему поддерживаются в .NET 6, и они по-прежнему «за кулисами» используют типы ConfigurationBuilder и ConfigurationRoot.

Единственная ситуация, которую я могу представить, когда вам нужно быть осторожным, — это если вы где-то полагаетесь на то, что IConfigurationBuilder или IConfigurationRoot представлены в виде конкретных типов ConfigurationBuilder или ConfigurationRoot. Мне это кажется маловероятным, и, если вы полагаетесь на это, мне было бы интересно узнать, почему!

Но, помимо этого маловероятного исключения, «старые» типы никуда не денутся, так что не о чем беспокоиться. Просто порадуйтесь, зная, что, если вам нужно выполнить «частичную сборку» и вы используете новый WebApplicationBuilder, ваше приложение будет немного более производительным.

Итого

В этом посте я описал новый тип ConfigurationManager, представленный в .NET 6 и используемый новым WebApplicationBuilder в минимальных API. ConfigurationManager был введен для оптимизации распространённого сценария, когда вам необходимо «частично построить» конфигурацию. Обычно это происходит потому, что поставщику конфигурации требуется некоторая предварительная конфигурация. Например, для загрузки секретов из Azure Key Vault требуется конфигурация, указывающая, какое хранилище использовать.

ConfigurationManager оптимизирует этот сценарий, немедленно загружая источники по мере их добавления, а не дожидаясь вызова Build(). Это позволяет избежать необходимости «перестраивать» конфигурацию в сценарии «частичной сборки». Компромисс заключается в том, что другие операции (например, удаление источника) являются дорогостоящими.

Оригинал

От переводчика

* Несмотря на то, что, начиная с .NET 5, создатели больше не используют слово Core в названии, автор использует его, поэтому я решил сохранить авторский стиль в переводе.

PS: Это мой первый опыт переводов для Хабра, поэтому не судите строго. В дальнейшем планирую перевести и остальные статьи этой серии.

Комментарии (10)


  1. Nonich
    09.12.2021 12:55

    Лайк за статью


  1. AgentFire
    09.12.2021 14:05

    Конфигурация - это хорошо, но причём тут ASP.NET (Core или не Core), совсем не понятно.


  1. dabrahabra
    09.12.2021 15:02

    Жаль в .NET 6 не завезли возможности использования конфигурации в Multi-Tenant окружении.


    1. KaiOvas
      09.12.2021 16:36

      А можно подробнее об этом почитать? Может есть ссылка на ишью гитхаба где это обсуждается?


    1. vabka
      09.12.2021 23:56
      +1

      Можете уточнить, что есть такого особенного в вашем мультитенанте, что в нём нельзя использовать стандартный IConfiguration?


  1. ODHB
    10.12.2021 00:09
    +1

    Автор правильно именует ASP.NET Core, т.к. переименовывали только .Net Core, но не ASP.NET Core:

    ASP.NET Core 5.0 is based on .NET 5 but retains the name "Core" to avoid confusing it with ASP.NET MVC 5.

    В 6-й версии переименований не было.


    1. SBenzenko Автор
      10.12.2021 00:10

      Спасибо. Упустил этот момент.


  1. mvv-rus
    10.12.2021 03:26
    +1

    Жаль, что это — всего лишь перевод, и он не дает возможноси задать кое-какие вопросы автору по-русски (по-английски — это уже не то, это — не душевно, это — по работе).
    Например, знает ли он, что в шаблоне Generic Hoat (в котором используется IHostBuilder) конфигурация по-любому строится в два этапа: сначала — конфигурация построителя, она же — конфигурация размещения (хоста), а потом уже — конфигурация веб-приложения, причем на втором этапе конфигурация, созданная на первом этапе уже доступна — через свойство Configuration первого параметра (он имеет тип HostBuilderContext). Так что параметр конфигурации KeyVaultName можно было бы занести и в конфигурацию размещения (например, в соответствущую переменную окружения с префиксом ASPNETCORE_* — эти переменные читаются именно на стадии конфигурирования построителя.
    А если знает — почему про это не пишет. Но раз это перевод — то вопросы, очевидно, останутся без ответа.

    PS А Microsoft — в своем нынешнем репертуаре: вместо того, чтобы развивать существующие шаблоны — просто добавила новый.


    1. SBenzenko Автор
      10.12.2021 10:34

      Про Generic Host и его заместитель в .NET 6 будет во второй части.

      В примере автор ссылается на рекомендуемую настройку конфигурации, указанную в документации Майкрософта https://docs.microsoft.com/en-us/aspnet/core/security/key-vault-configuration?view=aspnetcore-5.0#use-application-id-and-x509-certificate-for-non-azure-hosted-apps

      Но у вас интересный вариант. А можете пример кода набросать для наглядности?


      1. mvv-rus
        10.12.2021 17:25

        А можете пример кода набросать для наглядности?

        Там и кода-то нет. Просто задаете снаружи программы переменную окружения ASPNETCORE_KeyVaultName со значением — именем хранилища, а дальше выкидываете построение промежуточной конфигурации и чиатете значение параметра KeyVaultName из конфигурации построителя (все это — в Generic Host, за Web Host — не скажу):
         if (context.HostingEnvironment.IsProduction())
          {
            // чтение значения из конфигурации
            string keyVaultName = context.Configuration["KeyVaultName"];
        

        А если нужно все-таки хранить имя хранилища в appsettings.json, то, чтобы два раза не читать этот файл, можно построить конфигурацию из него одного, прочитать имя хранилища, а потом добавить уже построенную конфигурацию в список источников конфигурации с помощью configuration.AddConfiguration (как-то так, код писать лень).

        А что до документации MS — там написан самый простой и всегда работающий вариант, но не оптимальный для конкретных условий.

        PS Я тут, вообще-то, когда-то пару статей писал, про то, как происходит инициализация для Generic Host, возможно они для понимания полезны будут (хотя они и сами для понимания сложны).