В этой серии статей я собираюсь взглянуть на некоторые из новых функций, которые появились в .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
оптимизирован для наиболее распространенных случаев. Кто бы мог подумать? ????
В следующей таблице приведены сводные данные об относительной стоимости различных операций при использовании 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)
AgentFire
09.12.2021 14:05Конфигурация - это хорошо, но причём тут ASP.NET (Core или не Core), совсем не понятно.
dabrahabra
09.12.2021 15:02Жаль в .NET 6 не завезли возможности использования конфигурации в Multi-Tenant окружении.
KaiOvas
09.12.2021 16:36А можно подробнее об этом почитать? Может есть ссылка на ишью гитхаба где это обсуждается?
vabka
09.12.2021 23:56+1Можете уточнить, что есть такого особенного в вашем мультитенанте, что в нём нельзя использовать стандартный IConfiguration?
mvv-rus
10.12.2021 03:26+1Жаль, что это — всего лишь перевод, и он не дает возможноси задать кое-какие вопросы автору по-русски (по-английски — это уже не то, это — не душевно, это — по работе).
Например, знает ли он, что в шаблоне Generic Hoat (в котором используется IHostBuilder) конфигурация по-любому строится в два этапа: сначала — конфигурация построителя, она же — конфигурация размещения (хоста), а потом уже — конфигурация веб-приложения, причем на втором этапе конфигурация, созданная на первом этапе уже доступна — через свойство Configuration первого параметра (он имеет тип HostBuilderContext). Так что параметр конфигурации KeyVaultName можно было бы занести и в конфигурацию размещения (например, в соответствущую переменную окружения с префиксом ASPNETCORE_* — эти переменные читаются именно на стадии конфигурирования построителя.
А если знает — почему про это не пишет. Но раз это перевод — то вопросы, очевидно, останутся без ответа.
PS А Microsoft — в своем нынешнем репертуаре: вместо того, чтобы развивать существующие шаблоны — просто добавила новый.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
Но у вас интересный вариант. А можете пример кода набросать для наглядности?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, возможно они для понимания полезны будут (хотя они и сами для понимания сложны).
Nonich
Лайк за статью