[Перевод] Исследуем .NET 6. Часть 1

В этой серии статей я собираюсь взглянуть на некоторые из новых функций, которые появились в .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 Properties { get; }
  IList 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
{
  { "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();
  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 _providers;
  private readonly IList _changeTokenRegistrations;

  public ConfigurationRoot (IList providers)
  {
    _providers = providers;
    _changeTokenRegistrations = new List(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:

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

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

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

private class ConfigurationSources : IList
{
  private readonly List _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 оптимизирован для наиболее распространенных случаев. Кто бы мог подумать?

© Habrahabr.ru