Исследуем .NET 6. Часть 3. Рассматриваем код WebApplicationBuilder
Часть 1. ConfigurationManager
Часть 2. WebApplicationBuilder
В предыдущем посте я сравнивал новый WebApplication
с универсальным хостом. В этом посте я рассмотрю код, лежащий в основе WebApplicationBuilder
, чтобы увидеть, как он обеспечивает более чистый, минимальный API хостинга, при этом обеспечивая ту же функциональность, что и универсальный хост.
WebApplication и WebApplicationBuilder: новый способ начальной загрузки приложений ASP.NET Core
В .NET 6 представлен совершенно новый способ «начальной загрузки» приложения ASP.NET Core. Вместо традиционного разделения между Program.cs
и Startup.cs
весь код находится в Program.cs
и является гораздо более процедурным, чем множество лямбда-методов, которых требовал универсальный хост в предыдущих версиях:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
WebApplication app = builder.Build();
app.UseStaticFiles();
app.MapGet("/", () => "Hello World!");
app.MapRazorPages();
app.Run();
В язык C# добавлены различные обновления, которые делают всё это более чистым (операторы верхнего уровня, неявные директивы using, выведение типов в лямбдах и т. д.). Но также появились два новых типа: WebApplication
и WebApplicationBuilder
. В предыдущем посте я кратко описал, как использовать WebApplication и WebApplicationBuilder
для настройки приложения ASP.NET Core. В этом посте мы рассмотрим их код, чтобы увидеть, как получился более простой API, при сохранении той же гибкости и настраиваемости, что и у универсального хоста.
Создание WebApplicationBuilder
Первым шагом в нашем примере программы является создание экземпляра WebApplicationBuilder
с использованием статического метода класса WebApplication
:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
Это создаёт новый экземпляр WebApplicationOptions
, инициализирует аргументы Args
из аргументов командной строки и передаёт объект параметров в конструктор WebApplicationBuilder
(показанный кратко ниже):
public static WebApplicationBuilder CreateBuilder(string[] args) =>
new(new() { Args = args });
Кстати, новое сокращённое ключевое слово new
, когда целевой тип неочевиден, - просто отстой при разборе кода. Невозможно даже предположить, что создаёт второй new()
в приведённом выше коде. Во многом претензии те же самые, как и к повсеместному использованию var
, но в данном случае это меня особенно раздражает.
WebApplicationOptions
предоставляет простой способ программного переопределения некоторых важных свойств. Если они не установлены, они будут выведены в значения по умолчанию, как и в предыдущих версиях .NET.
public class WebApplicationOptions
{
public string[]? Args { get; init; }
public string? EnvironmentName { get; init; }
public string? ApplicationName { get; init; }
public string? ContentRootPath { get; init; }
}
Конструктор WebApplicationBuilder
– это то место, где происходит большая часть магии, чтобы заставить работать концепцию минимального хостинга:
public sealed class WebApplicationBuilder
{
internal WebApplicationBuilder(WebApplicationOptions options)
{
// … показано ниже
}
}
Я ещё не показал тело метода, так как в этом конструкторе много чего происходит и используется много вспомогательных типов. Мы вернёмся к ним через секунду, а пока сосредоточимся на публичном API WebApplicationBuilder
.
Публичный API WebApplicationBuilder
Публичный API WebApplicationBuilder
состоит из набора свойств, доступных только для чтения, и одного метода Build()
, который создаёт WebApplication
.
public sealed class WebApplicationBuilder
{
public IWebHostEnvironment Environment { get; }
public IServiceCollection Services { get; }
public ConfigurationManager Configuration { get; }
public ILoggingBuilder Logging { get; }
public ConfigureWebHostBuilder WebHost { get; }
public ConfigureHostBuilder Host { get; }
public WebApplication Build()
}
Если вы знакомы с ASP.NET Core, вы заметите, что многие из этих свойств используют стандартные типы из предыдущих версий:
IWebHostEnvironment
– используется для получения имени среды, пути к корню контента и подобных значений.IServiceCollection
– используется для регистрации сервисов в контейнере DI. Обратите внимание, что это альтернатива методуConfigureServices()
, используемому универсальным хостом для достижения того же результата.ConfigurationManager
– используется как для добавления новой конфигурации, так и для получения значений конфигурации. См. первый пост в серии, где обсуждается этот вопрос.ILoggingBuilder
– используется для регистрации дополнительных поставщиков журналов, как и в случае с методомConfigureLogging()
в универсальном хосте.Свойства
WebHost
иHost
интересны тем, что они предоставлены новыми типамиConfigureWebHostBuilder
иConfigureHostBuilder
. Эти типы реализуютIWebHostBuilder
иIHostBuilder
соответственно и в основном представлены как замена для используемых ранее методов расширения.
Например, в предыдущем посте я показал, как можно зарегистрировать интеграцию Serilog с ASP.NET Core, вызвав UseSerilog()
на свойстве Host
:
builder.Host.UseSerilog();
Раскрытие интерфейсов IWebHostBuilder
и IHostBuilder
было абсолютно необходимо для обеспечения возможности перехода к новому минимальному API хостинга WebApplication
, но это также оказалось проблемой. Как согласовать конфигурацию в виде лямбд/обратных вызовов в IHostBuilder
с императивным стилем в WebApplicationBuilder
? Вот где в игру вступают ConfigureHostBuilder
и ConfigureWebHostBuilder
вместе с некоторыми внутренними реализациями IHostBuilder
:
Мы начнём с рассмотрения публичных ConfigureHostBuilder
и ConfigureWebHostBuilder
.
ConfigureHostBuilder
ConfigureHostBuilder
и ConfigureWebHostBuilder
были добавлены как часть обновлений для минимального хостинга. Они реализуют IHostBuilder
и IWebHostBuilder
соответственно. В этом посте мы подробно рассмотрим ConfigureHostBuilder
:
public sealed class ConfigureHostBuilder : IHostBuilder, ISupportsConfigureWebHost
{
// ...
}
ConfigureHostBuilder
реализует IHostBuilder
, и похоже, что он реализует ISupportsConfigureWebHost
, но взгляд на реализацию показывает, что это не так:
IHostBuilder ISupportsConfigureWebHost.ConfigureWebHost(Action<IWebHostBuilder> configure, Action<WebHostBuilderOptions> configureOptions)
{
throw new NotSupportedException($"ConfigureWebHost() не поддерживается WebApplicationBuilder.Host. Используйте вместо этого WebApplication, возвращаемый WebApplicationBuilder.Build().");
}
Это означает, что хотя следующий код и компилируется
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureWebHost(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
он выбрасывает исключение NotSupportedException
во время выполнения. Это явно не идеально, но это цена, которую мы платим за наличие хорошего императивного API для настройки сервисов и т. д. То же самое верно и для метода IHostBuilder.Build()
– он выбросит NotSupportedException
.
Видеть исключения времени выполнения никогда не доставляет радости, но это помогает думать о ConfigureHostBuilder
как об «адаптере» для существующих методов расширения (таких как метод UseSerilog()
), а не как о «реальном построителе» хоста. Это становится очевидным, когда вы видите, как такие методы, как ConfigureServices()
или ConfigureAppConfiguration()
, реализованы для этих типов:
public sealed class ConfigureHostBuilder : IHostBuilder, ISupportsConfigureWebHost
{
private readonly ConfigurationManager _configuration;
private readonly IServiceCollection _services;
private readonly HostBuilderContext _context;
internal ConfigureHostBuilder(HostBuilderContext context, ConfigurationManager configuration, IServiceCollection services)
{
_configuration = configuration;
_services = services;
_context = context;
}
public IHostBuilder ConfigureAppConfiguration(Action<HostBuilderContext, IConfigurationBuilder> configureDelegate)
{
// Выполнить делегат немедленно, чтобы контекст и конфигурация были доступны в императивном коде
configureDelegate(_context, _configuration);
return this;
}
public IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configureDelegate)
{
// Выполнить делегат немедленно, чтобы контекст и конфигурация были доступны в императивном коде configureDelegate(_context, _services);
return this;
}
}
Например, метод ConfigureServices()
немедленно выполняет предоставленный делегат Action<>
, используя внедрённую коллекцию IServiceCollection
из WebApplicationBuilder
. Таким образом, следующие два вызова функционально идентичны:
// прямая регистрация типа MyImplementation в IServiceContainer
builder.Services.AddSingleton<MyImplementation>();
// «старый» метод ConfigureServices
builder.Host.ConfigureServices((ctx, services) => services.AddSingleton<MyImplementation>());
Последний подход явно не стоит использовать в обычной практике, но существующий код, который полагается на этот метод (например, методы расширения), все ещё можно использовать.
Не все делегаты, переданные методам в ConfigureHostBuilder
, запускаются немедленно. Некоторые из них, например UseServiceProviderFactory()
, сохраняются в списке и выполняются позже при вызове WebApplicationBuilder.Build()
Вот примерно и всё про тип ConfigureHostBuilder
. ConfigureWebHostBuilder
очень похож и действует как адаптер от предыдущего API к новому императивному стилю. Теперь мы можем вернуться к конструктору WebApplicationBuilder
.
Вспомогательный класс BootstrapHostBuilder
Прежде чем мы отвлеклись на ConfigureHostBuilder
, мы собирались взглянуть на конструктор WebApplicationBuilder
. Но мы еще не готовы к этому... сначала нужно взглянуть на ещё один вспомогательный класс, BootstrapHostBuilder
.
BootstrapHostBuilder
– это внутренняя реализация IHostBuilder
, используемая в WebApplicationBuilder
. В основном это относительно простая реализация, которая «запоминает» все полученные вызовы IHostBuilder
. Например, функции ConfigureHostConfiguration()
и ConfigureServices()
выглядят следующим образом:
internal class BootstrapHostBuilder : IHostBuilder
{
private readonly List<Action<IConfigurationBuilder>> _configureHostActions = new();
private readonly List<Action<HostBuilderContext, IServiceCollection>> _configureServicesActions = new();
public IHostBuilder ConfigureHostConfiguration(Action<IConfigurationBuilder> configureDelegate)
{
_configureHostActions.Add(configureDelegate);
return this;
}
public IHostBuilder ConfigureServices(Action<HostBuilderContext, IServiceCollection> configureDelegate)
{
_configureServicesActions.Add(configureDelegate);
return this;
}
// ...
}
В отличие от ConfigureHostBuilder
, который немедленно выполнял переданный делегат, BootstrapHostBuilder
«сохраняет» переданные делегаты в список для последующего выполнения. Это похоже на то, как работает универсальный HostBuilder. Но обратите внимание, что BootstrapHostBuilder
– это ещё одна «нестроящаяся» реализация IHostBuilder
, в которой вызов Build()
выдаёт исключение:
public IHost Build()
{
throw new InvalidOperationException();
}
Большая часть сложности BootstrapHostBuilder заключается в его методе RunDefaultCallbacks(ConfigurationManager, HostBuilder)
. Он используется для применения сохранённых делегатов в правильном порядке, как мы увидим позже.
Конструктор WebApplicationBuilder
Наконец, мы дошли до конструктора WebApplicationBuilder
. Он содержит много кода, поэтому я буду рассматривать его по частям.
Обратите внимание, что я позволил себе вольность удалить второстепенные фрагменты кода (например, защитные конструкции и тестовый код).
public sealed class WebApplicationBuilder
{
private readonly HostBuilder _hostBuilder = new();
private readonly BootstrapHostBuilder _bootstrapHostBuilder;
private readonly WebApplicationServiceCollection _services = new();
internal WebApplicationBuilder(WebApplicationOptions options)
{
Services = _services;
var args = options.Args;
// ...
}
public IWebHostEnvironment Environment { get; }
public IServiceCollection Services { get; }
public ConfigurationManager Configuration { get; }
public ILoggingBuilder Logging { get; }
public ConfigureWebHostBuilder WebHost { get; }
public ConfigureHostBuilder Host { get; }
}
Начнем с приватных полей и свойств. _hostBuilder
– это экземпляр универсального хоста HostBuilder
, который является «внутренним» хостом в WebApplicationBuilder
. У нас также есть поле BootstrapHostBuilder (из предыдущего раздела) и экземпляр WebApplicationServiceCollection
, являющийся реализацией IServiceCollection
, которую я пока не буду рассматривать.
WebApplicationBuilder
действует как «адаптер» для универсального хоста _hostBuilder, предоставляя императивный API, который я исследовал в моём предыдущем посте, сохраняя при этом ту же функциональность, что и универсальный хост.
К счастью, следующие шаги в конструкторе хорошо документированы:
// Запуск методов для ранней настройки общих параметров и значений по умолчанию для веб-хоста, чтобы заполнить конфигурацию из appsettings.json,
// переменных среды (с префиксом DOTNET_ и ASPNETCORE_) и других возможных источников по умолчанию для предварительной установки
// правильных значений по умолчанию.
_bootstrapHostBuilder = new BootstrapHostBuilder(Services, _hostBuilder.Properties);
// Не указываем здесь args, поскольку мы хотим применить их позже, чтобы они
// могли перезаписать значения по умолчанию, указанные в ConfigureWebHostDefaults
_bootstrapHostBuilder.ConfigureDefaults(args: null);
// Мы указываем командную строку здесь последней, поскольку мы пропустили её при вызове ConfigureDefaults.
// args могут содержать настройки как хоста, так и приложения, поэтому мы хотим убедиться, что
// мы соблюдаем правильный порядок провайдеров конфигурации без дублирования их
if (args is { Length: > 0 })
{
_bootstrapHostBuilder.ConfigureAppConfiguration(config =>
{
config.AddCommandLine(args);
});
}
После создания экземпляра BootstrapHostBuilder
первым вызывается метод расширения HostingBuilderExtension.ConfigureDefaults()
. Это тот же метод, который вызывается универсальным хостом при вызове Host.CreateDefaultBuilder()
.
Обратите внимание, что аргументы не передаются в вызов ConfigureDefaults()
, а вместо этого применяются позже. В результате на этом этапе аргументы не используются для настройки конфигурации хоста (конфигурация хоста определяет такие значения, как имя приложения и среда хостинга).
Следующий вызов метода GenericHostBuilderExtensions.ConfigureWebHostDefaults()
, который является тем же методом расширения, который мы обычно вызываем при использовании универсального хоста в ASP.NET Core 3.x/5.
_bootstrapHostBuilder.ConfigureWebHostDefaults(webHostBuilder =>
{
webHostBuilder.Configure(ConfigureApplication);
// Нам нужно перезаписать имя приложения, поскольку вызов Configure установит его как имя вызывающей сборки.
var applicationName = (Assembly.GetEntryAssembly())?.GetName()?.Name ?? string.Empty;
webHostBuilder.UseSetting(WebHostDefaults.ApplicationKey, applicationName);
});
Этот метод по сути добавляет «адаптер» IWebHostBuilder
поверх BootstrapHostBuilder
, вызывает на нём WebHost.ConfigureWebDefaults()
, а затем немедленно запускает переданный лямбда-метод. Это регистрирует метод WebApplicationBuillder.ConfigureApplication()
для последующего вызова, который устанавливает целую кучу промежуточного ПО. Мы вернёмся к этому методу в следующем посте.
После настройки веб-хоста следующий метод применяет аргументы к конфигурации хоста, гарантируя, что они правильно переопределяют значения по умолчанию, установленные предыдущими методами расширения:
// Применяем args к конфигурации хоста последними, поскольку ConfigureWebHostDefaults перезаписывает специфические для хоста настройки (имя приложения).
_bootstrapHostBuilder.ConfigureHostConfiguration(config =>
{
if (args is { Length: > 0 })
{
config.AddCommandLine(args);
}
// Применяем options после args
options.ApplyHostConfiguration(config);
});
Наконец, вызывается метод BootstrapHostBuilder.RunDefaultCallbacks()
, который запускает все сохранённые обратные вызовы, которые мы накопили до сих пор, в правильном порядке, чтобы построить HostBuilderContext
. Затем HostBuilderContext
используется для окончательной установки остальных свойств в WebApplicationBuilder
.
Configuration = new();
// Это конфигурация приложения
var hostContext = _bootstrapHostBuilder.RunDefaultCallbacks(Configuration, _hostBuilder);
// Получаем WebHostBuilderContext из свойств для использования в ConfigureWebHostBuilder
var webHostContext = (WebHostBuilderContext)hostContext.Properties[typeof(WebHostBuilderContext)];
// Получаем IWebHostEnvironment из webHostContext. А также регистрирует экземпляр конфигурации в IServiceCollection.
Environment = webHostContext.HostingEnvironment;
Logging = new LoggingBuilder(Services);
Host = new ConfigureHostBuilder(hostContext, Configuration, Services);
WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);
Services.AddSingleton<IConfiguration>(_ => Configuration);
Это конец конструктора. На этом этапе приложение настроено со всеми настройками по умолчанию для «хостинга»: конфигурация, ведение журнала, службы DI, среда и т. д.
Теперь вы можете добавить все свои собственные сервисы, дополнительную конфигурацию или ведение журнала в WebApplicationBuilder
:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// добавляем конфигурацию
builder.Configuration.AddJsonFile("sharedsettings.json");
// добавляем сервисы
builder.Services.AddSingleton<MyTestService>();
builder.Services.AddRazorPages();
// добавляем ведение журнала
builder.Logging.AddFile();
// строим!
WebApplication app = builder.Build();
Закончив настройку конкретного приложения, вы вызываете Build()
для создания экземпляра WebApplication
. В последнем разделе этого поста мы заглянем внутрь метода Build()
.
Построение WebApplication в WebApplicationBuilder.Build()
Метод Build()
не очень длинный, но его немного сложно понять, поэтому я рассмотрю его построчно:
public WebApplication Build()
{
// Копируем источники конфигурации в окончательный IConfigurationBuilder
_hostBuilder.ConfigureHostConfiguration(builder =>
{
foreach (var source in ((IConfigurationBuilder)Configuration).Sources)
{
builder.Sources.Add(source);
}
foreach (var (key, value) in ((IConfigurationBuilder)Configuration).Properties)
{
builder.Properties[key] = value;
}
});
// ...
}
Первое, что мы делаем, это копируем источники конфигурации, настроенные в ConfigurationManager
, в реализацию ConfigurationBuilder
_hostBuilder
. Когда вызывается этот метод, построитель изначально пуст, поэтому он заполняет все источники, которые были добавлены как методами расширения построителя по умолчанию, так и дополнительные источники, которые вы настроили впоследствии.
Обратите внимание, что технически метод ConfigureHostConfiguration
запускается не сразу. Скорее, мы регистрируем обратный вызов, который будет вызываться, когда мы далее вызовем _hostBuilder.Build()
.
Затем мы делаем то же самое для IServiceCollection
, копируя их из экземпляра _services
в коллекцию _hostBuilder
. Комментарии здесь достаточно выразительные. В этом случае коллекция сервисов _hostBuilder
не всегда пуста, но мы добавляем в неё всё из Services, а затем «сбрасываем» Services в экземпляр _hostBuilder
.
// Это нужно сделать здесь, чтобы избежать добавления IHostedService, который дважды загружает сервер (GenericWebHostService).
// Копируем сервисы, которые были добавлены через WebApplicationBuilder.Services, в окончательную коллекцию IServiceCollection
_hostBuilder.ConfigureServices((context, services) =>
{
// На этом этапе мы добавили только сервисы, настроенные GenericWebHostBuilder и WebHost.ConfigureWebDefaults.
// HostBuilder обновляет новую коллекцию ServiceCollection в HostBuilder.Build(), которую мы ещё не видели
// до сих пор, поэтому мы не можем очистить эти сервисы, даже если некоторые из них избыточны, потому что
// мы вызвали ConfigureWebHostDefaults как для _deferredHostBuilder, так и для _hostBuilder.
foreach (var s in _services)
{
services.Add(s);
}
_services.InnerCollection = services;
});
В следующей строке мы запускаем все обратные вызовы, которые мы собрали в свойстве ConfigureHostBuilder
, как я показывал выше.
Если они есть, то это обратные вызовы, такие как ConfigureContainer()
и UseServiceProviderFactory()
, которые обычно используются только в том случае, если вы используете сторонний контейнер DI.
Host.RunDeferredCallbacks(_hostBuilder);
Наконец, мы вызываем _hostBuilder.Build()
для создания экземпляра Host
и передаём его новому экземпляру WebApplication
. Вызов _hostBuilder.Build()
– это то место, где вызываются все зарегистрированные обратные вызовы.
_builtApplication = new WebApplication(_hostBuilder.Build());
Теперь нам надо сделать небольшую уборку. Чтобы всё было согласовано, экземпляр ConfigurationManager
очищается и связывается с конфигурацией, хранящейся в WebApplication
. Кроме того, IServiceCollection
в WebApplicationBuilder
помечается как доступный только для чтения, поэтому попытка добавить службы после вызова WebApplicationBuilder
вызовет исключение InvalidOperationException
. Наконец, возвращается построенное веб-приложение.
((IConfigurationBuilder)Configuration).Sources.Clear();
Configuration.AddConfiguration(_builtApplication.Configuration);
_services.IsReadOnly = true;
return _builtApplication;
Это почти всё для WebApplicationBuilder
, но мы ещё не выполнили обратный вызов ConfigureApplication()
. В следующем посте мы рассмотрим код, лежащий в основе типа WebApplication
, и увидим, где, наконец, вызывается ConfigureApplication()
.
Итого
В этом посте мы рассмотрели часть кода нового API минимального хостинга WebApplicationBuilder
. Я показал, как типы ConfigureHostBuilder
и ConfigureWebHostBuilder
действуют как адаптеры для универсального хоста и как BootstrapHostBuilder
используется в качестве оболочки для внутреннего HostBuilder
. Было довольно много запутанного кода только для создания экземпляра WebApplicationBuilder
, но мы закончили статью, вызвав Build()
для создания WebApplication
. В следующем посте мы рассмотрим код WebApplication
.