Эта статья является продолжением двух предыдущих занудных статей под общим заголовком "О классах Program и Startup — инициализация ASP.NET приложения": "Program и IHostBuilder"[1] и "IWebHostBuilder и Startup"[2] — в которых подробно написано о том, что происходит в процессе инициализации приложения ASP.NET, сделанного по шаблону Generic Host. В тех двух статьях я рассказывал, как производится настройка и выполняется создание объектов конфигурации (она доступна через интерфейс IConfiguration), контейнера сервисов (он же DI-контейнер, его интерфейс — IServiceProvider) и размещения (интерфейс IHost). А в этой статье я собираюсь подробно рассказать, что происходит сразу после того, как приложение запускается на выполнение в объекте размещения.
Написав третью статью на примерно одну и ту же тему, я подумал, что и первые две, и эта должны стать частью одной серии, которую я для себя озаглавил "Под капотом" (см. КДПВ)


Предупреждение: статья занудная

Cтатья эта является примерно такой же занудной, как и обе предыдущие, поэтому большинству читателей я рекомендую просто добавить ее в закладки на случай надобности приводимой в ней информации, и ограничиться пока что поверхностным ознакомлением с ее содержимым:


TLDR, она же аннотация
  1. Запуск приложения, состоящего из размещенных (фоновых) сервисов в Generic Host (конструктор класса Internal.Host и его метод StartAsync).
  2. Запуск размещенного сервиса веб-приложения (Конструктор класса GenericWebHostedService и его метод StartAsync)
  3. Создание конвейера компонентов-обработчиков запроса(middleware). Класс ApplicationBuilder.
  4. Заключение

Но если вам все ещё интересно (или не интересно, но читать все равно зачем-то надо) — добро пожаловать под кат (и не говорите, что я вас не предупреждал, что легкого чтения не будет).


Вступление. Версии ASP.NET Core, рассмотренные в статье.


Описание инициализации веб-приложения в этой статье основано на одновременном рассмотрении как современной версии ASP.NET Core 6, так и довольно старой версии ASP.NET Core 3.1. Во многом это связано с тем, что две предыдущие статьи (ссылки на которые приведены в начале) рассматривали процесс конфигурирования и создания приложения именно в версии ASP.NET Core 3.1. Поэтому в данной статье я постарался сохранить преемственность с ними. Это тем более является осмысленным, что для рассматриваемой стадии — создания конвейера обработчиков и запуска веб-приложения — изменения между этими двумя версиями оказались невелики.
На первый взгляд может показаться, что те две предыдущие статьи устарели, потому что за время, которое прошло после их выпуска, в новой версии .NET — NET 6 — появился, на первый взгляд, совершенно новый способ инициализации и запуска веб-приложений, основанный на классах WebApplication и WebApplicationBuilder. Но на самом деле это не так. Этот новый способ использует для конфигурирования (в основном, с небольшими изменениями), все те же классы что используются в шаблоне Generic Host. Он теперь всего лишь прячет под капот все эти странные и непонятные заклинания-лямбды, которые раньше приходилось использовать для конфигурирования веб-приложения, и придает процессу конфигурирования более привычный многим разработчикам императивный вид. Одновременно туда же, в историю, отправился и Startup-класс, с его методами ConfigureServices и Configure, вместо которых используются теперь прямые вызовы методов новых классов WebApplication и WebApplicationBuilder и других конфигурирующих объектов, доступных через свойства этих классов. Однако сами классы WebApplication и WebApplicationBuilder (и связанные с ними вспомогательные классы) являются всего лишь надстройкой над внутренними классами из шаблона Generic Host, собирающей команды конфигурирования от пользователя и передающей их к этим внутренним классам для выполнения реальной работы по конфигурированию и инициализации приложения. У меня есть намерение написать про подробности реализации этих новых классов статью, но, в принципе, для понимания того, что происходит под капотом, можно обойтись и без статьи, просто пока поверив мне, что ничего существенного в процессе инициализации и работы веб-приложения от появления классов WebApplication и WebApplicationBuilder не поменялось.


Запуск приложения на базе шаблона Generic Host, состоящего из размещенных (фоновых) сервисов (конструктор класса Internal.Host и его метод StartAsync).


Начнем с начала — с запуска построенного веб-приложения. Методов запуска приложения, созданного как реализация интерфейса IHost (в шаблоне Generic Host он реализован классом Inetrnal.Host, имеющим область видимости internal, а потому недоступным в других сборках), существует несколько разных, но большинство из них — это методы расширения, и все они вызывают метод StartAsync() реализации интерфейса IHost, созданной на предыдущем этапе инициализации (подробности см. в статьях по ссылкам выше). Поэтому для понимания, что происходит в процессе запуска приложения, достаточно рассмотреть только один этот метод.
Класс WebApplication, используемый в новом шаблоне приложения в .NET 6 также реализует интерфейс IHost. Кроме того, он имеет два дополнительных метода Run и RunAsync, принимающих в качестве параметра URL, который будет использовать веб-приложение. Эти методы также запускают приложение, в конечном итоге, через IHost.StartAsync, поскольку, после установки прослушиваемого URL, они просто вызывают одноименные методы расширения интерфейса IHost.
Итак, все начинается с метода (асинхронного) StartAsync класса размещения Internal.Host, реализующего интерфейс IHost в шаблоне Generic Host. Экземпляр этого класса получается в конце процесса инициализации в методе Build класса HostBuilder, реализующего интерфейс построителя IHostBuilder. Получается этот экземпляр через уже инициализованный ранее контейнер сервисов, а потому его конструктор может использовать механизм внедрения зависимостей для получения дополнительных параметров.


Параметры Internal.Host.StartAsync

И в .NET Core 3.1, и в .NET 6 в конструктор размещения Internal.Host передается в качестве параметров прежде всего ссылка на контейнер сервисов — интерфейс IServiceProvider. Она сохраняется в автоматическом свойстве Services интерфейса IHost. Также и в .NET Core 3.1, и в .NET 6 передаются ссылки на реализации следующих интерфейсов:


  • IHostApplicationLifetime — отслеживает события жизненного цикла приложения — запуск, начало и окончание завершения;
  • ILogger(специализированный для типа Internal.Host) — используется для ведения журнала событий;
  • IHostLifetime — используется для управления жизненным циклом приложения (в частности — для остановки приложения).
    Кроме того, через механизм параметров (Options) передается ссылка на объект (неименованный) класса HostOptions для задания настроек размещения. В .NET Core 3.1 он имеет единственное свойство ShutdownTimeout(назначение понятно из названия), в .NET 6 к нему прибавляется еще и свойство BackgroundServiceExceptionBehavior одноименного перечислимого типа, указывающее, что делать при возникновении необработанного исключения в одной из фоновых служб (наследников класса BackgroundService), вариантов возможно два: остановить приложение (по умолчанию) или игнорировать исключение.
    Все эти параметры являются обязательными (в случае равенства любого из них null выбрасывается исключение).
    В .NET 6 в конструктор размещения передаются еще два параметра — реализация интерфейса окружения размещения IHostEnvironment (о нем говорилось в первой статье по ссылке в начале) и провайдер для корневого каталога приложения — экземпляр класса PhysicalFileProvider. Все эти параметры сохраняются во внутренних переменных класса Internal.Host.

Сам метод StartAsync сначала выполняет ряд предварительных действий.


Ряд предварительных действий

Он создает комбинированный источник маркеров отмены (Cancellation Token Source) из двух маркеров: переданного в метод StartAsync параметра — он служит для отмены выполнения этого метода, и из свойства ApplicationStopping интерфейса IHostApplicationLifetime (ссылка на него была получена через параметр конструктора) — он служит для оповещения (путем отмены этого маркера) о начале завершения приложения. После этого метод StartAsync запускает метод WaitForStartAsync интерфейса IHostLifetime (ссылка на него также была получена через параметр конструктора) для ожидания процесса запуска, с аргументом — макером отмены от созданного ранее комбинированого источника, и асинхронно ожидает (через await) его завершения. После завершения метод StartAsync проверяет методом ThrowIfCancellationRequested, что комбинированный маркер не был отменен (т.е. не был отменен ни один из входящих в него маркеров) и в случае, если это не так, выбрасывает исключение OperationCanceledException. Ну и сам оператор await тоже может выбросить исключение, ранее выброшенное в ожидаемой задаче.


Затем метод StartAsync получает из контейнера сервисов список (точнее — последовательность, она имеет тип IEnunerable<IHostedService>) всех реализаций IHostedService в приложении, и в цикле для каждой из реализаций из списка запускает его метод StartAsync с аргументом — переданным как параметр маркером отмены запуска и асинхронно ожидает (await) завершения возвращаемой из этого метода задачи запуска размещенной службы. Для иллюстрации — кусок исходного кода NET 6, который это делает:


            _hostedServices = Services.GetService<IEnumerable<IHostedService>>();
            foreach (IHostedService hostedService in _hostedServices)
            {
                await hostedService.StartAsync(combinedCancellationToken).ConfigureAwait(false);
               //...дополнительная обработка для BackgroundServices (см. ниже) пропущена
            }

В .NET 6 для каждой из реализаций фоновых сервисов — классов-наследников BackgoundService в том же цикле делаются дополнительные действия для поддержки этого фонового сервиса.


дополнительные действия

Прежде всего, для этого действия для каждого такого фонового сервиса запускается асинхронный метод TryExecuteBackgroundServiceAsync, который по факту выполняется независимо от метода StartAsync (ожидание его не производится, ни синхронно, ни асинхронно). Этот метод, прежде всего, получает через свойство BackgroundService.ExecuteTask ссылку на задачу, выполняющую фоновый сервис, и если она в этом свойстве указана — пытается дождаться (асинхронно, через await) ее завершения. При анализе результата завершения метод TryExecuteBackgroundServiceAsync выполняет какие-либо действия только в случае возникновения исключения в задаче: он перехватывает выброшенное оператором await исключение, если оно произошло. Выброшенное исключение анализируется на предмет того, что это было исключение типа OperationCancelledException, выброшенное при отмене задачи из-за прекращения работы приложения (это считается нормальным завершением фоновой службы). Если это так, то исключение игнорируется. В противном случае считается, что произошел сбой фоновой службы. В этом случае исключение записывается в журнал (через сохраненную в конструкторе ссылку на реализацию ILogger), после чего проверяется свойство BackgroundServiceExceptionBehavior экземпляра класса HostOptions (полученного также через конструктор), и если в нем указано, что в случае сбоя фоновой службы необходимо завершить выполнение приложения — инициируется (методом StopApplication интерфейса IHostApplicationLifetime) его завершение, о чем также производится запись в журнал.
Смысл этих действий состоит в реализации (по возможности) поведения, указанного в поле BackgroundServiceExceptionBehavior параметров размещения HostOptions. Увы, такая реализация несовершенна: она срабатывает только для фоновых служб, основанных на классе BackgroundService и только если в конструкторе этой реализации не забыт вызов конструктора базового класса, который, собственно и устанавливает нужное свойство, позволяющее отслеживать задачу, выполняющую фоновый сервис.


Но в нашем случае, так как класс сервиса веб-приложения GenericWebHostService не является фоновым сервисом — наследником BackgroundService, то это не важно.


И, наконец, когда завершение всех методов StartAsync размещенных служб завершилось, метод StartAsync размещения (Internal.Host) фиксирует в журнале факт запуска (через ILogger.Started) и сообщает реализации интерфейса отслеживания событий жизненного цикла приложения IHostApplicationLifetime через метод NotifyStarted() о том, что запуск всех размещенных сервисов завершен.


Запуск размещенного сервиса веб-приложения (Конструктор класса GenericWebHostService и его метод StartAsync)


Итак, в процессе запуска всех реализаций интерфейса IHostingService в качестве одной их таких реализаций создается экземпляр внутреннего, доступного только внутри соответствующей сборки ASP.NET Core, класса GenericWebHostService размещенной службы веб-приложения и вызывается его метод StartAsync (тоже асинхронный, как и одноименный метод Internal.Host, из которого он вызывается).
Поскольку класс GenericWebHostService извлекается из контейнера сервисов как одна из реализаций сервиса(интерфейса) IHostedService, то его конструктор может иметь множество параметров-зависимостей, получающих значение (иначе говоря — разрешающихся) через механизм контейнера сервисов. И таких параметров у конструктора этого класса действительно немало.


Параметры конструктора класса GenericWebHostService

И в ASP.NET Core 3.1, и в ASP.NET Core 6 в конструктор класса размещенной службы веб-приложения GenericWebHostService передаются параметры следующих типов:


  1. IOptions<GenericWebHostServiceOptions> — обеспечивает доступ к переданному через механизм параметров экземпляру класса GenericWebHostServiceOptions — информации для размещенной службы приложения; переданный экземпляр сохраняется в публично доступном (формально: напоминаем, что сам класс — внутренний) автоматическом свойстве Options. Класс GenericWebHostServiceOptions содержит несколько публичных автоматических свойств: ConfigureApplication, в котором хранится Configure-делегат для создания конвейера обработчиков для веб-приложения (о его использовании см. следующий раздел), HostingStartupExceptions (типа AggregateException), содержащее список исключений, возникших при загрузке стартовых сборок размещения (подробности — в статье по ссылке [2] в начале), и WebHostOptions (одноименного типа), содержащий различную информацию о конфигурации созданного веб-приложения (подробности — в той же статье, из нее в StartAsync используется только флаг CaptureStartupErrors, указывающий, что в случае ошибки создания конвейера обработчиков для веб-приложения, нужно создать (только при работе в окружении Development или при установленном флаге DetailedErrors в этом же свойстве) фиктивное веб-приложение, показывающее информацию об этом исключении, уже упомянутый флаг DetailedErrors и список стартовых сборок размещения, доступный через метод GetFinalHostingStartupAssemblies() — для вывода его в журнал);
  2. IServer — ссылка на сервис веб-прослушивателя, она сохраняется в публичном автоматическом свойстве Server; сконфигурированное веб-приложение запускается методом StartAsync именно этого сервиса;
  3. ILoggerFactory — сервис создателя служб ведения журналов; конструктор с ее помощью создает две службы ведения журналов: с именем "Microsoft.Hosting.Lifetime" для ведения журнала ключевых событий, ссылка на нее сохраняется в публичном автоматическом свойстве LifetimeLogger, и с именем "Microsoft.AspNetCore.Hosting.Diagnostics" для ведения журнала общего назначения, ссылка на нее сохраняется в публичном автоматическом свойстве Logger и передается в методе StartAsync в конструктор класса HostingApplication;
  4. DiagnosticListener — ссылка на объект, позволяющий получать широкий круг оповещений о событиях, происходящих внутри процесса веб-приложения; ссылка сохраняется в публичном автоматическом свойстве DiagnosticListener и передается в методе StartAsync в конструктор HostingApplication;
  5. IHttpContextFactory — ссылка на сервис, предоставляющий объекты контекстов запроса (типа HttpContext) для обработки запросов; ссылка сохраняется в публичном автоматическом свойстве HttpContextFactory и передается в методе StartAsync в конструктор HostingApplication;
  6. IApplicationBuilderFactory — ссылка на сервис, позволяющий получить реализацию интерфейса IApplicationBuilder в методе StartAsync(про его использование см. следующий раздел), она сохраняется в публичном автоматическом свойстве ApplicationBuilderFactory;
  7. IEnumerable<IStartupFilter> — список сервисов, реализующих интерфейс фильтра конфигурирования конвейера компонентов-обработчиков (middleware); он сохраняется в публичном автоматическом свойстве StartupFilters и используется при создании конвейера компонентов-обработчиков (middleware), см. следующий раздел;
  8. IConfiguration — ссылка на интерфейс доступа к параметрам конфигурации, сохраняется в публичном автоматическом свойстве Configuration;
  9. IWebHostEnvironment — ссылка на интерфейс доступа к параметрам окружения, сохраняется в публичном автоматическом свойстве HostingEnvironment;

В ASP.NET Core 6 к ним добавляются еще два параметра типов ActivitySource и DistributedContextPropagator, они сохраняются в одноименных публичных автоматических свойствах и передаются в методе StartAsync в конструктор объекта HostingApplication.


Метод StartAsync, который завершает конфигурирование веб-приложения и запускает его на исполнение в веб-сервере, принимает единственный параметр — маркер отмены запуска. Этот метод делает следующее.
Во-первых, в случае если список прослушиваемых адресов веб-прослушивателя пуст, но может быть настроен, метод StartAsync устанавливает этот список из конфигурации


Подробности

Метод StartAsync получает через свойство Features главного интерфейса веб-прослушивателя IServer, доступ к функции (feature) настройки адресов — интерфейсу IServerAddressesFeature, проверяет, что список прослушиваемых адресов и портов для функции — Addresses — существует, доступен для записи и пуст. Если все эти условия выполняются, то метод StartAsync добавляет в этот список адреса и порты из конфигурации, а также устанавливает из конфигурации свойство PreferHostingUrls функции IServerAddressesFeature. Вот так выглядит (в ASP.NET Core 6) код, который это выполняет


            var serverAddressesFeature = Server.Features.Get<IServerAddressesFeature>();
            var addresses = serverAddressesFeature?.Addresses;
            if (addresses != null && !addresses.IsReadOnly && addresses.Count == 0)
            {
                var urls = Configuration[WebHostDefaults.ServerUrlsKey];
                if (!string.IsNullOrEmpty(urls))
                {
                    serverAddressesFeature!.PreferHostingUrls = WebHostUtilities.ParseBool(Configuration, WebHostDefaults.PreferHostingUrlsKey);
                    foreach (var value in urls.Split(';', StringSplitOptions.RemoveEmptyEntries))
                    {
                        addresses.Add(value);
                    }
                }
            }

Во-вторых, метод StartAsync создает конвейер из компонентов-обработчиков запросов(middleware) Именно так, по смыслу — "компонент-обработчик запроса" или просто "компонент-обработчик" — я буду называть здесь и далее в тексте то, что в оригинальной документации именуется невнятным словом middleware). Как происходит этот процесс, я подробно опишу в следующем разделе.
Созданный конвейер компонентов-обработчиков сохраняется в переменной-делегате application, имеющей тип RequestDelegate.


Несколько слов о RequestDelegate

RequestDelegate — это очень важный для ASP.NET Core тип делегата. Этот делегат представляет собой ссылку на метод-функцию, принимающую контекст запроса типа HttpContext, выполняющуюся асинхронно и возвращающую объект задачи (типа Task), который позволяет отслеживать ее выполнение. То есть, формально тип RequestDelegate совпадает со специализацией обобщенного типа Func<HttpContext,Task>, но он настолько важен для ASP.NET Core, что удостоился своего собственного имени.


Возвращаемый после создания конвейера RequestDelegate представляет собой первый компонент-обработчик в конвейере. Вообще, здесь и везде далее в тексте под конвейером компонентов-обработчиков (middleware) имеется в виду ссылка на его первый компонент-обработчик, потому что именно он отвечает за вызов второго компонента, второй — за вызов третьего, и так далее. То есть, каждый компонент-обработчик в конвейере отвечает за вызов следующего компонента-обработчика: он может вызывать его в любом месте своего кода, не вызывать вообще и даже, в случае разветвления пути обработки запроса, вызывать первый компонент-обработчик из другого конвейера-ветки. Весь контекст обработки запроса при этом хранится в объекте HttpContext, передаваемом в каждый из компонентов-обработчиков в качестве параметра. Такая структура конвейера позволяет быть ему очень гибким и способным обработать самые разные запросы. Но подробности этого — материал совсем для другой статьи.


Третье, что делает метод StartAsync — это создает объект внутреннего, извне ASP.NET Core недоступного типа HostingApplication, реализующий обобщенный интерфейс веб-приложения IHttpApplication. Этот интерфейс содержит методы, которые веб-прослушиватель использует в процессе обработки, вызывая их в определенном порядке для каждого пришедшего на веб-прослушиватель запроса. Именно в этом цикле и происходит вызов созданного конвейера компонентов-обработчиков запроса (middleware), однако описание этого процесса выходит далеко за рамки статьи.


Параметры конструктора HostingApplication

Вкратце для понимания связи HostingApplication с кодом инициализации веб-приложения полезно упомянуть, какие параметры и зачем передаются в конструктор объекта класса HostingApplication. Прежде всего это — конвейер компонентов-обработчиков (middleware), который, собственно, содержит специфический для веб-приложения код, и сервис, предоставляющий объекты контекстов запроса (типа HttpContext) для обработки запросов — реализация интерфейса IHttpContextFactory, который используется непосредственно при формировании контекста обработки (см. следующий скрытый текст), а предоставленный им контекст запроса — в обработке запроса. Другие параметры, передаваемые в конструктор — ссылка на службу ведения журнала общего назначения из свойства Logger класса GenericWebHostService, объект DiagnosticListener для передаче оповещений о стадиях обработки запроса, а в ASP.NET Core 6 еще и ссылки на сервисы ActivitySource и DistributedContextPropagator — сразу передаются в конструктор внутреннего объекта в HostingApplication, формирующего и распространяющего диагностическую информацию. Больше нигде в коде методов класса HostingApplication эти параметры не используются, а потому при анализе работы этого компонента на них можно не обращать особого внимания.


Затем метод StartAsync запускает запускает веб-прослушиватель методом StartAsync главного интерфейса веб-прослушивателя IServer и асинхронно дожидается (через await) окончания запуска веб-прослушивателя. Код:


            var httpApplication = new HostingApplication(application, Logger, DiagnosticListener, ActivitySource, Propagator, HttpContextFactory);
            await Server.StartAsync(httpApplication, cancellationToken);

(cancellationToken — это параметр метода GenericWebHostService.StartAsync).


Дополнительная информации о классе HostingApplication и его интерфейсе IHttpApplication

Для справки, интерфейс IHttpApplication имеет следующие методы: инициализация контекста обработки — CreateContext, запуск конвейера компонентов-обработчиков (middleware) с контекстом запроса HttpContext (инициализованным в процессе инициализации контекста обработки) — ProcessRequestAsync и освобождения контекста обработки — DisposeContext, в процессе которого также происходит и освобождение контекста запроса. Контекст обработки не следует путать с контекстом запроса, это — некий отдельный тип. На самом деле, интерфейс веб-приложения IHttpApplication является обобщенным, и он как раз специализируется параметром типа контекста обработки.
Конкретная реализация интерфейса IHttpApplication в HostingApplication специализирована вложенным в этот класс типом HostingApplication.Context, доступным только внутри этой сборки (и даже не по всему ASP.NET Core). Этот же тип, которым специализирован интерфейс IHttpApplication, используется как параметр-тип и при вызове метода IServer.StartAsync, который тоже является обобщенным (в приведенном выше примере кода это не видно, т.к. параметр-тип выводится автоматически). Но так как методы и свойства типа контекста обработки внутри класса, реализующего IServer, приходится вызывать не только из метода StartAsync, в который этот тип передается, то для вызова методов IHttpApplication из других методов этого класса, в которые этот тип не передается, приходится прибегать к трюкам. В частности, в классе MessagePump, который реализует IServer для работы с прослушивателем режима ядра в Windows, для этих целей применен вспомогательный обобщенный класс, реализующий интерфейс IHttpApplication, специализированный типом Object. Методы этого класса являются переходниками, вызывающими методы реализации интерфейса IHttpApplication, специализированного параметром-типом этого вспомогательного класса, для этого в конструктор вспомогательного класса передается ссылка на IHttpApplication, специализированный нужным параметром-типом.
Внутри обощенного метода StartAsync создается экземпляр этого вспомогательного обобщенного класса, специализированный параметром-типом метода StartAsync — т.е. вспомогательный обобщенный класс получается специализированным тем же типом, что и интерфейс IHttpApplication, реализованный классом HostingApplication. Другие методы MessagePump вызывают методы этого экземпляра (напоминаем, что они реализуют методы IHttpApplication<Object>), которые внутри объекта вспомогательного класса транслируются в вызовы методов IHttpApplication, специализированного типом контекста из HostingApplication, ссылка на который была передана через параметр-тип StartAsync.
Зачем надо было делать так сложно, я не понимаю, возможно это просто возникло само с течением времени из-за ошибки в оригинальном дизайне архитектуры. Но, тем не менее, оно работает именно так.


После завершения ожидания запуска веб-прослушивателя метод StartAsync записывает в журнал некоторую информацию. Во-первых, в журнал записываются комбинации адрес/порт, на которых веб-сервер осуществляет обработку запросов. Во-вторых, если это было запрошено в конфигурации, в журнал записывается полученная на этапе построения веб-приложения информация о загруженных стартовых сборках размещения, а если при вызове их методов конфигурирования произошли исключения — то и информация обо всех произошедших исключениях. Подробнее о загрузке стартовых сборок размещения можно почитать в статье по ссылке [2] в начале этой статьи.
На этом выполнение метода StartAsync класса размещенной службы веб-приложения GenericWebHostService завершается: запуск данной размещенной службы закончен.


Создание конвейера компонентов-обработчиков запроса(middleware). Класс ApplicationBuilder.


Вернемся однако немного назад, к процессу построения конвейера компонентов-обработчиков запроса(middleware). Его стоит рассмотреть подробнее: в нем есть ряд интересных деталей. Начать следует с того, как задается список устанавливаемых компонентов-обработчиков запроса. Всем, кто читал документацию или одну из многочисленных книг или статей по программированию на ASP.NET Core, известен как минимум, один способ, зависящий от того, какой шаблон инициализации приложения ASP.NET Core используется. По своей сути эти способы сходны: в их основе лежит создание списка конфигурирующих делегатов — действий для подключения каждого из используемых компонентов-обработчиков, а после добавления в список всех конфигурирующих делегатов — построение конвейера из этого списка. Объект, который служит для ведения списка и последущего построения конвейера, имеет интерфейс IApplicationBuilder. Именно через методы этого интерфейса выполняется и добавление в список конфигурирующих делегатов, и передача и дополнительной информации, и построение конвейера. Однако для простоты конфигурирования фреймворка пользователями разные компоненты-обработчики дополнительно определяют для этого интерфейса свои методы расширения (обычно с названиями типа UseXXX()), через которые обычно и производится добавление и настройка конфигурирующих делегатов для этих компонентов. Методы эти специфичны для каждого из компонентов. Компонентов этих в ASP.NET Core существует немало, а потому эти их специфические методы расширения IApplicationBuilder я в этой статье рассматривать не буду.


Для старого доброго шаблона Gentric Host создание конфигурирующих делегатов выполняется через Configure-метод, обычно (его имя может для ряда вариантов использования отличаться) имеющий имя Configure. Необходимый для подключения объект, реализующий интерфейс IApplicationBuilder, передается него в качестве первого (или единственного) параметра. Этот метод может быть задан несколькими разными способами.


Способы задания Configure-метода

Основной, рекомендуемый в документации способ — сделать Configure-метод методом Startup-класса, использование которого указывается в делегате, передаваемом как аргумент в метод расширения интерфейса IHostBuilder.ConfigureWebHostDefaults (или — ConfigureWebHost, на котором основан предыдущий метод). Startup-класс указывается обычно как параметр-тип обобщенного метода расширения UseStartup интерфейса IWebHostBuilder, вызываемого внутри этого делегата. Есть и другой вариант — указать Startup-класс как обычный аргумент типа Type в необобщенной форме одноименного метода. При этом в качестве Configure-метода используется метод Startup-класса, имя которого определяемым соглашениями, изложенными в документации. Обычно этот метод имеет имя Configure, и он обязан иметь первый параметр типа IApplicationBuilder. Также он может иметь дополнительные параметры, значения которых получаются путем внедрения зависимостей из контейнера сервисов перед запуском Configure-метода.
Альтернативный способ определения метода Configure — передача делегата для используемого в качестве Configure метода через метод расширения интерфейса IWebHostBuilder, тоже имеющий имя Configure, вызываемый в том же делегате — параметре ConfigureWebHostDefaults/ConfigureWebHost.
Еще один альтернативой вариант — использование стартовой сборки, содержащей Startup-класс, имя которой указывается в перегруженной версии все того же метода расширения UseStartup(необобщенного ) как значение строкового параметра. В этом случае в качестве метода Configure используется выбираемый по имени метод выбираемого по имени класса из этой сборки. Правила выбора имен (и метода, и класса) определяются соглашениями, изложенными в документации, в типовом случае это — метод с именем Configure класса с именем Startup.
Кроме того, шаблон Generic Host позволяет вместо указания Startup-класса, Configure-метода или стартовой сборки в делегате-параметре методов ConfigureWebHostDefaults или ConfigureWebHost использовать механизм конфигурирования через стартовые сборки размещения (они же — просто стартовые сборки). Эти сборки указываются в атрибуте НоstingStartup сборки, содержащей веб-приложение или Startup-класс, вместе с конфигурирующими классами в них — которые обязаны реализовывать интерфейс IHostingStartup.

Как устроены и работают все эти способы, я подробно описывал в статье [2], посвященной инициализации веб-приложения ASP.NET.
Любой из этих способов приводит к созданию делегата для вызова Configure-метода. Этот процесс (он включает для ряда способов задания Configure-метода ещё и создание кода для внедрения зависимостей для задания значений его аргументов) я тоже достаточно подробно описывал в той же статье. Созданный делегат передается в конструктор экземпляра класса GenericWebHostService, реализующего размещенное веб-приложение, через механизм парметров (Options pattern) путем внедрения зависимости от интерфейса IOptions<GenericWebHostServiceOptions>: этот делегат содержится в поле ConfigureApplication получаемого через этот интерфейс (его свойство Value) экземпляра этого класса.
В новомодном варианте конфигурирования через WebApplicationBuilder/WebApplication, интерфейс IApplicationBuilder, через который добавляются устанавливаемые компоненты-обработчики запроса, реализуется самим классом WebApplication. Так что методы расширения, добавляющие компоненты-обработчики, можно выполнять прямо для класса webAplication, безо всякой мороки с делегатами.
Как это работает

(Предупреждение: в этом скрытом куске текста, чтобы разъяснить, как это работает, мне пришлось забежать немного вперед, так что рекомендуется читать его не сразу, а после прочтения раздела до конца)
В конструкторе класса WebApplication создается и сохраняется в доступном только внутри сборки свойстве ApplicationBuilder и только для чтения объект одноименного класса ApplicationBuilder, реализующий интерфейс интерфейс IApplicationBuilder (того же, что используется по умолчанию для реализации этого интерфейса и в методе GenericWebHostService.StartAsync, об этом будет дальше). И все вызовы методов IApplicationBuilder для объекта класса WebApplication переадресуются этому внутреннему объекту ApplicationBuilder — в результате список конфигурующих действий-делегатов для установки компонентов-обработчиков запроса создается внутри него. А чуть раньше, в методе Build экземпляра класса WebApplicationBuilder, с помощью которого был создан рассматриваемый объект WebApplication, создается в качестве Configure-метода делегат, который передается (в целом, обычным образом через GenericWebHostServiceOptions с помощью упомянутогого выше метода расширения Configure, подробности я этой статье я рассматривать не буду) для вызова метода WebApplicationBuilder.ConfigureApplication (далее — просто ConfigureApplication). Этот метод играет роль того костыля, которым приколачивается набор операций конфигурирования конвейера компонентов-обработчиков (middleware), произведенных через методы WebApplication, к стандартной логике конфигурирования конвейера компонентов-обработчиков (middleware) в методе StartAsync. Дело в том (об этом будет написано далее), что Configure-метод — это не единственный источник конфигурирующих делегатов: другим источником служат регистрации сервисов-интерфейсов IStartupFilter (они тоже будут рассмотрены далее), которые могут добавить в список конфигурирующие делегаты как до, так и после Configure-метода. При этом положение осложняется тем, что нельзя просто так взять список конфигурирующих делегатов из объекта ApplicationBuilder, внутреннего для класса WebApplication, и вставить делегаты из этого списка в нужное место списка в IApplicationBuilder, который используется для построения конвейера в методе StartAsync (обычно этот IApplicationBuilder — тоже ApplicationBuilder, но из-за того, что создается он там не нормальным явным образом операцией new, а через одно место, называемое IoC, это не гарантируется). И наоборот — взять список из того IApplicationBuilder и вставить его в WebApplication.ApplicationBuilder — тоже нельзя. Все что доступно — это добавить в список в IApplicationBuilder какой-нибудь свой конфигурирующий делегат методом Use и преобразовать этот список в конвейер методом Build. Поэтому для того, чтобы встроить результат конфигурирующих действий, накопленных в списке WebApplication.ApplicationBuilder, в то место создаваемого в StartAsync конвейера, где должны быть конфигурирующие действия из Configure-метода, метод WebApplicationBuilder.ConfigureApplication прибегает к хитрым трюкам. Ключевым трюком является вставка через метод IApplicationBuilder.Use вот такого хитрого конфигурирующего делегата (он выражен лямбда-функцией — аргументом метода Use):


            app.Use(next =>
            {
                _builtApplication.Run(next);
                return _builtApplication.BuildRequestDelegate();
            });

Здесь app — это параметр метода ConfigureApplication: IApplicaionBuilder, используемый для создания конвейера, _builtApplication — экземпляр WebApplication, созданный в WebApplicationBuilder его методом Build, а вызов BuildRequestDelegate — это фактически вызов IApplicationBuilder.Build для WebApplication — точнее, вызов того внутреннего метода, который реализует этот метод интерфейса в этом классе. Не правда ли, все просто и очевидно (неправда, и по-моему эти пять строчек заслужили право стать примером для статьи, как писать формально чистый, но совершенно непонятный код — статьи, которую я обязателно напишу, когда-нибудь).
Но если разобраться то можно понять, что тут происходит. Конфигурирующий делегат, добавленный методом IApplicationBuilder.Use в методе ConfigureApplication, вызывается уже в процессе работы метода IApplicationBuilder.Build, при построении конвейера (назовем этот конвейер внешним, сам процесс построения конвейера целиком будет рассмотрен позднее). На момент вызова конфигурирующего делегата его параметр next (хотя авторы кода постарались это скрыть с помощью лямбда-выражения ;-), но мы знаем, что он имеет тип RequestDelegate) содержит ссылку уже построенную часть конвейера, который строится методом Build, конфигурирующие делегаты для построения которой были добавлены через реализации IStartupFilter после вызова метода ConfigureApplication (при построении конвейера, как мы увидим, список конфигурирующих делегатов проходится в порядке обратном порядку добавления). Конфигурирующий делегат, созданный ConfigureApplication, добавляет (методом расширения Run) в хвост списка конфигурирующих делегатов WebApplication терминальный конфигурирующий делегат, вызывающий эту, уже построенную, концевую часть внешнего конвейера. После этого конфигурирующий делегат строит внутренний конвейер из списка конфигурирующих делегатов в WebApplication, причем в конце этого конвейера, благодаря добавленному в конец конфигурирующему делегату, будет вызвана та самая уже построенная часть внешнешнего конвейера. Далее построение внешнего конвейера идет обычным образом за счет конфигурирующих делегатов, добавленных через реализации IStartupFilter до вызова метода ConfigureApplication.
Помимо этого метод WebApplicationBuilder.ConfigureApplication копирует в словарь Properties используемого для конфигурирования объекта, реализующего IApplicationBuilder, все значения всех ключей из словаря Properties объекта WebApplication (т.е — встроенного в него объекта ApplicationBulder) для того, чтобы оба конвейера были построены согласованно. Кроме того, он делает ещё пару трюков для обеспечения правильного функционирования механизма маршрутизации запросов HTTP к конечным точкам после предыдущего хитрого трюка, но так как маршрутизация в этой статье не рассматривается, то и эти трюки я тоже оставлю без рассмотрения.


Теперь настал черед подробно рассмотреть свойства и методы интерфейса IApplicationBuilder. Данный интерфейс содержит пару информационных свойств: ApplicationServices — ссылку на корневой контейнер сервисов приложения и ServerFeatures — набор функций HTTP (features) предоставляемый сервером приложений.Также этот интерфейс содержит свойство Properties — словарь (типа Dictionary<String,object>), который позволяет обмениваться информацией между различными конфигурирующими делегатами, работающими совместно (например — обеспечивающими маршрутизацию запросов HTTP).
Для добавления конфигурирующих действий в список используется метод Use, принимающий в качестве параметра конфигурирующий делегат. Конфигурирующий делегат вызывается в процессе построения конвейера компонентов-обработчиков (middleware), рассмотренном ниже. Он принимает один параметр типа RequestDelegate, представляющий уже построенную ранее часть конвейера, и создает компонент-обработчик (делегат типа RequestDelegate), выполняющий свою стадию конвейера обработки и, при необходимости, в нужном месте передающий управление (обычно асинхронно с ожиданием по await) в созданную ранее последующую часть конвейера через переданную ему в параметре ссылку. Конфигурирующий делегат возвращает ссылку на созданный им компонент-обработчик (middleware) как на новый конвейер. Управление в последующую часть конвейера может, на усмотрение компонента-обработчика, передаваться до, посередине или после обработки для текущей стадии, а может и не передаваться вообще (такой элемент конвейера называется терминальным), а кроме того, в определенных случаях управление может передаваться в ответвление конвейера, за создание которого тоже отвечает конфигурирующий делегат. Метод Use возвращает ссылку на IApplicationBuilder для возможности объединения конфигурирующих операций в цепочку.
Метод New создает и возвращает ссылку на новый экземпляр класса, реализующего IApplicationBuilder, содержащий в своем словаре Properties копию словаря текущего интерфейса IApplicationBuilder, но пустой список конфигурирующих делегатов. Этот метод используется для создания ответвлений конвейера (например — запускающихся вместо основного конвейера при выполнении какого-либо условия).
И, наконец, метод Build создает из текущего списка конфигурирующих делегатов конвейер компонентов-обработчиков (middleware) и возвращает ссылку (типа RequestDelegate) на этот конвейер.


Реализацией IApplicationBuilder по умолчанию является класс ApplicationBuilder.


Откуда он берется

В ASP.NET Core ничто не делается просто, поэтому там нельзя просто так взять и создать объект нужного класса через new() — это, видите ли, нарушает некий важный теоретический принцип (IoC, если кому интересно). Вместо этого, через внедрение зависимостей в конструктор класса GenericWebHostService сервиса веб-приложения передается — нет, ещё не ссылка на реализацию IApplicationBuilder с помощью ApplicationBuilder — а сервис-интерфейс типа IApplicationBuilderFactory, единственный метод которого возвращает (в реализации по умолчанию, впрочем в боевой программе ее вряд ли кто будет менять) объект класса ApplicationBuilder. Вот такой тут сделан FizzBuzz Enterprise Edition.


Самым интересным для нас в этом классе являются реализации методов Use и Build интерфейса IApplicationBuilder. Конфигурирующие делегаты хранятся в объекте класса ApplicationBuilder в во внутреннем поле — списке обобщенного типа List, специфицированном типом конфигурирующего делегата (имя поля — _components) в порядке добавления. Метод Use просто добавляет переданный ему делегат в конец этого списка. Метод Build сначала создает концевой терминальный элемент конвейера (обычно возвращающий код статуса 404, но в случае использования маршрутизации — выбрасывающий исключение) в качестве текущего конвейера. Затем метод Build вызывает конфигурирующие делегаты, хранящиеся в списке, в порядке, обратном порядку их в этом списке, от последнего до первого — то есть, в порядке, обратном прорядку добавления. Каждому вызывающему конфигурирующему делегату в качестве аргумента передается текущий (уже созданный) конвейер, а полученный от него результат становится новым текущим конвейером. После того, как метод Build применяет первый конфигурирующий делегат из числа хранящихся в списке, он возвращает его результат в качестве созданного конвейера. Таким образом обеспечивается вызов компонентов-обработчиков (midleware) в конвейере в порядке их добавления в список. Вот код, который это делает:


            RequestDelegate app =  
               //... Здесь создается концевой компонент-обработчик
            for (var c = _components.Count - 1; c >= 0; c--)
            {
                app = _components[c](app);
            }
            return app;

Реализация других свойств и методов в ApplicationBuilder

Среди свойств интерфейса IApplicationBuilder основным является словарь Properties. Два других свойства — ApplicationServices и ServerFeatures — реализуются через него: они хранятся в этом словаре под специальными ключами — ApplicationServicesKey = "application.Services" и ServerFeaturesKey = "server.Features" — соответственно. Свойство Properties для обычного (первичного) объекта ApplicationBuilder реализовано на основе обычного для словаря обобщенного класса Dictionary, специализированного как Dictionary<String, Object?>. Метод New, создающий вторичный объект ApplicationBuilder реализован с помощью специального частного конструктора, который для свойства Properties вместо обычного Dictionary использует хитрую конструкцию CopyOnWriteDictionary. Это — внутренний для ASP.NET Core класс, базирующийся на словаре Dictionary исходного класса. Пока в словарь класса CopyOnWriteDictionary не добавлено ни одного нового значения (что часто бывает при использовании вторичного ApplicationBuilder), он для поиска значений использует базовый словарь, но перед первым же добавлением значения он создает свой объект словаря и копирует в него все значения из базового словаря, после чего работает исключительно с этой копией словаря.


Следующий вопрос — откуда берутся конфигурирующие делегаты. Мы уже рассмотрели Configure-метод (и его аналог в шаблоне приложения WebApplication), но это — не начало истории, а ее конец. Полностью история выглядит следующим образом. После получения реализации IApplicationBuilder метод StartAsync класса GenericWebHostService начинает заполнять список конфигурирующих делегатов в нем. И начинает он это делать с того, что берет список всех регистраций сервиса фильтра IStartupFilter. Этот список конструктор класса GenericWebHostService получает путем внедрения зависимостей и сохраняет в автоматическом свойстве StartupFilters. Для каждой регистрации IStartupFilter из этого списка вызывается его метод, который обеспечивает добавление нужных конфигурирующих делегатов, определяемых этим фильтром, в список в IApplicationBuilder. Конфигурирующие делегаты из фильтров делятся на начальные и завершающие. Начальные конфигурирующие делегаты добавляются фильтрами в порядке появления реализаций IStartupFilter в списке — он совпадает с порядком регистрации описателей сервисов IStartupFilter перед построением контейнера сервисов. Потом производится вызов Configure-метода, добавляющий свои конфигурирующие делегаты в текущее место списка. После этого фильтры добавляют свои завершающие конфигурирующие делегаты в порядке, обратном порядку фильтров в списке StartupFilter.
Однако обратите внимание, что в предыдущей фразе написано не "добавляет", а "обеспечивает добавление", и написано это не просто так. Потому что в коде ASP.NET царит функциональщина, и в частности она проявляется и в этом месте.


Подробности: как именно заполняется список конфигурирующих делегатов

Единственный метод интерфейса IStartupFilter — Configure — принимает в качестве параметра делегат, сигнатура которого совпадает с сигнатурой делегата для вызова Configure-метода: Action<IApplicationBuilder> и возвращает делегат с такой же сигнатурой. Предполагается, что возвращаемый делегат сначала производит, если это ему необходимо, предварительное конфигурирование — добавляет в список IApplicationBuilder начальные конфигурирующие делегаты и производит другие действия. Затем возвращаемый методом Configure делегат вызывает переданный в метод параметр-делегат. После его вызова возвращаемый Configure делегат производит, если это ему необходимо, завершающее конфигурирование — добавляет завершающие конфигурирующие делегаты и производит другие действия.
Метод StartAsync вызывает методы Configure фильтров — сервисов IStartupFilter в порядке, обратном их порядку в списке — от последнего и до первого. Стартовый (т.е. — для последнего фильтра в списке) вызов Configure получает в качестве аргумента ссылку на делегат вызова Configure-метода, а каждый последующий (для предыдущего фильтра) — ссылку на делегат, которую вернул вызванный перед этим метод последующего фильтра в списке. В результате в методе StartAsync получается цепочка делегатов, начинающаяся с делегата, который вернул метод Configure первого фильтра IStartupFiter в списке, и заканчивающаяся делегатом вызов Configure-метода. Метод StartAsync вызывает первой делегат этой цепочки — и цепочка производит конфигурирующие действия в описанном выше порядке. Вот код, который это делает (все просто и очевидно, да ;-) ):


              var configure = Options.ConfigureApplication;             
              //...
               foreach (var filter in StartupFilters.Reverse())
                {
                    configure = filter.Configure(configure);
                }
                configure(builder);

Но, так или иначе, в результате внутри ApplicatinBuilder получается список конфигурирующих делегатов в нужном порядке — сначала начальные, помещаемые фильтрами IStartupFilter, начиная с первого в порядке регистрации фильтра до последнего, после них — помещаемые Configure-методом, и наконец — завершающие, помещаемые фильтрами после Configure-метода в обратном порядке. Затем метод Build, как описано выше, строит из этого списка конвейер компонентов-обработчиков (middleware), и в результате компоненты-обработчики, определяемые фильтрами IStartupFilter при предварительном конфигурировании оказываются в начале конвейера в порядке регистрации сервисов фильтра, за ним идут в порядке добавления компоненты-обработчики, добавленные Configure-методом в порядке их добавления, в конце конвейера оказываются компоненты-обработчики, определяемые фильтрами IStartupFilter при заключительном конфигурировании в порядке, обратном порядку регистрации фильтров, и завершает конвейер концевой компонент, возвращающий, если до него дошло управление, ошибку 404 или, в случае некорректно настроенной маршрутизации — выбрасывающий исключение.


Заключение


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


PS


Про КДПВ

КДПВ ("картинка для привлечения внимания") в начале статьи отражает мое видение того, что находится под капотом у ASP.NET Core реально, а не на маркетинговых материалах Microsoft. Взята она отсюда и сопровождается в оригинале следующим текстом:


на фестивале «Автоэкзотика» в Москве мне показали «двадцать первую», под капотом у которой был настоящий огород — земля, цветочки, лепесточки… И при этом «Волга» была на ходу!

Вот и ASP.NET Core — оно тоже на ходу. И ездит оно, ну, совсем неплохо. И это главное. Особенно если под капот без нужды не заглядывать.

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


  1. md_backend_binance
    26.07.2022 11:14

    Вердикт по прочтению 3х ваших статей. Вы пишите очень сложно , с каждом абзацем все больше и больше путаешься. И еще такой эффект есть , как будто прочитал 4 листа А4 с миллионом данных и в итоге это мы всё еще про 1 строчку кода.

    Если будете писать след статью, попробуйте формат минимум писанины , максимум схем.


  1. mvv-rus Автор
    26.07.2022 15:23

    Благодарю за комментарий.
    То, что тексты эти получились сложные — это я понимаю, а в этой статье — даже прямо написал про это.
    К сожалению, эти статьи — про действительно сложный (ну, хорошо — который мне кажется сложным) код, и как упростить изложение его логики, я не знаю: при минимуме писанины (я пробовал) теряется, на мой взгляд, слишком много нюансов.