Конвеєр запитів та Middleware
Конвеєр запитів (Request Pipeline) та Middleware
1. Контекст: Що таке Конвеєр (Pipeline)?
Уявіть, що ви на автомобільному заводі. Голий кузов машини (це вхідний HTTP Запит) стає на стрічку конвеєра. Стрічка рухається через серію станцій.
- На першій станції робот перевіряє, чи є у вас квиток на завод (Аутентифікація). Якщо немає — він викидає машину з конвеєра з помилкою 401 Unauthorized.
- На другій станції робот прикріплює двері (Додає заголовки у HTTP Відповідь).
- На третій станції робот фарбує машину (Формує JSON тіло відповіді).
Кожна така "станція", кожен робот на конвеєрі ASP.NET Core називається словом Middleware (Проміжне програмне забезпечення).
Ця діаграма демонструє найголовніше правило ASP.NET: Запит проходить через Middleware двічі. Спочатку "вниз" (до кінцевої точки Endpoint), а потім відповідь повертається "нагору" через ті самі Middleware, перш ніж вилетіти до користувача.
Це нагадує російську матрьошку: виклик Next() занурює нас у наступну матрьошку.
2. HttpContext: Бог вашого запиту
Кожного разу, коли мільйони байтів прилітають на мережеву карту вашого сервера, Kestrel магічним чином збирає ці байти і перетворює їх на розкішний об'єкт мовою C#, з яким нам приємно працювати. Цей об'єкт називається HttpContext.
Де б ви не знаходились у виконанні запиту (в Middleware, в Controller, в Minimal API Endpoint), HttpContext є завжди поруч.
Request(HttpRequest): Всі вхідні дані (URL, Заголовки, Query, Тіло, Куки).Response(HttpResponse): Всі вихідні дані, які ми збираємось повернути.User(ClaimsPrincipal): Зашифрована інформація про користувача з токена (якщо він авторизований).TraceIdentifier: Унікальний ID запиту, корисний для систем агрегації логів (Kibana, Seq).
2.1. HttpRequest (Читання)
Використовується виключно для читання того, що надіслав клієнт.
app.MapGet("/info", (HttpContext context) =>
{
var path = context.Request.Path; // Поверне "/info"
var method = context.Request.Method; // Поверне "GET"
var userAgent = context.Request.Headers["User-Agent"]; // Поверне "Mozilla/5.0..."
return $"Ви прийшли на шлях {path} використовуючи метод {method}. Ваш браузер: {userAgent}";
});
Зверніть увагу: ми попросили ASP.NET передати поточний HttpContext в нашу лямбду просто дописавши його як параметр (HttpContext context). DI контейнер автоматично зв'язує його для нас на час цього конкретного запиту.
2.2. HttpResponse (Записування)
HttpResponse дозволяє нам керувати тим, що прийде назад в браузер.
app.MapGet("/custom-headers", async (HttpContext context) =>
{
// 1. Встановлення HTTP Коду Статусу
context.Response.StatusCode = 201; // Created
// 2. Встановлення своїх заголовків
context.Response.Headers.Append("X-Server-Version", "1.0.42");
context.Response.ContentType = "text/html; charset=utf-8";
// 3. Запис сирих байтів або рядків у тіло (Body)
await context.Response.WriteAsync("<h1>Магічна Сторінка</h1>", System.Text.Encoding.UTF8);
});
context.Response.WriteAsync() або повернули будь-які дані, Kestrel відправляє заголовки клієнту. Якщо після цього ви спробуєте змінити context.Response.StatusCode = 404, програма впаде з помилкою Headers are already sent. Ви не можете змінювати заголовки або статус-коди після того, як почали писати в тіло запиту!3. Методи реєстрації Middleware: Run, Use та Map
Як нам створити власного "робота" на конвеєрі? За допомогою трьох методів класу IApplicationBuilder (наш app).
Метод Run(): Термінатор
Метод Run() додає до пайплайну Термінальний Middleware (Terminal Middleware). Це означає, що як тільки запит доходить до Run(), він обробляється і ПРОЦЕС ЗАКІНЧУЄТЬСЯ. Цей робот ніколи не передасть кузов машини далі. Пайплайн розвертається і їде назад.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Run(async context =>
{
await context.Response.WriteAsync("Це Термінатор. Я завершую запит.");
});
// ЦЕЙ КОД НІКОЛИ НЕ ВИКОНАЄТЬСЯ
app.Run(async context =>
{
await context.Response.WriteAsync("Мене ніхто ніколи не побачить :(");
});
app.Run();
Run() використовується дуже рідко, найчастіше на самому "дні" пайплайну як fallback (остання надія) для запитів типу "404 Not Found". Всі функції app.MapGet(...) під капотом зводяться саме до Run().
Метод Use(): Класична ланка конвеєра
Метод Use() дозволяє вам вставити власну логіку, І ПЕРЕДАТИ управління наступному по ланцюгу (викликавши next()).
app.Use(async (context, next) =>
{
// Логіка ВХІДНОГО запиту (До виконання наступних Middleware)
Console.WriteLine($"[1] Вхідний запит: {context.Request.Path}");
// ПЕРЕДАЄМО КЕРУВАННЯ НАСТУПНОМУ MIDDLEWARE!
await next();
// Логіка ВИХІДНОГО запиту (Після виконання всього іншого)
Console.WriteLine($"[4] Відповідь сформовано. Статус: {context.Response.StatusCode}");
});
app.Use(async (context, next) =>
{
Console.WriteLine($"[2] Я другий middleware.");
await next();
Console.WriteLine($"[3] Завершую другий middleware.");
});
app.MapGet("/", () => "Hello!");
Якщо ви зробите запит, в консолі рівно в такому порядку напишеться:
[1] -> [2] -> (відпрацює MapGet) -> [3] -> [4].
Use ви забудете написати await next(), пайплайн обірветься на цьому ж місці. Ця лінія фактично перетворить Use() на Run().Метод Map(): Розгалуження конвеєра
Іноді вам потрібно відправити певну частину трафіку на окремий "міні-завод", і ніколи не повертати його на головний конвеєр. Метод Map дозволяє створити відгалуження на основі шляху (URL Path).
// Якщо хтось зайде на /admin
app.Map("/admin", adminApp =>
{
// Це ОДРЕМА гілка пам'яті Middleware. Вона нічого не знає про головну.
adminApp.Run(async context =>
{
await context.Response.WriteAsync("Ви в адмінці!");
});
});
app.Run(async context =>
{
await context.Response.WriteAsync("Головна публічна частина сторінки");
});
Якщо запит іде на /admin, він потрапляє у відгалуження, обробляється там через Run, і повертається клієнту напряму. Він ніколи не дійде до головного app.Run().
4. Стан Middleware: Пастка життєвого циклу
До цього ми писали Middleware прямо в файлі Program.cs як лямбда-вирази (так званий Inline Middleware).
Ви повинні знати один страшний факт: Екземпляри лямбд для Middleware створюються лише ОДИН РАЗ при старті сервера (вони є Singleton за своєю природою).
Розглянемо класичну помилку початківців:
int counter = 0; // Глобальна змінна (Closure) для middleware
app.Use(async (context, next) =>
{
counter = counter + 1; // Мутація спільного стану!
await context.Response.WriteAsync($"Ви відвідувач номер {counter}");
});
Якщо ви відкриєте браузер і будете натискати F5, ви побачите: "ви відвідувач 1", "ви відвідувач 2", "ви відвідувач 3". Здається, все добре.
Але уявіть, що у вас на сервері 500 одночасних клієнтів. Спрацьовує пул потоків (Thread Pool), і 5 потоків одночасно намагаються зробити counter = counter + 1. Це призведе до Стану Перегонів (Race Condition). Значення зіб'ються в кашу. Більше того, ви можете зламати пам'ять.
HttpContext.Items, бази даних або Scoped DI Сервіси.5. Вбудовані Middleware від Microsoft
Ви рідко будете писати app.Use() вручну. Найкрутіша фішка ASP.NET Core в тому, що всі потужні можливості вже написані як оптимізовані класи Middleware корпорацією Microsoft, і підключити їх можна одним методом розширення.
Існує фундаментальне правило для встроенних Middleware — Порядок має значення (Order Matters)! Якщо ви поставите перевірку доступу (Авторизацію) після того, як запит обробився, вас просто взламають.
Рекомендований порядок виглядає так:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1. Обробник виключень (перехоплює паніки з усіх інших Middleware під собою)
app.UseExceptionHandler("/error");
// 2. HTTPS переадресація. Немає сенсу працювати далі, якщо ми на небезпечному з'єднанні.
app.UseHttpsRedirection();
// 3. Статичні файли (картинки, css). Нема чого їх перевіряти складними правилами.
app.UseStaticFiles();
// 4. Маршрутизація. Kestrel дізнається, до якого Endpoint летить запит.
app.UseRouting();
// 5. CORS (Крос-доменні запити). Дозволяє фронтенду звертатися до нашого API.
app.UseCors();
// 6. Аутентифікація (Хто цей користувач? Парсинг JWT Token чи Cookie)
app.UseAuthentication();
// 7. Авторизація (Чи має цей користувач права адміна?)
app.UseAuthorization();
// 8. Виконання наших Minimal API Endpoints
app.MapGet("/", () => "Top Secret Data");
app.Run();
Якщо ви поміняєте UseRouting() і UseAuthentication() місцями, ваша програма може перестати працювати або стати вразливою. Microsoft створила цей ланцюжок так, щоб "найдешевіші" і найважливіші перевірки відсіювали сміттєві запити на самих ранніх стадіях пайплайну, зберігаючи ресурси процесора.
5.1 Практика: Middleware для обробки помилок
Одне з найважливіших завдань конвеєра — коректно обробляти помилки (Exceptions). В ASP.NET Core працює правило: обробник помилок має бути першим у пайплайні, щоб він міг перехопити (catch) будь-яку помилку, що виникне в компонентах під ним.
Традиційно використовують три ключові компоненти (як описано в класичній літературі з ASP.NET Core):
DeveloperExceptionPageMiddleware: Використовується тільки під час розробки (if (app.Environment.IsDevelopment())). Він перехоплює помилку і генерує детальну (іноді "білу сторінку смерті") HTML-сторінку зі Stack Trace, параметрами запиту та іншою чутливою інформацією.ExceptionHandlerMiddleware: Використовується на Production. Замість показу деталей помилки клієнту (що небезпечно), він перехоплює паніку, логує її і прозоро перенаправляє користувача на безпечний роут (наприклад,app.UseExceptionHandler("/error")).StatusCodePagesMiddleware: Працює з помилками на рівні статус-кодів (наприклад, 404 Not Found або 403 Forbidden). Перетворює порожні відповіді браузеру на красиві сторінки або правильні JSON-структури.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Налаштування залежно від середовища
if (app.Environment.IsDevelopment())
{
// Покаже повний stack trace при помилці (у .NET 6+ працює автоматично)
app.UseDeveloperExceptionPage();
}
else
{
// Безпечне перенаправлення для користувачів
app.UseExceptionHandler("/error");
// Безпека: HTTP Strict Transport Security
app.UseHsts();
}
app.UseStatusCodePages(); // Додає тіло відповіді для статус кодів 400-599
6. Пишемо Класичний Middleware (Експертний рівень)
Лямбди (Inline Middleware) — це круто для прототипування. Але у великому ентерпрайз проекті логіка записує 500 рядків коду в один кластер Program.cs. Це жах.
ASP.NET дозволяє нам ізолювати Middleware у красиві класи.
Нехай ми напишемо власний розумний таймер, який логуватиме, скільки мілісекунд виконувався запит.
Крок 1: Клас логіки
using System.Diagnostics;
// 1. Клас має бути публічним.
public class RequestTimingMiddleware
{
// 2. Він ОБОВ'ЯЗКОВО повинен приймати наступну ланку конвеєра в конструкторі.
private readonly RequestDelegate _next;
public RequestTimingMiddleware(RequestDelegate next)
{
_next = next;
}
// 3. Він ОБОВ'ЯЗКОВО повинен мати метод Invoke або InvokeAsync
public async Task InvokeAsync(HttpContext context)
{
// Логіка ДО (перехоплюємо старт)
var sw = Stopwatch.StartNew();
// Пропускаємо запит далі в глиб додатка
await _next(context);
// Логіка ПІСЛЯ (коли відповідь повертається з глибини)
sw.Stop();
// Знаходимо Logger через сервіси запиту і пишемо час виконання
var logger = context.RequestServices.GetRequiredService<ILogger<RequestTimingMiddleware>>();
logger.LogInformation($"Запит до {context.Request.Path} виконався за {sw.ElapsedMilliseconds}ms");
}
}
Крок 2: Метод-Розширення для красоти
Ми не хочемо писати app.UseMiddleware<RequestTimingMiddleware>(). Ми хочемо як у дорослих.
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder builder)
{
return builder.UseMiddleware<RequestTimingMiddleware>();
}
}
Крок 3: Ін'єкція в пайплайн
Тепер наш Program.cs виглядає кришталево чисто:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Викликаємо нашого власного робота
app.UseRequestTiming();
app.MapGet("/", () => "Hello Performance!");
app.Run();
7. Практичні завдання та виклики
Теоретичні знання пусті без практики. Випробуйте себе:
- Challenge 1 (API Key Check): Створіть власне Inline Middleware через
app.Use(), яке перевіряє заголовокX-Api-Keyвcontext.Request.Headers. Якщо ключа немає або він неправильний, не викликайтеnext(), а відразу встановітьStatusCode = 401, надішлітьcontext.Response.WriteAsync("Unauthorized")і завершіть запит. Це і є ручна аутентифікація! - Challenge 2 (Request Modification): Напишіть Middleware, яке завжди додає глобальний заголовок до кожної відповіді сервера (наприклад,
context.Response.Headers.Append("X-Author", "MyName")). Перевірте це в вкладці Network вашого браузера (DevTools F12).
8. Підсумкове Резюме Майстрів
Вітаю, ви успішно пройшли всі фундаментальні блоки. Давайте підіб'ємо підсумки:
- Middleware — це ланцюжок делегатів. Вони виконуються Секвенційно (один за одним) при надходженні запиту, і в зворотному порядку при формуванні відповіді (Матрьошка).
- HttpContext — це альфа і омега всієї магії. Все, що знає сервeр про веб, лежить у
RequestабоResponseвсередині цього контексту. - Run vs Use — Run є термінатором конвеєра, він завжди стоїть у фіналі. Use — це проміжне кільце, яке повинне кликати
await next(). - Порядок реєстрації (Послідовність
Use...()) критично важлива. Статичні файли треба віддавати раніше аутентифікації для швидкості; Аутентифікацію треба ставити раніше маршрутизації для безпеки.
Перевірка знань
Якщо тест не завантажується, перевірте свої знання тут:
- Якщо
Middleware 1викликаєawait next(), аMiddleware 2просто пише статус код і НЕ викликаєnext(), чи виконаєтьсяMiddleware 3? - На якому етапі життєвого циклу запиту безпечно змінювати HTTP-заголовки у
context.Response? (Підказка: До виклику першогоWriteAsync). - Ви написали кастомний сервіс-контролер, який залежить від бази даних через Constructor Injection. Який Lifetime (
AddSingleton,AddScopedчиAddTransient) для БД є правильним, якщо ви хочете, щоб всі операції бази даних в межах одного HTTP запиту відбувалися в одній і тій самій транзакції?
WebApplication, Builder та Dependency Injection
Глибинний розбір патерну Builder в ASP.NET Core, розуміння архітектури Dependency Injection, методів розширення та життєвого циклу додатка.
Маршрутизація в ASP.NET Core: Основи
Маршрутизація (Routing) — це фундаментальний механізм будь-якого веб-фреймворку. Вона відповідає за аналіз вхідного HTTP-запиту (наприклад, GET /users/1) та виклик відповідної ділянки коду (обробника) для формування відповіді.