Маршрутизація в ASP.NET Core: Розширені можливості
Маршрутизація в ASP.NET Core: Розширені можливості
У попередньому матеріалі ми дізналися, як створювати базові маршрути та приймати параметри. Однак реальні проєкти вимагають значно більшого контролю над тим, які запити має обробляти конкретна кінцева точка.
У цьому розділі ми розглянемо обмеження параметрів, перехоплення гнучких маршрутів (catch-all), отримання даних з рядка запиту (Query Strings), введення залежностей (Dependency Injection) безпосередньо в маршрути та взаємодію з Middleware.
Нагадування: Ми будемо багато працювати з кодом. Звертайте особливу увагу на "Анатомію коду" після кожного прикладу.
Обмеження маршрутів (Route Constraints)
Коли ми створюємо маршрут на кшталт /users/{id}, ми очікуємо, що id буде числом. Якщо користувач перейде за адресою /users/manager, фреймворк спробує передати "manager" в int id, що призведе до помилки конвертації (400 Bad Request).
Замість того, щоб ловити помилку конвертації, ми можемо сказати роутеру: "Цей маршрут працює лише якщо id є цілим числом". Це і є обмеження (Constraints).
Використання вбудованих обмежень
Обмеження додаються безпосередньо в шаблон маршруту після двокрапки :.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Маршрут 1: Лише для числових ID
app.MapGet("/users/{id:int}", (int id) => $"Користувач з ID {id}");
// Маршрут 2: Будь-який інший текст (спрацює лише якщо Маршрут 1 не підійшов)
app.MapGet("/users/{name:alpha}", (string name) => $"Профіль: {name}");
app.Run();
Анатомія коду:
- Якщо запит
GET /users/5, спрацьовує Маршрут 1 (:int), і роутер знає, що5— це точноint. - Якщо запит
GET /users/tom, Маршрут 1 "каже", що/tomне є числом, тому ігнорує його. Роутер йде далі й знаходить Маршрут 2 (:alpha— дозволяє лише літери від A до Z), який успішно обробляє запит. - Якщо запит
GET /users/123tom— обидва маршрути відмовлять, і користувач отримає404 Not Found.
Таблиця найпопулярніших обмежень:
| Обмеження | Опис | Приклад шаблону |
|---|---|---|
int | Цілі числа | {id:int} |
bool | true або false | {active:bool} |
datetime | Дати | {date:datetime} |
alpha | Літери A-Z | {name:alpha} |
minlength(v) | Мінімальна довжина | {name:minlength(3)} |
max(v) | Максимальне значення | {age:max(120)} |
Комбінування обмежень
Обмеження можна об'єднувати (chaining) за допомогою тої самої двокрапки :. Роутер перевірятиме їх по черзі зліва направо.
// id: ціле число, мінімум 1, максимум 1000
app.MapGet("/products/{id:int:range(1,1000)}", (int id) =>
{
return $"Продукт #{id} знайдено.";
});
// login: лише букви, від 4 до 15 символів
app.MapGet("/profile/{login:alpha:length(4,15)}", (string login) =>
{
return $"Ласкаво просимо, {login}";
});
Регулярні вирази (Regex)
Якщо вбудованих обмежень замало, ви можете використовувати потужність регулярних виразів за допомогою :regex().
// slug для постів блогу: маленькі літери, цифри та дефіс.
app.MapGet("/posts/{slug:regex(^[a-z0-9-]+$)}", (string slug) =>
{
return $"Ви читаєте статтю: {slug}";
});
Кастомні обмеження (Custom Route Constraints)
Якщо у вас є бізнес-правило, яке важко описати існуючими обмеженнями (наприклад, перевірка чи слово знаходиться у списку "заборонених"), ви можете створити власне обмеження.
Крок 1: Створення класу обмеження
Вам потрібно створити клас, який реалізує інтерфейс IRouteConstraint.
using System.Text.RegularExpressions;
public class NotReservedWordConstraint : IRouteConstraint
{
private static readonly string[] Reserved = { "admin", "root", "system" };
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
// 1. Отримуємо значення, яке прийшло в URL
if (values.TryGetValue(routeKey, out var value) && value != null)
{
var word = value.ToString()?.ToLower();
// 2. Повертаємо true, якщо слово НЕ в списку зарезервованих (маршрут підходить)
// Повертаємо false, якщо слово у списку (маршрут відмовляє)
return !Reserved.Contains(word);
}
return false;
}
}
Крок 2: Реєстрація обмеження
Тепер потрібно пояснити ASP.NET Core, як "називати" це обмеження в шаблоні URL. Це робиться при налаштуванні сервісів маршрутизації.
var builder = WebApplication.CreateBuilder(args);
// Реєструємо кастомне обмеження з ім'ям "notreserved"
builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("notreserved", typeof(NotReservedWordConstraint));
});
var app = builder.Build();
// Використовуємо його!
app.MapGet("/signup/{username:notreserved}", (string username) =>
{
return $"Користувача {username} успішно створено!";
});
app.Run();
Анатомія коду:
- Якщо користувач зробить
GET /signup/johndoe, обмеження перевірить масивReserved, не знайде його هناك і повернеtrue. Виконається лямбда і поверне статус 200. - Якщо користувач зробить
GET /signup/admin, обмеження знайде його вReservedі повернеfalse. Маршрут вважатиметься невідповідним, і користувач отримає404 Not Found.
Спеціальні (Catch-All) маршрути
Іноді ви не знаєте заздалегідь, скільки сегментів (/a/b/c/d) буде у вашому URL. Наприклад, ви хочете обробляти всі запити до файлів у вкладених папках. Для цього використовуються Catch-All параметри, які починаються з * або **.
// *path "збереже" в собі всю решту URL після /files/
app.MapGet("/files/{*path}", (string path) =>
{
return $"Ви шукаєте файл за шляхом: {path}";
});
Анатомія коду:
- Якщо запит:
GET /files/images/2023/summer/sea.jpg - Значення змінної
pathбуде:"images/2023/summer/sea.jpg"(без початкового слеша).
Різниця між
*та**: У сучасному роутингу вони працюють майже ідентично в контексті отримання значення, проте\*_використовується як інструмент сумісності, якщо ви також працюєте з компонентами, які генерують URL (наприклад, Razor Pages). За замовчуванням достатньо використовувати просто_.
Параметри рядка запиту (Query Strings)
Ми навчилися отримувати дані з URL за допомогою Route Parameters (шляху /{id}). Але є другий спосіб передачі даних — Query Strings, тобто все, що йде після знаку питання ? в URL (/search?q=apple&sort=asc&page=2).
У Minimal APIs вам нічого не потрібно робити, щоб їх отримати. Якщо ім'я параметра делегата збігається з іменем в Query String, воно підставиться автоматично!
var app = WebApplication.Create();
// URL: /search?query=laptop&page=2
app.MapGet("/search", (string query, int? page) =>
{
int currentPage = page ?? 1; // Якщо page немає, використовуємо 1
return $"Пошук: {query}, Сторінка: {currentPage}";
});
app.Run();
Групування параметрів [AsParameters] (ASP.NET Core 7+)
Якщо у вас є endpoint, який приймає 5-6 параметрів (id, page, sort, order, filter), сигнатура методу стає дуже довгою. Ви можете згрупувати їх в об'єкт за допомогою спеціального атрибута [AsParameters].
// 1. Створюємо рекорд для наших параметрів
public record SearchRequest(string Query, int Page = 1, string Sort = "desc");
var app = WebApplication.Create();
// 2. Вказуємо фреймворку "розібрати" цей об'єкт
app.MapGet("/api/search", ([AsParameters] SearchRequest req) =>
{
return $"Шукаємо: {req.Query}, Сторінка: {req.Page}, Сортування: {req.Sort}";
});
app.Run();
Анатомія коду:
- При запиті
GET /api/search?query=phone&sort=asc, фреймворк автоматично створить об'єктSearchRequest, заповнившиQuery= "phone",Sort= "asc", аPageзалишиться дефолтним = 1.
Dependency Injection (Введення залежностей) прямо в роути
Однією з найкрутіших фішок Minimal APIs є те, що параметри вашого маршрутного делегата можуть мати різне походження:
- Вони можуть прийти з URL-шляху (Route Data)
- Вони можуть прийти з URL-фільтрів (Query String)
- Вони можуть прийти з Тіла запиту (Request Body)
- Або вони можуть прийти з вашого DI контейнера (Services)!
ASP.NET розумний. Якщо тип вашого параметра не є простим (наприклад, це не int чи string), він сам перевірить, чи зареєстрований цей сервіс в контейнері, і якщо так — створить його та інжектує.
var builder = WebApplication.CreateBuilder(args);
// 1. Створюємо сервіс та додаємо його в DI
builder.Services.AddSingleton<ITimeService, SystemTimeService>();
var app = builder.Build();
// 2. Просто додаємо його як аргумент до лямбди!
app.MapGet("/time", (ITimeService time) =>
{
return $"Поточний час: {time.Now.ToShortTimeString()}";
});
app.Run();
// --------- Код сервісу ---------
interface ITimeService { DateTime Now { get; } }
class SystemTimeService : ITimeService { public DateTime Now => DateTime.Now; }
Анатомія коду:
- У рядку
app.MapGetми передаємо інтерфейсITimeService. Роутер не може знайти це в URL (бо це не число і не рядок). Тоді він дивиться уbuilder.Services. Знаходить тамSystemTimeServiceі передає його всередину функції! - Мінімалізм і краса: ніяких конструкторів і громіздких класів контролерів.
[FromServices].
app.MapGet("/a", ([FromServices] ITimeService time) => ...)Спеціальні маршрути: взаємодія з Middleware
У великих додатках маршрути часто працюють разом з Middleware. Наприклад, ви можете хотіти, щоб певний Middleware перехоплював помилку, коли маршрут не знайдено (404), замість стандартної порожньої сторінки.
var app = WebApplication.Create();
// 1. Middleware для обробки 404 (використовуємо Use)
app.Use(async (context, next) =>
{
// Відпускаємо запит далі по конвеєру
await next();
// Якщо конвеєр повернув 404 (маршрут не підійшов)
if (context.Response.StatusCode == 404)
{
// Перехоплюємо і повертаємо кастомну відповідь
await context.Response.WriteAsync("На жаль, такої сторінки не існує :(");
}
});
// 2. Звичайні маршрути
app.MapGet("/", () => "Welcome");
app.MapGet("/about", () => "About us");
app.Run();
Анатомія коду:
- Запит іде спочатку в
app.Use. Там ми кажемоawait next(). - Запит іде в роутинг. Якщо ми зайшли на
/about, роутинг знайде маршрут і запише відповідь "About us". Статус код стане 200. Ми повертаємось вapp.Use,StatusCode == 200, отже блокifне виконується. - Якщо ми зайшли на
/secret, роутинг не знайде маршрут. Статус код стане 404. Ми повертаємось вapp.Use, блокifспрацьовує, і ми перезаписуємо відповідь красивим повідомленням.
Практичні завдання
app.MapGet("/user/{id}", (string id) => id);
Змініть його так, щоб він приймав ID лише у форматі guid (глобальний ідентифікатор). Коли користувач намагатиметься передати текст або число, він повинен автоматично отримувати 404.GET /catalog/filter.
Він має приймати Query String параметри: category (рядок), minPrice (int), maxPrice (int).
Об'єднайте ці три параметри в один record class ProductFilter і використайте атрибут [AsParameters]. Поверніть у форматі JSON ехо-відповідь (що саме ви шукаєте).Захищений файл-сервер
- Створіть Catch-All маршрут
GET /assets/{*filepath}. - Створіть кастомне обмеження (Route Constraint)
SafeFileExtensionConstraint, яке повертаєtrueлише якщо шлях закінчується на.png,.jpgабо.pdf. - Додайте це обмеження до вашого маршруту за допомогою
options.ConstraintMap.Add(...). - Створіть сервіс
IDownloadTrackerі додайте його в DI, який рахуватиме скільки разів загалом щось скачали, і інжектуйте (Dependency Injection) його в цей же маршрут, щоб він повертав відповідь типу:Скачування файлу {filepath}. Це було скачування №{count}.
Маршрутизація в ASP.NET Core: Основи
Маршрутизація (Routing) — це фундаментальний механізм будь-якого веб-фреймворку. Вона відповідає за аналіз вхідного HTTP-запиту (наприклад, GET /users/1) та виклик відповідної ділянки коду (обробника) для формування відповіді.
Статичні файли в ASP.NET Core
Будь-який сучасний веб-додаток, окрім динамічних даних (JSON з бази даних), потребує віддавати клієнту так звані статичні файли. Це HTML-сторінки, CSS-стилі, JavaScript-скрипти, зображення (PNG, JPG) та шрифти, які лежать на диску сервера і не змінюються при кожному запиті.