Andriy Shyrokoryadov

.Net developer, data scientist

№4 Связующие программные компоненты в Asp.Net Core [#52].

Текст к видео "Связующие программные компоненты в Asp.Net Core" на канале YouTube

Приветствую Вас на моём канале. Сегодня мы продолжим изучение компонентов приложения ASP.NET Core. В прошлый раз мы познакомились с внедрением зависимостей, сегодня мы продолжим изучение и познакомимся более детально со связующими программными компонентами или на английском языке middleware. Данный видео урок будет представлен следующим образом – сначала немного теории, которая будет чередоваться с примерами в приложении, а потому я покажу пример создания своего собственного связующего программного компонента. Изначально я планировал показать 2 примера собственных связующих программных компонентов, но потом я пришел к выводу, что покажу только о дин пример. Почему так, я объясню позже по ходу видео.

Итак, связующие программные компоненты — это часть приложения, которая выполняется в определённом порядке при обработке запросов к приложению между получением запроса и передачей этого запроса в контроллер. Каждый связующий компонент может выполнить следующие 2 задания:

  • Выполнить свою логику и передать запрос далее следующему связующему программному компоненту в цепи компонентов или в контроллер, если это последний связующий программный компонент.
  • Может выполнить определенную логику перед и после следующего программного компонента в цепи компонентов.

Делегаты запросов используются для построения цепочки обработки запросов. Каждый элемент такой цепи – это связующий программный компонент.

Делегаты запросов добавляются при помощи методов Run, Map и Use. Делегат запросов может быть задекларирован как простой анонимный метод или в виде класса, который можно повторно использовать. Технически, эти анонимные методы и классы являются связующими программными компонентами. Каждый связующий программный в цепи компонентов отвечает за то, чтобы запустить следующий связующий программный компонент или вернуть запрос обратно. Когда запрос возвращается обратно данный связующий программный компонент называется конечным или терминальным. Из этого можно сделать вывод, что программные компоненты после терминального компонента не будут выполняться и обслуживать запрос.

В приложении ASP.NET Core цепочка связующих программных компонентов состоит из последовательности компонентов, который вызываются один за другим. На рисунке будет показана такая цепочка. Черные стрелки обозначают последовательность выполнения кода в потоке.

alt text

Каждый компонент может выполнить операции до и после следующего делегата. Например компоненты, которые отвечают за обработку исключений, должны быть вызваны в цепочке компонентов как можно раньше. Благодаря этому они смогут обработать исключения, которые могут возникнуть в цепочке компонентов значительно позже.

Самый простой способ задекларировать связующий программный компонент в приложении ASP.NET Core это создать единственный делегат, который обслуживает все запросы. В этом случае не создается цепочка компонентов. Вместо этого для каждого запроса HTTP вызывается единственный связующий программный компонент. Такой связующий компонент добавляется при помощи метода расширения Run, этот метод расширения не получает параметр с названием next о котором мы скажем несколько слов позже. Первый компонент, добавленный методом Run, всегда является последним или терминальным компонентом, он всегда добавляется в конце цепочки связующих программных компонентов. Метод Run это своего рода конвенция. Если Вы создаете, такой метод, то Вы подразумеваете, что данный связующий компонент, добавленный данным методом, должен использоваться в конце цепочки программных компонентов:

	app.Run(async context =>
	{
		await context.Response.WriteAsync("Hello, World!");
	});

Если у вас есть несколько программных компонентов, которые необходимо вызвать последовательно, то такие программные компоненты необходимо добавить в цепочку при помощи метода Use. В таком случае связующий программный компонент в своем конструкторе принимает аргумент типа RequestDelegate, который называется next. Этот параметр представляет собой следующий связующий программный компонент в цепочке компонентов. Можно прекратить вызов связующих программных компонентов, не вызывая делегат next. До и после вызова делегата next можно выполнить логику, определенную в данном связующем программном компоненте.

	app.Use(async (context, next) =>
	{
		// Бизнесс логика до передачи управления в другой связующий программный компонент.
		await next.Invoke();
		// Бизнесс логика после передачи управление в другой связующий программный компонент,
		// например логирование
	});

	app.Run(async context =>
	{
		await context.Response.WriteAsync("Hello from 2nd delegate.");
	});

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

alt text

Порядок, в котором связующие программные компоненты были добавлены в методе Configure класса Startup определяет порядок, в котором данные связующие программные компоненты будут вызваны на запросе и на ответе. В случае ответа это будет обратный порядок вызова. Этот порядок важен для безопасности, производительности и функционирования приложения.

	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}

	app.UseHttpsRedirection();
	app.UseRouting();
	app.UseAuthorization();

	app.UseRequestCorrelationId();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapControllers();
	});
	
	//app.UseStaticFiles();
	//app.UseCookiePolicy();
	//app.UseRequestLocalization();
	//app.UseCors();
	//app.UseAuthentication();
	//app.UseSession();
	//app.UseResponseCompression();
	//app.UseResponseCaching();

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

  1. Обслуживание исключений
    • Когда приложение запущено в среде разработке:
      • Связующий программный компонент страницы ошибок разработчика (UseDeveloperExceptionPage) сообщает об ошибках во время выполнения приложения.
      • Связующий программный компонент страницы ошибок базы данных сообщает о об ошибках при работе с базой данных.
    • Когда приложение запущено в производственной среде:
      • Компонент обработки ошибок (UseExceptionHandler) перехватывает исключение выброшенные в последующих программных компонентах.
  2. Компонент HTTP Strict Transport Security Protocol (HSTS) (UseHsts) добавляет специфический заголовок Strict-Transport-Security.
  3. Компонент перенаправления HTTPS (UseHttpsRedirection) перенаправляет запросы HTTP на страницы HTTPS.
  4. Компонент статических файлов (UseStaticFiles) возвращает статические файлы и прекращает последующую обработку запросов.
  5. Компонент политики cookies (UseCookiePolicy) обеспечивает соответствие приложения акту ЕС о защите персональных данных.
  6. Компонент маршрутизации (UseRouting) служит для управления маршрутами обслуживания запросов.
  7. Компонент аутентификации (UseAuthentication) пытается аутентифицировать пользователя до того, как пользователь получит доступ к хранимым ресурсам.
  8. Компонент авторизации (UseAuthorization) авторизирует пользователя для доступа к хранимым ресурсам.
  9. Компонент сессий (UseSession) устанавливает и поддерживает состояние сессии. Если приложение использует сессию, данный компонент должен быть вызван после компонента политики cookies и перед компонентом MVC.
  10. Компонент маршрутизации к конечным точках (UseEndpoints с MapRazorPages) для добавление страниц Razor как конечного пункта обработки запроса.

Метод расширения Map используются как конвенция для разделения цепочки обработки запросов. Map разделяет цепочку обработки на основании полученного пути запроса. Если запрос начинается с данной строки, то соответствующее разделение цепочки будет выполнено.

	app.Map("/map1", HandleMapRequest1);
	app.Map("/map2", HandleMapRequest2);

Метод MapWhen разделят цепочку обработки запросов на основании условия. В данном случае условием является делегат типа Func<HttpContext, bool>. Этот делегат используется для определения необходимо ли выполнение данной цепочки на основании данных, которые содержатся в объекте HttpContext. В следующем примере данный делегат используется для определения наличия значения в строке запроса:

	app.MapWhen(context => context.Request.Query.ContainsKey("book"), HandleBook);

Метод UseWhen также используется для разделения цепочки обработки запросов на основании данного предиката. Однако в отличии от MapWhen, это ответвление после выполнения снова соединяется с основной цепочкой выполнения, если в середине данного компонента нет логики прекращения обработки запроса:

	app.UseWhen(context => context.Request.Query.ContainsKey("book"), HandleBookWithUse);

Теперь давайте рассмотрим вопрос, как создать свой собственный связующий программный компонент. Рассмотрим ситуацию, когда у нас есть несколько взаимодействующих между собой микросервисов. Каждый микросервис ведёт свой собственный лог, который записывает в файл. В случае возникновения ошибки нам необходимо проанализировать все логи и попытаться понять, как возникла ошибка. Здесь проблемой является ответ на вопрос как все логи прочитать вместе, сопоставить между собой и понять откуда начать читать лог, с какого микросервиса. Для решения данной проблемы можно воспользоваться следующим подходом. Каждый запрос, который приходит из вне к нашим микросуслугам должен иметь в себе какое-то свойство, которое будет неизменно пока данный запрос будет путешествовать между нашими микроуслугами в той или иной форме. В логах этот запрос и последующие запросы, связанные с первоначальным запросом, будут иметь это свойство. Например, возможно такая цепочка запросов: добавь товар в корзину, закажи товар, оплати товар, отправь товар, очисти корзину. Каждое действие это запрос к определенному микросервису, например к сервису платежей или сервису доставки. Также во время логирования запросов это свойство будет откладываться в логах. Если возникнет ошибка, мы можем взять все логи и в этих логах сделать поиск по значению данного свойства, которое мы можем найти в первоначальном запросе. То есть все связанные запросы будут иметь одинаковое значение данного свойства. Теперь мы должны ответить на несколько вопросов:

  • Что это за свойство? Мы можем назвать это свойство CorrelationId (название свойства происходит от слова «корреляция», то есть соотношение).
  • Какой будет тип этого свойства? Мы хотели бы чтобы значение данного свойства было уникальным. В таком случае можно использовать тип Guid. Итак, каждый запрос к нашему приложению должен иметь свойство CorrelationId типа Guid. Также мы условились, что запросы без данного свойства не будут приниматься к обработке нашим приложением.
  • Как передать данное свойство? Свойство можно добавить непосредственно в модель, то есть объект, который будет пересылаться вместе с запросом будет иметь это свойства. Хорошее решение, но есть вариант получше. Это пересылка данных в заголовках запроса. По английски это называется headers. Заголовки — это обычные строковые пары «ключ-значение». В нашем случае ключ это CorrelationId, а значение, какой-то сгенерированный Guid. Естественно, существуют заголовки, которые заранее определены – их достаточно много, несколько десятков или даже около сотни. Например, заголовок User-Agent содержит информацию о вашем браузере и с его помощью веб-страница знает, как вы просматриваете содержимое страницы в каком браузере и с какого устройства.
  • Где проверять это свойство? Можно проверять в контроллере, но, по-моему, это уже поздно – запрос прошел аж до контроллера и находится в своей конечной точке перед отправкой ответа на запрос. Есть место получше – это проверка в связующем программном компоненте.
  • Какая будет логика проверки? Есть ли в запросе заголовок с названием CorrelationId – если да, то идём дальше, если нет – возвращаем сообщение об ошибке. Проверяем значение заголовка CorrelationId правильный ли это тип Guid – если да, то заканчиваем работу и передаём управление в следующий связующий программный компонент, если нет, то возвращаем сообщение об ошибке. Все проверки происходят вне контроллеров и это признак хорошей архитектуры приложения.

Теперь посмотрим на код – я создал класс CorrelationIdMiddleware. Конструктор данного класса принимает объект RequestDelegate – мы получаем его от предыдущего связующего программного компонента и вызываем его, когда мы хотим передать управление следующему программному компоненту в цепочке. Бизнес-логика выполнена в методе InvokeAsync аргументом которого является объект HttpContext. Объект HttpContext это объект нашего запроса – мы должны в нём поискать заголовки проверить есть ли там заголовок с названием CorrelationId. Этот заголовок мы получаем в строке 20 и проверяем в строке 25. Далее мы проверяем какой формат значения данного заголовка – если это не Guid, то мы возвращаем ошибку. Если все в порядке мы вызываем делегат next и наш запрос попадает в следующий связующий программный компонент или, если его нет, в контроллер. Обратите внимание как возвращается ошибка – коду статуса присваивает код 400. Этот код обозначает неправильный запрос или на английском BadRequest. Вообще этих кодов статусов большое количество. Важно помнить, что коды, которые начинаются с цифры 2 – это успешные коды, коды с цифрой 4 в начале – обозначают проблемы у клиента, а коды с цифрой 5 в начале – проблемы на сервере в нашем приложении.

Чтобы красиво использовать наш связующий программный компонент CorrelationIdMiddleware в классе Startup мы должны написать метод расширения Use. Такой метод уже написан в статическом классе Extensions – это метод UseRequestCorrelationId.

Идем в класс Startup и добавляем наш компонент. Где? В конце нашей цепочки. Теперь наше приложение будет отвергать все запросы, которые не содержат заголовок CorrelationId с правильным значением Guid. Давайте посмотрим, как это работает. Сразу обращаю Ваше внимание, что запрос в приложении Postman, которые я для вас подготовил в одном из предыдущих видео, не будут содержать заголовка CorrelationId – вы должны добавить их самостоятельно, чтобы ваши запросы работали с новым кодом.

Я хотел добавить еще один пример связующего программного компонента – в этом компоненте должна быть реализована валидация объекта, который мы пересылаем в запросе, но по мере подготовке данного видео я отказался от этой идеи. Почему? Во-первых, я не видел чтобы кто-то так реализовывал валидацию - поэтому я посчитал, что такой пример, которым никто не пользуется это плохая идея. А во-вторых оказалось, что если мы прочитаем содержимое запроса, записанное в виде потока, то потом, в контроллере, нам будет нечего читать – ведь поток можно прочитать только один раз. Я пытался это обойти, но не вышло и тут я понял, что не зря никто ничего не читает в содержимом запроса в связующих программных компонентах, а если хочет что-то передать для проверки, то это как правило заголовки. В общем я оставил эту идею. Файл BookValidatorMiddleware не будет добавлен в код на моей страничке GitHub.

Ну что – это вроде бы всё что я хотел сказать на тему связующих программных компонентов. Теория была, практические примеры на основании теории были, пример собственного связующего программного компонента из реальной жизни программиста был. Так что пора заканчивать видео. Просто из всего этого можно сделать следующий вывод – некоторую логику можно вынести за пределы контроллера и разместить её в связующих программных компонентах. Как всегда напоминаю, что код в данном примере есть на моей странице github, а текст к видео есть на моей персональной веб странице. Я надеюсь, что вам данное видео понравилось и вы узнали для себя что-то новое. Если это так, то будут благодарен за лайк, подписку и положительный комментарий. Спасибо за внимание и до новых встреч.

Пример кода из видео на GitHub

Для открытия файла проекта необходимо Visual Studio 2019.