Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В этой серии статей я собираюсь взглянуть на некоторые из новых функций, которые появились в .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
в свою очередь представляет окончательные «многоуровневые» значения конфигурации, объединяя все значения из каждого из источников конфигурации, чтобы дать окончательное «плоское» представление всех значений.
В .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
оптимизирован для наиболее распространенных случаев. Кто бы мог подумать?