ASP.NET Core MVC

Маршрутизація в MVC: Convention vs Attribute Routing

MVC-специфічна маршрутизація: convention routing з MapDefaultControllerRoute, attribute routing з [Route] та [HttpGet], route tokens, реєстрація Areas. Демо-проєкт: BlogController з кастомними SEO-friendly URLs.

Маршрутизація в MVC: Convention vs Attribute Routing

У курсі Minimal API ви вже знаєте основи routing — як URL відповідає на запит. Тут ми не повторюємо цю базу, а зосереджуємося на тому, що унікально для MVC: два способи оголошення маршрутів (convention та attribute), спеціальні route tokens [controller] та [action], і — найпотужніше — Areas routing для великих застосунків.

Ця стаття передбачає знання базового routing з Minimal API (параметри маршруту, constraints, {id:int}, query string). Тут ми вивчаємо лише MVC-специфіку поверх цих знань.

Convention Routing: маршрути з конвенцій

Convention routing означає, що маршрути виводяться з шаблону заданого один раз — без атрибутів на кожному методі.

Program.cs
app.MapDefaultControllerRoute();
// Рівнозначно:
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
);

Цей один шаблон обслуговує всі Controllers і Actions у проєкті:

URLControllerActionid
/HomeIndexnull
/homeHomeIndexnull
/productProductIndexnull
/product/detailsProductDetailsnull
/product/details/42ProductDetails"42"
/library/edit/7LibraryEdit"7"
/blog/createBlogCreatenull

Кілька шаблонів маршрутів

Можна оголосити кілька шаблонів для різних URL-структур:

Program.cs
// Спочатку — специфічніші маршрути
app.MapControllerRoute(
    name: "blog",
    pattern: "blog/{year:int}/{month:int}/{slug}",
    defaults: new { controller = "Blog", action = "Post" }
);

app.MapControllerRoute(
    name: "api-like",
    pattern: "api/{controller}/{action}/{id?}"
);

// Потім — загальний default
app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
);
Маршрути перевіряються по порядку — перший що збігається перемагає. Специфічніші маршрути завжди оголошуйте вище загального.

Обмеження convention routing

Convention routing зручний, але має обмеження:

  • Усі маршрути будуть слідувати одному шаблону {controller}/{action}
  • Складно зробити SEO-friendly URLs: /blog/2024/03/my-post замість /blog/post?id=5
  • Для кожного нестандартного URL — окремий MapControllerRoute

Тут на допомогу приходить attribute routing.


Attribute Routing: явні маршрути

Attribute routing дозволяє оголосити маршрут безпосередньо на Controller або Action:

[Route("books")]                    // базовий префікс для всього Controller
public class BookController : Controller
{
    [HttpGet("")]                   // GET /books
    public IActionResult Index() => View();

    [HttpGet("{id:int}")]           // GET /books/42
    public IActionResult Details(int id) => View();

    [HttpGet("create")]             // GET /books/create
    public IActionResult Create() => View();

    [HttpPost("create")]            // POST /books/create
    public IActionResult Create(CreateBookDto dto) { ... }

    [HttpGet("{id:int}/edit")]      // GET /books/42/edit
    public IActionResult Edit(int id) => View();

    [HttpPost("{id:int}/edit")]     // POST /books/42/edit
    public IActionResult Edit(int id, EditBookDto dto) { ... }

    [HttpDelete("{id:int}")]        // DELETE /books/42
    public IActionResult Delete(int id) { ... }
}

Route на рівні Controller та Action

// Варіант 1: [Route] тільки на Controller
[Route("catalog/products")]
public class ProductController : Controller
{
    // GET /catalog/products (маршрут з Controller + "" від Action)
    [HttpGet("")]
    public IActionResult Index() => View();

    // GET /catalog/products/5
    [HttpGet("{id:int}")]
    public IActionResult Details(int id) => View();
}

// Варіант 2: [Route] на кожному Action (без Route на Controller)
public class CategoryController : Controller
{
    [Route("shop/categories")]           // GET /shop/categories
    public IActionResult Index() => View();

    [Route("shop/categories/{slug}")]    // GET /shop/categories/electronics
    public IActionResult Details(string slug) => View();
}

Route tokens: controller та action

Щоб не дублювати ім'я Controller у кожному атрибуті, використовуйте route tokens:

// [controller] замінюється іменем Controller (без суфікса)
// [action] замінюється іменем Action-методу
[Route("[controller]/[action]")]
public class ProductController : Controller
{
    // GET /product/index    (token [action] = "index")
    public IActionResult Index() => View();

    // GET /product/details  (token [action] = "details")
    public IActionResult Details(int id) => View();

    // GET /product/create
    [HttpGet]
    public IActionResult Create() => View();
}

Це особливо корисно для базового шаблону всього Controller:

[Route("api/v1/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    // GET /api/v1/products
    [HttpGet]
    public IActionResult GetAll() => Ok();

    // GET /api/v1/products/42
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id) => Ok();
}

Convention vs Attribute: коли що використовувати

СценарійРекомендація
Простий CRUD, стандартні URLConvention routing
SEO-friendly URL (/blog/2024/post-slug)Attribute routing
REST API з версіонуванням (/api/v2/)Attribute routing
Areas (великі застосунки)Convention routing з MapAreaControllerRoute
Змішаний проєктAttribute routing на специфічних Controllers, convention для решти
У реальних проєктах зазвичай поєднують обидва підходи: MapDefaultControllerRoute() для більшості і [Route] там де потрібні нестандартні URL.

Areas: маршрутизація для великих застосунків

Areas — це спосіб розділити великий застосунок на незалежні частини (наприклад, Admin, Shop, Forum). Кожна Area має власні Controllers і Views.

Детальний розгляд Areas (файлова структура, asp-area Tag Helper, конфлікти імен) буде у спеціальній статті 08. Тут ми лише покажемо, як зареєструвати маршрут для Area, оскільки це частина загальної маршрутизації:

Program.cs
// Маршрути для Areas реєструються ПЕРЕД загальним маршрутом (вони специфічніші)
app.MapAreaControllerRoute(
    name: "admin",
    areaName: "Admin",
    pattern: "admin/{controller=Dashboard}/{action=Index}/{id?}"
);

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
);

А на самому Controller достатньо повісити атрибут [Area]:

[Area("Admin")]
public class DashboardController : Controller { /* ... */ }

Демо-проєкт: BlogController з SEO-friendly URLs

Будуємо покроково. Мета: публічний блог з URL формату /blog/2024/03/my-first-post.

Крок 1: Налаштування маршрутів у Program.cs

Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();

// Заглушка сервісу для демо
builder.Services.AddSingleton<IBlogService, InMemoryBlogService>();

var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();

// 1. Специфічний SEO-friendly маршрут для блогу — першим
app.MapControllerRoute(
    name: "blog_post",
    pattern: "blog/{year:int}/{month:int}/{slug}",
    defaults: new { controller = "Blog", action = "Post" }
);

// 2. Default маршрут — останнім
app.MapDefaultControllerRoute();

app.Run();

Крок 2: Модель

Models/BlogPost.cs
namespace BlogApp.Models;

public record BlogPost(
    int Id,
    string Title,
    string Slug,
    string Content,
    string Author,
    DateOnly PublishedAt,
    string Category
);

Крок 3: BlogController

Controllers/BlogController.cs
using Microsoft.AspNetCore.Mvc;
using BlogApp.Models;
using BlogApp.Services;

namespace BlogApp.Controllers;

public class BlogController : Controller
{
    private readonly IBlogService _service;
    public BlogController(IBlogService service) => _service = service;

    // GET /blog — список всіх постів
    // (default route: /blog = BlogController.Index())
    public async Task<IActionResult> Index()
    {
        var posts = await _service.GetAllAsync();
        return View(posts);
    }

    // GET /blog/2024/03/my-first-post
    // (blog_post route: year=2024, month=3, slug="my-first-post")
    public async Task<IActionResult> Post(int year, int month, string slug)
    {
        var post = await _service.GetBySlugAsync(year, month, slug);
        if (post is null) return NotFound();
        return View(post);
    }

    // GET /blog/category/tech
    // Attribute routing для специфічних дій
    [Route("blog/category/{categorySlug}")]
    public async Task<IActionResult> Category(string categorySlug)
    {
        var posts = await _service.GetByCategoryAsync(categorySlug);
        
        // Зберігаємо назву категорії у ViewBag для заголовка сторінки
        // (Нагадуємо: ViewBag детально вивчається у статті 06)
        ViewBag.Category = categorySlug;
        
        return View(posts);
    }
}

Крок 4: View для публічного блогу

Views/Blog/Index.cshtml
@model List<BlogApp.Models.BlogPost>
@{ ViewData["Title"] = "Блог"; }

<h1>Останні публікації</h1>

@foreach (var post in Model)
{
    <article class="mb-4">
        <h2>
            @* Генеруємо SEO URL: /blog/2024/03/my-first-post *@
            <a asp-action="Post"
               asp-route-year="@post.PublishedAt.Year"
               asp-route-month="@post.PublishedAt.Month"
               asp-route-slug="@post.Slug">
                @post.Title
            </a>
        </h2>
        <p class="text-muted">
            @post.Author · @post.PublishedAt.ToString("dd MMMM yyyy")
            · <a asp-action="Category" asp-route-categorySlug="@post.Category">@post.Category</a>
        </p>
        <p>@post.Content[..Math.Min(200, post.Content.Length)]…</p>
    </article>
}

Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Додайте до BlogController Action Archive що повертає пости за конкретний рік та місяць: /blog/archive/2024/03. Використайте attribute routing. View повинен показати заголовок "Архів: Березень 2024" та список постів.

Завдання 1.2. Перевірте, що URL /blog/2024/03/my-first-post та /blog/2024/03/another-post коректно маршрутизуються до BlogController.Post() з різними slug. Додайте Console.WriteLine у метод і спостерігайте параметри.

Рівень 2 — Логіка

Завдання 2.1. Створіть ProductController з attribute routing де:

  • GET /products → список
  • GET /products/{id:int} → деталі (404 якщо не знайдено)
  • GET /products/by-category/{category} → список за категорією
  • GET /products/search?q=laptop&minPrice=1000&maxPrice=50000 → пошук з фільтрами

Завдання 2.2. Виникла потреба: той самий ProductController має відповідати і на /products/ і на /shop/products/ (для SEO). Як реалізувати без дублювання коду? Підказка: Controller може мати кілька [Route] атрибутів.

Рівень 3 — Архітектура

Завдання 3.1. Спроєктуйте маршрутизацію для освітньої платформи:

  • /courses — каталог курсів
  • /courses/{slug} — деталі курсу
  • /courses/{courseSlug}/lessons/{lessonSlug} — урок у курсі
  • /admin/courses — управління курсами (Area)
  • /admin/courses/{id}/lessons — управління уроками (Area)
  • /api/v1/courses — REST API для мобільного застосунку

Реалізуйте Program.cs з усіма маршрутами та заглушки Controllers. Переконайтесь що URL не конфліктують.


Резюме

  • Convention routing: один шаблон для всіх — {controller}/{action}/{id?}. Простий, але негнучкий для складних URL
  • Attribute routing: [Route], [HttpGet] безпосередньо на класі/методі. Гнучкий, SEO-friendly, але verbose
  • Route tokens: [controller], [action] — динамічні частини маршруту що замінюються іменами
  • Areas: розподіл великого застосунку на зони з власними Controllers і Views; [Area("Name")] + MapAreaControllerRoute
  • Порядок маршрутів критичний: специфічніші — першими, MapDefaultControllerRoute — останнім

У наступній статті — Model Binding: як параметри HTTP-запиту (route, query, form, body) автоматично перетворюються на параметри ваших Action-методів.