В этой статье я хочу рассказать о том, как расширяются возможности ASP.NET Core по обработке запросов к веб-серверу с помощью самостоятельно написанных компонентов конвейера обработки.
Обычно для разработки серверной части веб-приложений (по-простому — бэка) с помощью ASP.NET Core имеет смысл использовать базирующиеся на нем фреймворки высокого уровня: MVC Core, Razor Pages и т.д.: они позволяют с минимумом усилий решать типовые задачи. Но встречаются задачи нетиповые, которые требуют для своего решения перейти на более низкий уровень (или, по крайней мере, понимать, что на этом уровне происходит) — на уровень базовых возможностей ASP.NET Core. И в этой статье как раз рассказывается об использовании одной из этих базовых возможностей — компонентов middleware, из которых создается конвейер обработки запросов к веб-серверу. Далее в статье я буду называть их компонентами-обработчиками, потому что официальный перевод из документации от MS — "ПО промежуточного слоя" — он некрасив и не описывает их функции. Конкретно в статье рассматривается, какие средства предоставляет фреймворк ASP.NET Core для создания самописных компонентов-обработчиков. И особое внимание уделено тому, как эти компоненты могут получить доступ к другой базовой возможности ASP.NET Core — сервисам, которые зарегистрированы в контейнере сервисов (он же — "DI-контейнер"), как к стандартным для ASP.NET Core, так и к самописным.Эта статья является четвертой статьей из цикла "Под капотом" (предыдущие статьи цикла: [1], [2], [3]), посвященного внутренней логике работы ASP.NET Core. Поэтому она содержит информацию не просто о том, как писать самописные обработчики, а о том, как именно эти обработчики встраиваются в конвейер и получают управление.
Помня о занудности предыдущих статей цикла, я постарался сделать эту статью менее занудной (а что из этого получилось — судить читателю) В частности, чтобы сократить объем статьи, я по возможности убрал под спойлеры необязательную для понимания статьи информацию.
Во-первых — детали реализации, с названиями конкретных методов и подробным описанием их логики работы. Я не думаю, что большинству читателей эта информация понадобится, но раз я уж это выяснил, то почему бы про это не написать? Во-вторых — сведения, которые наверняка известны почти всем читающим, но которые я все же решил упомянуть, потому что текст статьи во многом на них опирается. Так что если вам при чтении что-то кажется непонятным про какой-нибудь теоретический термин или элемент .NET — имеет смысл посмотреть соответствующий спойлер.
Но, в целом, при первом прочтении статьи спойлеры можно пропустить.
Введение
Каждому разработчику на ASP.NET Core, хотя бы немного выходившему за пределы высокоуровневых фрейворков (MVC, Razor Pages и т.п.), известно, что обработка запросов к веб-серверу в ASP.NET происходит в конвейере из компонентов-обработчиков (middleware). Разработчик при этом волен использовать как многочисленные стандартные компоненты-обработчики, так и добавлять в конвейер свои, самописные компоненты(custom middleware).
Внутри компонентов-обработчиков разработчик может использовать многочисленные служебные компоненты или сервисы, зарегистрированные в ASP.NET Core в специальном контейнере сервисов. Сервисы представляют собой интерфейсы (или, иногда, классы) .NET. Через методы и свойства этих интерфейсов (или классов) компонент-обработчик может получить доступ к самым разным возможностям: конфигурации, связанной с приложением (IConfiguration), внешним заранее заданным типизированным данным через механизм параметров (IOptions и другие связанные с ним сервисы) и т.д. Сервис становится доступным компоненту-обработчику через ссылку на объект класса, реализующего запрошенный интерфейс (или, если сервис являетися классом — на объект самого этого класса или класса, унаследованного от него). Эту ссылку возвращает контейнер сервисов в результате запроса к нему с указаним типа запрашиваемого сервиса. Таким образом, код компонента-обработчика не зависит от конкретной реализации сервиса (а часто и не знает ее). Эта концепция взаимодействия кода, непосредственно выполняющего обработку, и используемого им вспомогательного сервисного кода в теории именуется "инверсия управления" (англ. Inversion of Control, IoC). Экземпляр какого именно класса будет получен от контейнера для реализации сервиса, указывается совершенно независимо на этапе инциализации приложения при регистрации сервиса в списке для контейнера, предшествующей его созданию. Подробнее о том, как это происходит в приложении на ASP.NET Core, я написал в первой статье цикла.
В теории существуют два основных варианта поддержки инверсии управления с помощью контейнера сервисов. Теоретически предпочтительным является т.н. внедрение зависимостей (англ. Dependency Injection, DI).
Этот способ для теории настолько важен, что часто сам контейнер сервисов называют именно контейнером внедрения зависимостей (DI-контейнером), хотя он является всего лишь средством создания и хранения сервисов, а внедрение зависимостей производится другими механизмами. При использовании внедрения зависимостей все ссылки на требуемые сервисы указываются в списке параметров конструктора класса и/или параметров вызываемого метода. При создании разработчиком экземпляра класса или вызове метода нужные сервисы неким скрытым от разработчика образом получаются из контейнера сервисов и передаются в качестве аргументов в конструктор или метод.
К сожалению, для разработчиков на C# механизм внедрения зависимостей в самом языке и его базовых библиотеках отсутствует. Внедрение зависимостей возможно только средствами фреймворка (в нашем случае — ASP.NET Core) — в тех вариантах создания объекта и вызова метода, которые реализуются через фреймворк, и для которых такое внедрение зависимостей предусмотрено.
Второй, теоретически менее почтенный, но зато не требующий практически никакой помощи со стороны фреймворка, метод реализации инверсии управления — активное получение
нужного сервиса из контейнера в коде компонента-обработчика, в результате чего происходит поиск и/или создание реализующего его объекта средствами контейнера. В теории такой метод называется «шаблон обнаружителя сервисов» (англ. Service Locator Pattern). Для получения ссылки на сервис используется метод GetService основного интерфейса контейнера сервисов — IServiceProvider. Для удобства разработчиков в .NET определены на основе этого метода интерфейса несколько методов расширения для интерфейса IServiceProvider с разными дополнительными возможностями, позволяющие получить сервис удобным для разработчика образом.
- Упомянутые методы расширения определены в классе ServiceProviderServiceExtensions
- На самом деле, и внедрение зависимостей во фреймворке — оно тоже реализовано через такое же активное получение сервисов из контейнера (напрямую либо посредством методов класса ActivatorUtilities), только этим занимается код самого фреймворка, а не код, написанный разработчиком.
У контейнера сервисов, предназначенного для использования в .NET (в частности, у стандартного контейнера .NET, используемого по умолчанию в ASP.NET Core), существуют (как минимум) две формы: корневой контейнер сервисов и контейнер сервисов ограниченной области. Первый — это основной контейнер приложения, существующий все то время, пока выполняется приложение. Вторая форма — это производный от корневого контейнер, который содержит те же сервисы, но имеет меньшее время жизни. В ASP.NET Core контейнер ограниченной области создается для каждого запроса на время его выполнения, ссылка на него помещается в контекст запроса (который имеет тип HttpContext) в свойство RequestServices.
При регистрации сервисов указывается, сколько времени должен гарантированно существовать объект сервиса, который реализует этот сервис: время жизни объекта. Для него есть три возможных варианта. В первом варианте, Transient (время жизни по потребности), контейнер не хранит ссылку на созданный объект сервиса. При каждом новом запросе сервиса создается новый объект, который существует по обычным правилам — до тех пор, пока в программе сохраняется ссылка на него, после чего объект сервиса утилизируется сборщиком мусора. В противоположном варианте, Singleton (постоянное время жизни), контейнер сервисов создает единственный объект, ссылка на который сохраняется в корневом контейнере сервисов, и именно ссылка этот объект возвращается при получении сервиса из контейнера. При этом контейнер сервисов ограниченной области переадресует запросы на получение объекта с временем жизни Singleton в корневой контейнер сервисов, так что эти запросы все равно возвращают ссылку на этот единственный объект в корневом контейнере. Третий вариант, Scoped (время жизни ограниченной области), предназначен только для получения сервисов из контейнера ограниченной области. При создании объекта контейнер ограниченной области сохраняет ссылку на него, а при запросе на получение — возвращает сохраненую ссылку, поэтому в пределах ограниченой области (в конвейере ASP.NET Core — при обработке одного запроса) все обращения для получения сервиса с временем жизни ограниченной области возвращают один и тот же объект. Контейнеры разных ограниченных областей (для разных запросов HTTP) возвращают для одного и тоже сервиса с временем жизни Scoped ссылки на разные объекты. По завершении обработки запроса ссылка на связанный с ним объект сервиса освобождается вместе с контейнером ограниченной области (и впоследствии этот объект подвергается сборке мусора).
Жизненный цикл конвейера компонентов-обработчиков (middleware) состоит из трех этапов (подробно они описаны в предыдущей статье цикла). На первом этапе, этапе конфигурирования, производится добавление в список объекта-построителя конвейера (он представлен интерфейсом IApplicationBuilder) конфигурирующих делегатов. Конфигурирование специфично для приложения, и поэтому оно выполняется кодом, который пишет разработчик приложения. В большинстве своем используемые конфигурирующие делегаты предоставляются фреймворком. И, хотя фреймворк ASP.NET Core дает возможность создавать самописные конфигурирующие делегаты и добавлять их в список методом Use интерфейса IApplicationBuilder, но в данной статье эта возможность не рассматривается. На следующем этапе, этапе построения конвейера, эти добавленные в список объекта-построителя конфигурирующие делегаты вызываются, и каждый из конфигурирующих делегатов создает и добавляет в конвейер свой компонент-обработчик. Компоненты-обработчики могут быть как частью фреймворка (базового ASP.NET Core или фреймворка высокого уровня, такого как MVC Core), так и самописные, которым как раз и посвещена эта статья. И, наконец, на этапе обработки запросов компоненты-обработчики выполняются в указанном при создании конвейера порядке.
На этапе конфигурирования и построения конвейера для получения сервисов используется корневой контейнер сервисов приложения: на этапе конфигурирования он доступен через свойство ApplicationServices объекта-построителя (интерфейс IApplicationBuilder), а для использования корневого контейнера на этапе построения конвейера ссылку на него следует передать конфигурирующему делегату с предыдущего этапа. На этапе выполнения запросов используется, как правило, контейнер сервисов ограниченной области, связанной с запросом. Ссылка на него передается в контексте запроса (объекте типа HttpContext).
На всякий случай, вдруг кто не знает, расскажу про делегаты.
Делегат — это переменная, которая содержит ссылку на метод, по которой этот метод можно вызвать (в принципе, в .NET есть вариант делегата, который может содержать ссылку на несколько методов, которые поочередно вызываются при вызове такого делегата, но в статье я такие делегаты рассматривать не буду, т.к. для ее целей они не интересны). Вызов осуществляется либо просто указанием списка аргументов в скобках после имени переменной-делегата, либо ее методом Invoke (это — экземплярный метод в типе делегата).
Как известно, методы в C# могут быть статическими, не относящимися ни к одному экземпляру объекта своего класса, а могут быть экземплярными, которые работают с конкретным экземпляром класса. Соответственно, делегат для вызова экземплярного метода должен содержать ссылку на экземпляр объекта, который должен быть как-то создан заранее, а для вызова статического метода экземпляр объекта не требуется.
Часто в качестве делегата используется лямбда-функция. При этом, будет ли делегат, созданный на ее основе, содержать ссылку на статический метод, зависит от того, используются ли в лямбда-функции внешние по отношению к ней переменные — те, которые не являются параметром лямбда-функции и которые определены вне ее: это локальные переменные метода, где определена лямбда-функция, поля экземпляра объекта, в котором находится этот метод и т.д. Если внешние переменные не используются, то созданный на основе лямбда-функции делегат будет содержать ссылку на статический метод, и для его работы никакой экземпляр никакого объекта не нужен. Если же внешние переменные используются, то для лямбда-функции компилятором генерируется тип объекта состояния и код для его создания и копирования в него внешних переменных, а созданный в результате делегат содержит ссылку на экземплярный метод этого объекта. Если такой, использующий внешние переменные, делегат на основе лямбда-функции создается часто (например, как параметр некоего часто вызываемого метода), то, поскольку при каждом вызове этого метода создается объект состояния, это может повлиять на производительность программы. Поэтому в новых версиях языка C# появился квалификатор static для лямбда-функции: он явно указывает компилятору, что эта лямбда-функция не должна ссылаться на внешние переменные, а попытка на них все же сослаться в программе в такой лямбда-функции приведет к ошибке компиляции.
Этап конфигурирования конвейера компонентов-обработчиков
Приложения, использующие ASP.NET Core, могут быть созданы на базе одного из нескольких шаблонов, исторически сменявших друг друга. И, что важно для этой статьи, этап конфигурирования конвейера компонентов-обработчиков реализован по-разному в разных шаблонах.
Самый старый из шаблонов, Web Host, является теперь устаревшим (хотя и до сих пор может использоваться), и его я затрагивать в этой статье особо не буду. Упомяну лишь, что этап конфигурирования конвейера компонентов-обработчиков выглядит (но только выглядит — под капотом там сильно другие классы) в нем примерно так же, как и сменившем его шаблоне Generic Host.
До недавнего времени, в версиях ASP.NET Core 3.x и 5, актуальным шаблоном для построения веб-приложений ASP.NET Core был шаблон Generic Host.
Особенностью шаблона Generic Host является то, что набор действий по конфигурированию веб-приложения в нем при первом взгляде выглядит как последовательность неких магических заклинаний, смысл которых зачастую не очень понятен простому разработчику (особенно — не знакомому с функциональным программированием). Ранее я написал две статьи (первые две статьи цикла: [1], [2] ), в которых разобрал, что находится во внутренностях шаблона Generic Host, на чем основана и как работает магия, необходимая для конфигурирования веб-приложения. Тех, кто захочет их просмотреть, предупреждаю сразу, что это действительно длинные и нудные статьи, причем те знания о внутренностях Generic Host, которые в них изложены, практически не нужны для успешного написания Web-приложения по этому шаблону: MS хорошо постаралась, чтобы избежать такой необходимости. Так что я рекомендую читать их, только если вам очень любопытно или очень нужно знать как устроен Generic Host внутри).
В шаблоне Generic Host конфигурирование конвейера обычно производится в методе Configure Startup-класса. Впрочем, если другая функциональность Startup-класса в приложении не требуется, есть возможность вместо Startup-класса просто указать делегат, выполняющий конфигурирование конвейера.
Основной способ указания Startup-класса — явное указание его типа методом расширения UseStartup интерфейса IWebHostBuilder. Этот интерфейс передается в качестве параметра в делегат, передаваемый как аргумент в метод расширения ConfigureWebHostDefaults (или ConfigureWebHost) интерфейса IHost, который используется в шаблоне Generic Host для конфигурирования ASP.NET Сore. В этом делегате обычно и вызывается метод UseStartup. Этот метод имеет две формы, по-разному указывающие тип Startup-класса: обобщенную (тип Startup-класса указывается в качестве параметра типа) и необобщенную (тип Startup-класса указывается как обычный параметр типа Type).
Еще один способ указания Startup-класса — через указание полного имени стартовой сборки, в которой он находится, при этом в качестве Startup-класса используется класс с именем типа, определяемым соглашениями, обычно это имя — "Startup". Полное имя сборки указывается как аргумент для третьей формы метода расширения UseStartup интерфейса IWebHostBuilder — той, что принимает строковый параметр.
Ну, и для полноты следует упомянуть, что упомянутый выше делегат для выполнения конфигурирования конвейера при отстуствии Startup-класса указывается как аргумент для метода расширения Configure интерфейса IWebHostBuilder.
Метод Configure Startup-класса позволяет использовать внедрение зависимостей. Он принимает один обязательный параметр — ссылку на объект построителя конвейера.
Этот параметр имеет интерфейсный тип IApplicationBuilder. Через этот интерфейс в объект построителя конвейера, реализующий его, помещается список конфигурирующих делегатов для компонентов-обработчиков, вызываемых на стадии построения конвейера, подробности об этом есть в предыдущей статье цикла) в разделе, посвященном созданию конвейера
Также метод Configure может принимать дополнительные параметры. Значения этих параметров получаются путем внедрения зависимостей.
Это делает делегат, создаваемый для вызова метода Configure, подробности можно найти во второй статье цикла, в разделе с описанием внутреннего метода UseStartup класса GenericWebHostBuilder.
Значения этих дополнительных параметров могут использоваться при создании конфигурирующих делегатов.
Если вместо Startup-класса указан выполняющий конфигурирование конвейера делегат, то использовать внедрение зависимостей в шаблоне Generic Host невозможно.
Начиная с ASP.NET Core 6.0, появился альтернативный шаблон веб-приложения — минимальный API на базе классов WebApplication/WebApplicationBuilder. Он имеет преимущество перед Generic Host в том, что конфигурирование веб-приложения в нем выглядит уже не как набор магических заклинаний (ну, почти), а как простая последовательность действий по конфигурированию. Действия выглядят как вызовы методов этих классов (или методов их свойств) и делаются в обычном императивном стиле. Под капотам у этого шаблона, однако, остался все тот же Generic Host. А потому внутренняя логика работы веб-приложения осталась практически неизменной, и при создании и работе веб приложения используются те же самые классы, плюс несколько дополнительных, транслирующих вызовы методов конфигурирования для классов WebApplication/WebApplicationBuilder в вызовы для классов, реализующих шаблон Generic Host. Но внешне процесс конфигурирования веб-приложения в минимальном API выглядит по-другому, значительно проще.
Этот новый шаблон имеет, тем не менее, существенные отличия для этапа конфигурирования конвейера компонентов-обработчиков, ибо Startup-класс в нем недоступен и больше не используется. Как результат, использование контейнера сервисов для внедрения зависимостей на этапе конфигурирования, возможное в шаблоне Generic Host в методе Configure Startup-класса, в шаблоне минимального API на базе WebApplication/WebApplicationBuilder недоступно.
Однако активное получение сервиса (Service Locator pattern) можно использовать в любом из шаблонов, т.к. ссылка на контейнер сервисов (который, в данном случае, является корневым контейнером приложения) доступна через свойство ApplicationServices интерфейса IApplicationBuilder: в метод Configure Startup-класса этот интерфейс передается как параметр, а в минимальном API этот интефейс реализует сам класс WebApplication.
Варианты реализации самописных компонентов-обработчиков и этапы построения конвейера и выполнения запросов.
Очевидный и не требующий помощи фреймворка ASP.NET Core вариант реализации самописного компонента-обработчика — это создать и добавить в список методом IApplicationBuilder.Use конфигурирующий делегат, который создаст и подключит в конвейер компонент-обработчик. Этот обработчик должен быть делегатом типа RequestDelegate т.е. принимать единственный параметр HttpContext — контекст запроса и возвращать объект задачи(Task), через который можно отследивать его завершение. В таком сценарии использование внедрения зависимостей невозможно, однако необходимые сервисы можно получить путем активного получения их из соответствующего этапу контейнера сервисов: IApplicationBuilder.ApplicationServices для этапов конфигурирования и построения конвейера и HttpContext.RequestServices для этапа выполнения запросов. В данной статье, однако, как уже было написано выше, такой вариант создания самописного компонента-обработчика без помощи фреймворка рассматриваться не будет.
При помощи фреймворка ASP.NET Core есть несколько вариантов создания самописных компонентов-обработчиков для использования в конвейере обработки запросов. Для всех этих вариантов фреймворк предоставляет конфигурирующие делегаты для этапа построения конвейера, а код самописных компонентов-обработчиков вызывается уже на этапе обработки запросов. Имеются следующие варианты реализации самописных обработчиков: на базе делегата, на базе класса с использованием соглашения о вызовах и на базе фабрики классов. Действия, выполняемые фреймворком на этапах построения конвейера и обработки запроса, в значительной мере зависят от выбранного варианта, поэтому их стоит рассмотреть отдельно для каждого из вариантов.
Принципы реализации логики обработчиков
Все варианты реализации самописного компонента-обработчика (реализующий его делегат или метод InvokeAsync/Invoke реализующего его класса) строятся по примерно одинаковой схеме.
Прежде всего, обработчики разных запросов выполняются параллельно. При этом любой из вариантов может выполняться асинхронно, поскольку он возвращает объект задачи (Task), по которой отслеживается его завершение. В отличие от ASP.NET Framework, в ASP.NET Core отсутствует связанный с запросом контекст синхронизации.
А, значит, любой асинхронный код, запускаемый из обработчика, может выполняться в другом потоке пула параллельно с кодом самого обработчика. С одной стороны, это может осложнить реализацию обработчика из-за необходимости синхронизации доступа к переменным, доступным и для основного кода обработчика, и для запущенного асинхронного кода. Но, с другой стороны, при запуске такого асинхронного кода не приходится использовать ConfigureAwait, чтобы избежать взаимоблокировки.
Если компонент-обработчик не является терминальным (последним выполняющемся в конвейере), то в его обязанность входит вызвать следующий компонент-обработчик в конвейере. А если логика обработчика включает использование результатов вызова следующей стадии конвейера, то обработчик должен асинхронно дождаться завершения этого вызова и только после этого запустить код, использующий результаты. Проще всего реализовывать подобные методы или делегаты как асинхронные, в парадигме async/await: метод или делегат помечается ключевым словом async, а там, где требуется дождаться результата выполнения, используется оператор await. При этом для вызова обработчика следующей стадии конвейера (во фрагменте ниже он представлен как делегат next, а переменная context содержит контекст запроса типа HttpContext) получается примерно такой код:
//Код выполняющийся до вызова следующей стадии:
//...
await next(context); //Вызов следующей стадии
//Код выполняющийся после вызова следующей стадии
//...
return;
Однако если все операции, реализующие логику компонента-обработчика, являются синхронными и не требуют использования результатов выполнения следующих стадий конвейера, то метод/делегат обработчика можно не помечать как async, а в качестве результата вернуть результат вызова следующей стадии конвейера, примерно как в следующем фрагменте:
//Код выполняющийся синхронно до вызова следующей стадии:
//...
return next(context); //Вернуть результат вызова следующей стадии
//Код выполняющийся после вызова следующей стадии отсутствует
Для получения информации о запросе и записи ответа на него обработчик использует контекст запроса (типа HttpContext), который передается в него в качестве параметра. Если обработчику для работы нужны какие-то сервисы, и они не были переданы в него с помощью внедрения зависимостей, то он может получить их из контейнера сервисов ограниченной области, связанного с запросом. Ссылку на этот контейнер содержит свойство RequestServices контекста запроса.
Компоненты-обработчики на базе делегатов
Самый простой, но дающий наименьшие возможности вариант реализации самописного компонента-обработчика — создать делегат, который будет вызываться в конвейере на этапе обработки запроса и реализовывать логику его обработки. Для конфигурирования обработчика на базе делегата используется метод расширения Use (одна из его двух перегруженных форм) для интерфейса IApplicationBuilder. В качестве аргумента в этот метод передается функция-делегат, реализующая логику компонента-обработчика. Обе формы метод расширения Use имеют сходные сигнатуры примерно такие (многоточие стоит в месте, где сигнатуры для разных форм отличаются)
public static IApplicationBuilder Use(this IApplicationBuilder app, Func<...> middleware)
Внедрение зависимостей для этого варианта ни на этапе построения конвейера, ни на этапе выполнения запросов не используется. Но на этапе выполнения запроса код компонента-обработчика может использовать активное получение сервисов из контейнера. Необходимая для этого ссылка на контейнер сервисов ограниченной области для запроса доступна через свойство RequestServices контекста запроса (типа HttpContext), получаемого как первый параметр.
Конфигурирующий делегат, созданный методом расширения Use, добавляет в конвейер прокси-обработчик, вызывающий функцию-делегат самописного обработчика, реализующую логику его работы, и возвращающий ее результат.
Эта функция возвращает, как и положено обработчику, задачу (объект типа Task) для отслеживания своего завершения. В качестве параметров она принимает ссылку на контекст запроса (типа HttpContext) и делегат для вызова следующей стадии конвейера. Две упомянутые выше формы метода Use отличаются как раз сигнатурой этого делегата, но в любом случае делегат, вызывающий следующую стадию, возвращает результат этого вызова, т.е. задачу (объект типа Task) для отслеживания завершения следующей стадии.
В первой форме метода расширения Use этот делегат — функция без параметров, а во второй — имеет тип RequestDelegate (тот же, что компоненты-обработчики в конвейере) и принимает параметр типа HttpContext. То есть, передаваемые в метод расширения Use делегаты могут иметь одну из двух следующих сигнатур
//Первая форма, делегат параметров не имеет
delegate Task Middleware1(HttpContext context, Func<Task> next);
//Вторая форма, в отличие от первой переданный делегат принимает параметр - контекст запроса
delegate Task Middleware2(HttpContext context, RequestDelegate next);
Первая форма метода расширения Use имеет значительно больше накладных расходов при использовании, чем вторая. Потому что при использовании первой формы метода Use делегат, установленный в конвейер, должен при каждом запросе сохранять для делегата вызова следующей стадии, который передается в функцию самописного обработчика, ссылку на контекст запроса, меняющийся от вызова к вызову. Потому что иначе этому делегату будет неоткуда получить контекст запроса для вызова следующего компонента конвейера. То есть, этот делегат должен содержать ссылку не на статический, а на экземплярный метод с экземпляром объекта, хранящего ссылку на контекст запроса. А так как код конфигурирующего делегата задает делегат вызова следующей стадии в виде лямбда-функции, то этот экземпляр объекта состояния приходится при каждом запросе создавать заново (почему — см. информацию под спойлером "Слово о делегатах" выше). То есть, при каждом вызове самописного обработчика, добавленного первой формой метода Use, в куче создается новый объект, который становится ненужным после выполнения запроса, а это неизбежно приведет к более частым вызовам сборщика мусора.
Вторая форма метода расширения Use лишена этого недостатка: делегат вызова следующей стадии получает контекст запроса через свой параметр, а потому это делегат может содержать ссылку на статический метод, для которого не требуется экземпляр объекта, содержащий ссылку на контекст запроса.
К компонентам-обработчикам на базе делагата относится также терминальный компонент-обработчик на базе делегата. Слово "терминальный" означает, что этот обработчик не вызывает следующий в конвейере, а завершает обработку. Для конфигурирования такого обработчика используется метод расширения Run интерфейса IApplicationBuilder, которому в качестве аргумента передается делегат, реализующий логику обработки. Этот делегат имеет тот же тип RequestDelegate, что и обработчики в конвейере:
public static void Run(this IApplicationBuilder app, RequestDelegate handler)
Метод расширения Run добавляет в список построителя конвейера (методом Use интерфейса IApplicationBuilder) конфигуририрующий делегат, который игнорирует переданую ему ссылку на следующий обработчик в конвейере и возвращает для установки в конвейер делегат-параметр, переданный в метод расширения Run.
Компоненты-обработчики на базе класса с вызовом по соглашению.
Другой вариант создания самописного компонента-обработчика — это создать класс, метод которого будет реализовывать логику компонента-обработчика. На самом деле, за таким описанием скрываются два разных варианта: на базе класса с вызовом по соглашению (описан в этом разделе) и на базе фабрики классов (описан в следующем разделе).
Чтобы класс был пригоден для создания компонента-обработчика с вызовом по соглашению, он должен удовлетворять следующим условиям:
- Класс должен содержать конструктор, принимающий первый параметр типа RequestDelegate (обязательный) и, возможно, другие необязательные параметры.
- Класс должен содержать ровно один метод с именем InvokeAsync или Invoke (но не оба сразу); этот метод дожен возвращающать объект задачи (типа Task или производных от него) для отслеживания выполнения запроса, метод должен принимать первый параметр — контекст запроса, имеющий тип HttpContext, и, возможно, другие необязательные параметры.
- Класс не должен реализовывать интерфейс IMiddleware (класс, реализующий этот интерфейс, используется для варианта на базе фабрики классов).
Для конфигурирования компонента-обработчика на базе класса с вызовом по соглашению (и, забегая вперед, на базе фабрики — тоже) используется метод расширения UseMiddleware интерфейса IApplicationBuilder.
У метода UseMiddleware есть две формы: обобщенная, параметр-тип которой является классом, реализующим обработчик, и необобщенная. Обобщенная форма обязательных параметров не имеет, у необобщенной формы есть единственный обязательный параметр — тип класса, реализующего обработчик.
На самом деле, обобщенная форма метода UseMiddleware является чисто вспомогательной, используемой для упрощения кода приложения. Ее фактическая реализация просто вызывает необобщенную форму с параметром-типом, как обязательным аргументом и всеми необязательными параметрами в качестве необязательных аргументов. Основная реализация метода UseMiddleware — необобщенная.
У метода расширения UseMiddleware может быть произвольное количество необязательных дополнительных параметров. Эти параметры предназначены для передачи в конструктор класса, реализующего компонент-обработчик. Экземпляр класса компонента-обработчика, используемый на этапе обработки запросов, создается один раз, на этапе построения конвейера, и впоследствии этот единственный экземпляр используется для обработки всех запросов, в том числе — выполняющихся параллельно. Это накладывает ограничения на свойства и методы, изменяющие состояние экземпляра: конкурентный доступ из нескольких потоков с целью изменения состояния должен, как минимум, синхронизироваться. А ещё лучше, если состояние объекта класса компонента-обработчика на этапе выполнения не меняется вообще. В конце концов, если информацию о состоянии надо хранить, то лучше использовать возможности самописных request features ("функций запросов" в официальном переводе Microsoft), доступных через свойство Features контекста запроса — даже при том, что, к сожалению, эта возможность расширения функциональности ASP.NET Core плохо документирована.
Для определения значений аргументов конструктора (помимо обязательного первого) при создании экземпляра класса компонента-обработчика наряду с параметрами, переданными в метод UseMiddleware, может использоваться внедрение зависимостей (с использованием корневого контейнера приложения). При этом внедрение зависимостей имеет меньший приоритет, чем переданные параметры: для конкретного аргумента оно используется, только если параметр нужного типа не был передан в числе парметров метода UseMiddleware.
На этапе выполнения запроса вызывается метод InvokeAsync или Invoke созданного экземпляра класса-обработчика. При этом для получения значений необязательных параметров, передаваемых в метод, используется внедрение зависимостей. Необходимые сервисы получаются из контейнера ограниченной области, связанной с запросом, доступного через контекст запроса — HttpContext.RequestServices.
Метод UseMiddleware прежде всего определяет, какой из вариантов компонента-обработчика на базе класса будет использоваться. Проверка производится по тому, реализуется ли классом, переданным в UseMiddleware, интерфейс IMiddleware. Если этот интерфейс реализуется, то класс будет использован в варианте на базе фабрики классов (см. следующий раздел). В этом случае метод UseMiddleware сначала проверяет, не переданы ли ему дополнительные аргументы (потому что в варианте на базе фабрики классов эти аргументы использовать нельзя), и если дополнительных аргументов нет — вызывает внутренний метод UseMiddlewareInterface для конфигурирования использования класса компонента-обработчика в варианте на базе фабрики классов. Иначе конфигурирование для варианта вызова по соглашению производится внутри самого метода UseMiddleware.
Конфигурирующий делегат, созданный методом UseMiddleware, на этапе создания конвейера прежде всего проверяет, удовлетворяет ли переданный в метод класс остальным условиям для варианта вызова по соглашению. Для этого, в частности, он получает информацию (типа MethodInfo) о методе, который реализует логику компонента-обработчика (имещего имя Invoke или InvokeAsync). Если все условия соблюдены, он создает экземпляр класса компонента-обработчика с помощью вызова метода ActivatorUtilities.CreateInstance. В метод CreateInstance передаются следующие аргументы: тип класса, ссылка на корневой контейнер сервисов приложения (IApplicationbuilder.ApplicationServices) и массив с параметрами для конструктора — ссылка на следующий компонент в конвейере в качестве первого элемента плюс содержимое массива дополнительных параметров, переданных в метод UseMiddleware, если они есть. Вызываемый метод CreateInstance с помощью отражения ищет подходящий конструктор класса (принимающий все переданные параметры плюс, возможно, какие-то дополнительные), устанавливает значение каждого из передаваемых в него аргументов либо из полученного массива параметров (содержащего ссылку на следующий компонент и дополнительные параметры метода UseMiddleware), либо — если параметров нужного типа нет в числе переданных — из контейнера сервисов. Затем метод CreateInstance создает экземпляр объекта переданного в метод CreateInstance класса, вызывая этот конструктор, и возвращает полученный экземпляр.
Если данный компонент-обработчик — не терминальный, то вызванный конструктор должен сохранить в созданном экземпляре ссылку на следующий элемент конвейера (значение первого параметра), для его последующего использования в методе Invoke/InvokeAsync, реализующего логику компонента-обработчика.
Далее конфигурирующий делегат проверяет, есть ли у метода Invoke/InvokeAsync дополнительные параметры кроме контекста запроса (типа HttpContext). Если дополнительных параметров у этого метода нет, то, поскольку его сигнатура соответствует сигнатуре компонента конвейера (RequestDelegate), конфигурирующий делегат возвращает для добавления в конвейер делегат, вызающий этот метод для созданного ранее экземпляра класса компонента-обработчика (делегат создается вызовом MethodInfo.CreateDelegate). Если же метод Invoke/InvokeAsync имеет дополнительные параметры, то конфигурирующий делегат проделывает более сложную работу для создания вызывающего его прокси-делегата, который возвращается для добавления в конвейер: этот делегат создается динамически с использованием возможностей класса Expression. Созданный код, вызываемый через такой прокси-делегат, включает в себя получение значений всех дополнительных параметров из контейнера сервисов ограниченной области для запроса, доступного через контекст запроса (свойство HttpContext.RequestServices), с целью реализации внедрения зависимостей и вызов метода Invoke/InvokeAsync класса компонента-обработчика для ранее созданного экземпляра этого класса.
Компоненты-обработчики на базе фабрики классов.
Третий вариант создания самописного компонента-обработчика — это создать класс обработчика, экземпляры которого будут создаваться для обработки каждого из запросов фабрикой классов.
Для создания такого обработчика класс должен реализовывать интерфейс IMiddleware. Этот интерфейс имеет единственный метод InvokeAsync, который реализует логику компонента-обработчика. Именно он вызывается на этапе выполнения запроса.
Для конфигурирования компонента-обработчика на базе фабрики классов используется тот же самый метод расширения UseMiddleware интерфейса IApplicationBuilder, что и для класса с вызовом по соглашению. В этот метод передается тип класса-обработчика, реализующего интерфейс IMiddleware. Но в использовании метода UseMiddleware для этого варианта есть важное отличие — в метод UseMidlewre в этом варианте нельзя передавать дополнительные параметры.
Для использования самописных обработчиков на базе фабрики классов необходимо, чтобы фабрика классов Middleware (сервис с интерфейсом IMiddlewareFactory) была зарегистрирована в контейнере сервисов. ASP.NET Core регистрирует стандартную реализацию сервиса фабрики (с временем жизни ограниченой области, связанной с запросом) — класс MiddlewareFactory. Однако возможно и создание самописной реализации IMiddlewareFactory, и тогда будет использоваться она. Стандартная реализация IMidllewareFactory для использует получения экземпляра класса обработчика контейнер сервисов (ограниченной области, связанной с запросом), поэтому класс обработчика должен быть зарегистрирован в контейнере сервисов. Рекомендуется, чтобы этот сервис имел время жизни Transient или Scoped, чтобы для обработки каждого запроса создавался новый экземпляр класса обработчика. Так как класс обработчика в стандартной реализации получается из контейнера сервисов, то в его конструкторе могут использоваться параметры, значения которых получаются через внедрение зависимостей: оно выполняется самим контейнером сервиса при создании реализующего сервис объекта.
Работа по созданию конфигурирующего делегата для этого варианта реализации выполняется внутренним методом UseMiddlewareInterface класса UseMiddlewareExtensions (в котором также определен и метод расширения UseMiddleware). Этот конфигурирующий делегат создает делегат-обработчик, добавляемый в конвейер, который выполняет следующие действия:
- Получает из контейнера сервисов ограниченной области запроса сервис фабрики (интерфейс IMiddlewareFactory). В стандартной реализации (класс MiddlewareFactory) в конструктор этого класса передается путем внедрения зависимостей ссылка на этот контейнер сервисов, и она запоминается во внутренней переменной.
- Получает от фабрики методом IMiddlewareFactory.Create объект обработчика. Класс объекта обработчика передается как параметр метода Create. В стандартной реализации IMiddlewareFactory этот метод получает объект переданного в параметре класса из контейнера сервисов ограниченной области запроса, ссылка на который была сохранена в конструкторе.
- Вызывает и асинхронно ожидает (в части try блока try/finally) код, выполняющий логику обработчика — метод IMiddleware.InvokeAsync. Этот метод принимает в качестве параметров контекст запроса (тип HttpContext) и ссылку на следующий элемент конвейера (тип RequestDelegate), он возвращает объект задачи (Task), позволяющий отслеживать его завершение.
- Освобождает (в части finally блока try/finally) полученный объект обработчика методом IMiddlewareFactory.Release. В стандартной реализации этот метод ничего не делает, т.к. обработчики являются сервисами, полученными из контейнера сервисов (ограниченной области, связанной с запросом), и потому жизненным циклом объектов обработчиков управляет этот контейнер сервисов.
Заключение.
Собственно, это все основное, что стоит сказать про создание самописных компонентов-обработчиков (middleware) для конвейера обработки запросов, добавление в конвейер которых производится средствами фрейворка ASP.NET Core. Единственное, что ещё стоит добавить — это то, что указанные методы создания и конфигурирования компонентов-обработчиков используются и для некоторых штатных, входящих в состав самого фрейворка ASP.NET Core компонентов, так что знакомство с ними может быть полезно и при анализе работы этих компонентов.
AlexDevFx
Спасибо за статью. Позвольте замечание:
Объект не реализует сервис, он является экземпляром сервиса. Реализация сервиса - конкретный класс.
mvv-rus Автор
Спасибо за замечание: там действительно была двусмысленность, связанная с тем, что, хотя сервис реализуется классом, код, запрашивающий сервис, получает ссылку на объект класса реализующего сервис.
Я переработал этот кусок с целью добиться большей ясности.