ASP.NET Core MVC

Глобалізація та Локалізація MVC

Globalization та Localization в ASP.NET Core MVC: IStringLocalizer<T>, IHtmlLocalizer<T>, IViewLocalizer, ресурсні файли .resx, RequestLocalizationMiddleware, визначення культури через URL-сегмент, cookie та Accept-Language. Демо: перемикач мови uk-UA/en-US/pl-PL у навбарі з локалізованими помилками валідації.

Глобалізація та Локалізація MVC

Вас попросили зробити застосунок доступним для клієнтів у трьох країнах: Україна, США та Польща. Кожна з них очікує свою мову інтерфейсу, формат дат (27.03.2026 проти 3/27/2026), роздільник у числах і позначення валюти. Це не одна задача — це дві різні концепції, що часто плутають.

Globalization (Глобалізація) — підтримка різних форматів для дат, чисел, валют залежно від культури (CultureInfo). Наприклад, 1 234,56 (uk-UA) проти 1,234.56 (en-US).

Localization (Локалізація) — переклад текстового вмісту інтерфейсу на різні мови. «Зберегти» → «Save» → «Zapisz».

Ці дві речі тісно пов'язані й у ASP.NET Core реалізуються через єдину систему Microsoft.Extensions.Localization.


Архітектура локалізації в ASP.NET Core

Система локалізації складається з трьох рівнів:

Запит
  ↓
RequestLocalizationMiddleware         ← визначає культуру для запиту
  ↓
CultureInfo.CurrentCulture            ← встановлена культура (дати, числа)
CultureInfo.CurrentUICulture          ← UI-культура (рядки перекладу)
  ↓
IStringLocalizer<T>                   ← повертає переклад рядка
  ↓
IResourceManager → .resx files        ← читає переклади з ресурсних файлів

CurrentCulture визначає форматування (дати, числа). CurrentUICulture визначає яку мову використовувати для перекладів. Зазвичай вони однакові.


Крок 1: Реєстрація та налаштування

Program.cs
using Microsoft.AspNetCore.Localization;
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);

// Реєструємо локалізацію — вказуємо де шукати .resx файли
builder.Services.AddLocalization(options =>
    options.ResourcesPath = "Resources" // папка Resources/ у корені проєкту
);

// MVC з підтримкою локалізованих View та DataAnnotations
builder.Services.AddControllersWithViews()
    .AddViewLocalization()           // IViewLocalizer у .cshtml
    .AddDataAnnotationsLocalization(); // локалізовані повідомлення валідації

// ── Налаштування підтримуваних культур ────────────────────────────
var supportedCultures = new[]
{
    new CultureInfo("uk-UA"),  // Українська
    new CultureInfo("en-US"),  // Англійська (США)
    new CultureInfo("pl-PL"),  // Польська
};

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.DefaultRequestCulture = new RequestCulture("uk-UA");
    options.SupportedCultures = supportedCultures;      // форматування
    options.SupportedUICultures = supportedCultures;    // переклади

    // Порядок провайдерів: перший спрацює → решта ігноруються
    options.RequestCultureProviders =
    [
        // 1. З сегменту URL: /en-US/articles → culture=en-US
        new RouteDataRequestCultureProvider { RouteDataStringKey = "culture" },
        // 2. З cookie "culture"
        new CookieRequestCultureProvider(),
        // 3. З Accept-Language заголовка браузера
        new AcceptLanguageHeaderRequestCultureProvider(),
    ];
});

var app = builder.Build();

// ── Middleware ─────────────────────────────────────────────────────
app.UseRequestLocalization(); // ← обов'язково перед UseRouting i UseEndpoints

app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

// Маршрут з опціональним culture-параметром
app.MapControllerRoute(
    name: "culture",
    pattern: "{culture=uk-UA}/{controller=Home}/{action=Index}/{id?}"
);

app.Run();

RouteDataRequestCultureProvider зчитує значення культури з параметра {culture} маршруту. Тому URL /en-US/articlesculture=en-USCultureInfo("en-US").


Крок 2: Структура .resx файлів

Ресурсні файли розташовуються у папці Resources/ і іменуються за конвенцією: {Namespace}.{ControllerOrViewName}.{culture}.resx.

Resources/
├── Controllers/
│   ├── HomeController.uk-UA.resx      ← рядки для HomeController, uk-UA
│   ├── HomeController.en-US.resx      ← рядки для HomeController, en-US
│   └── HomeController.pl-PL.resx
├── Views/
│   ├── Home/
│   │   ├── Index.uk-UA.resx           ← рядки для Home/Index.cshtml, uk-UA
│   │   └── Index.en-US.resx
│   └── Shared/
│       ├── _Layout.uk-UA.resx
│       └── _Layout.en-US.resx
└── SharedResource.uk-UA.resx          ← спільні рядки для всіх
    SharedResource.en-US.resx
    SharedResource.pl-PL.resx

Формат .resx файлу — XML з парами ключ-значення:

Resources/Controllers/HomeController.uk-UA.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Welcome" xml:space="preserve">
    <value>Ласкаво просимо!</value>
  </data>
  <data name="ArticlesTitle" xml:space="preserve">
    <value>Статті</value>
  </data>
  <data name="ReadMore" xml:space="preserve">
    <value>Читати далі</value>
  </data>
</root>
Resources/Controllers/HomeController.en-US.resx
<?xml version="1.0" encoding="utf-8"?>
<root>
  <data name="Welcome" xml:space="preserve">
    <value>Welcome!</value>
  </data>
  <data name="ArticlesTitle" xml:space="preserve">
    <value>Articles</value>
  </data>
  <data name="ReadMore" xml:space="preserve">
    <value>Read more</value>
  </data>
</root>
Visual Studio та Rider мають вбудовані редактори для .resx файлів — таблиця ключ/значення/коментар. Але їх також можна редагувати як звичайний XML. Існують також інструменти типу ResX Resource Manager (VS extension) для зручного управління перекладами.

Крок 3: IStringLocalizer у Controllers

Controllers/HomeController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;

namespace BlogApp.Controllers;

public class HomeController : Controller
{
    // IStringLocalizer<T> — T вказує який .resx файл шукати
    private readonly IStringLocalizer<HomeController> _localizer;

    public HomeController(IStringLocalizer<HomeController> localizer)
    {
        _localizer = localizer;
    }

    public IActionResult Index()
    {
        // _localizer["Welcome"] → знаходить рядок "Welcome" у відповідному .resx
        // Якщо рядок не знайдено — повертає сам ключ "Welcome" (не кидає виняток)
        ViewData["Title"] = _localizer["ArticlesTitle"];

        // Форматування з параметрами (як string.Format)
        var userName = "Іван";
        ViewBag.Greeting = _localizer["GreetingWith", userName]; // "Вітаємо, Іван!"

        return View();
    }
}

IStringLocalizer<T> — основний інтерфейс. T — тип (зазвичай Controller або спеціальний клас SharedResource), до якого прив'язаний .resx файл.

Спільні рядки через SharedResource

Resources/SharedResource.cs
namespace BlogApp.Resources;

// Порожній клас — лише маркер для IStringLocalizer<SharedResource>
public class SharedResource { }
Controllers/ArticleController.cs
using BlogApp.Resources;
using Microsoft.Extensions.Localization;

public class ArticleController : Controller
{
    private readonly IStringLocalizer<SharedResource> _sharedLocalizer;
    private readonly IStringLocalizer<ArticleController> _localizer;

    public ArticleController(
        IStringLocalizer<SharedResource> sharedLocalizer,
        IStringLocalizer<ArticleController> localizer)
    {
        _sharedLocalizer = sharedLocalizer;
        _localizer = localizer;
    }

    public IActionResult Index()
    {
        // Спільні рядки: кнопки, навігація, загальні повідомлення
        ViewBag.SaveButton = _sharedLocalizer["Save"];
        ViewBag.CancelButton = _sharedLocalizer["Cancel"];

        // Специфічні для контролера
        ViewData["Title"] = _localizer["PageTitle"];

        return View();
    }
}

Крок 4: IViewLocalizer у .cshtml

У View використовується IViewLocalizer — специфічний для View варіант що шукає .resx за шляхом View:

Views/Home/Index.cshtml
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
@inject IHtmlLocalizer<BlogApp.Resources.SharedResource> SharedLocalizer

@* IViewLocalizer — автоматично ескейпує HTML *@
<h1>@Localizer["Welcome"]</h1>

@* IHtmlLocalizer — якщо рядок може містити HTML (не ескейпується) *@
@* Наприклад: "<strong>Новий</strong> функціонал" *@
<p>@SharedLocalizer["NewFeatureAnnouncement"]</p>

@* Параметри *@
<p>@Localizer["ArticleCount", Model.Count]</p>

@* Дата у форматі поточної культури *@
<time>@DateTime.Now.ToString("D")</time>
@* uk-UA: "27 березня 2026 р." | en-US: "Saturday, March 27, 2026" | pl-PL: "27 marca 2026" *@

@* Число у форматі культури *@
<span>@(1234567.89.ToString("N2"))</span>
@* uk-UA: "1 234 567,89" | en-US: "1,234,567.89" *@

IViewLocalizer — підкласс IHtmlLocalizer що автоматично визначає який .resx файл шукати за шляхом поточного View (Views/Home/Index.cshtmlResources/Views/Home/Index.{culture}.resx).


Крок 5: Локалізовані повідомлення валідації

AddDataAnnotationsLocalization() дозволяє перекладати повідомлення DataAnnotations:

Models/RegisterDto.cs
using System.ComponentModel.DataAnnotations;

public class RegisterDto
{
    // Ключ для локалізації — рядок ErrorMessage
    [Required(ErrorMessage = "FieldRequired")]
    [StringLength(50, MinimumLength = 3, ErrorMessage = "UsernameLength")]
    [Display(Name = "Username")]
    public string Username { get; set; } = "";

    [Required(ErrorMessage = "FieldRequired")]
    [EmailAddress(ErrorMessage = "InvalidEmail")]
    [Display(Name = "Email")]
    public string Email { get; set; } = "";
}
Resources/SharedResource.uk-UA.resx
<data name="FieldRequired"><value>Поле '{0}' є обов'язковим</value></data>
<data name="UsernameLength"><value>Від {2} до {1} символів</value></data>
<data name="InvalidEmail"><value>Введіть коректний email</value></data>
Resources/SharedResource.en-US.resx
<data name="FieldRequired"><value>The '{0}' field is required</value></data>
<data name="UsernameLength"><value>From {2} to {1} characters</value></data>
<data name="InvalidEmail"><value>Please enter a valid email address</value></data>

Крок 6: Перемикач мови

Controllers/LanguageController.cs
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;

namespace BlogApp.Controllers;

public class LanguageController : Controller
{
    // POST /language/set
    [HttpPost]
    public IActionResult Set(string culture, string returnUrl = "/")
    {
        // CookieRequestCultureProvider зчитує культуру з цього cookie
        Response.Cookies.Append(
            CookieRequestCultureProvider.DefaultCookieName,
            CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
            new CookieOptions
            {
                Expires = DateTimeOffset.UtcNow.AddYears(1),
                IsEssential = true,
                SameSite = SameSiteMode.Strict
            }
        );

        return LocalRedirect(returnUrl);
    }
}
Views/Shared/_Layout.cshtml — перемикач мови
@using Microsoft.AspNetCore.Builder
@using Microsoft.Extensions.Options
@inject IOptions<RequestLocalizationOptions> LocalizationOptions

@{
    var cultures = LocalizationOptions.Value.SupportedUICultures ?? [];
    var currentCulture = CultureInfo.CurrentUICulture.Name;
}

<div class="dropdown">
    <button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
        🌐 @CultureInfo.CurrentUICulture.NativeName
    </button>
    <ul class="dropdown-menu dropdown-menu-end">
        @foreach (var culture in cultures)
        {
            <li>
                <form asp-controller="Language" asp-action="Set" method="post">
                    <input type="hidden" name="culture" value="@culture.Name">
                    <input type="hidden" name="returnUrl" value="@Context.Request.Path">
                    <button type="submit"
                            class="dropdown-item @(culture.Name == currentCulture ? "active" : "")">
                        @culture.NativeName
                    </button>
                </form>
            </li>
        }
    </ul>
</div>

Варіант 2: через URL-сегмент (SEO-орієнтований)

При маршруті {culture=uk-UA}/{controller}/{action} URL містить мову: /en-US/articles. Це краще для SEO — кожна мовна версія має унікальний URL.

Щоб посилання автоматично містили поточну культуру:

Infrastructure/CultureRouteConstraint.cs
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using System.Globalization;

namespace BlogApp.Infrastructure;

// Обмеження маршруту: лише валідні культури
public class CultureRouteConstraint : IRouteConstraint
{
    private static readonly HashSet<string> _validCultures =
        ["uk-UA", "en-US", "pl-PL"];

    public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
                      RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (!values.TryGetValue(routeKey, out var value)) return false;
        return _validCultures.Contains(value?.ToString() ?? "");
    }
}
Views/Shared/_Layout.cshtml — culture URL links
@{
    var currentCulture = CultureInfo.CurrentUICulture.Name;
    var routeValues = ViewContext.RouteData.Values;
}

@* Посилання на перемикач мови через URL *@
<a asp-controller="Article" asp-action="Index"
   asp-route-culture="en-US">English</a>

<a asp-controller="Article" asp-action="Index"
   asp-route-culture="uk-UA">Українська</a>

Форматування залежно від культури

Глобалізація — це не лише переклади. Числа, дати та валюти форматуються автоматично за CultureInfo.CurrentCulture:

Controllers/StatisticsController.cs
public IActionResult Dashboard()
{
    var stats = new DashboardStats
    {
        TotalRevenue = 1_234_567.89m,
        OrderCount = 42_150,
        AverageOrderValue = 29.30m,
        LastUpdated = DateTime.Now
    };
    return View(stats);
}
Views/Statistics/Dashboard.cshtml
@model DashboardStats

@* Форматування числа — культуро-залежне *@
<dd>@Model.TotalRevenue.ToString("C")</dd>
@* uk-UA: 1 234 567,89 ₴  | en-US: $1,234,567.89 | pl-PL: 1 234 567,89 zł *@

<dd>@Model.OrderCount.ToString("N0")</dd>
@* uk-UA: 42 150 | en-US: 42,150 | pl-PL: 42 150 *@

<dd>@Model.LastUpdated.ToString("f")</dd>
@* uk-UA: "27 березня 2026 р. 14:30"
   en-US: "Saturday, March 27, 2026 2:30 PM"
   pl-PL: "27 marca 2026 14:30" *@

Demо-проєкт: Blog з перемикачем uk-UA / en-US / pl-PL

Controllers/HomeController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using BlogApp.Resources;

namespace BlogApp.Controllers;

public class HomeController : Controller
{
    private readonly IStringLocalizer<HomeController> _localizer;
    private readonly IStringLocalizer<SharedResource> _shared;

    public HomeController(
        IStringLocalizer<HomeController> localizer,
        IStringLocalizer<SharedResource> shared)
    {
        _localizer = localizer;
        _shared = shared;
    }

    public IActionResult Index()
    {
        ViewData["Title"] = _localizer["WelcomeTitle"];
        ViewBag.Description = _localizer["WelcomeDescription",
            CultureInfo.CurrentUICulture.NativeName];
        return View();
    }
}
Resources/Controllers/HomeController.uk-UA.resx
<data name="WelcomeTitle"><value>Ласкаво просимо!</value></data>
<data name="WelcomeDescription"><value>Ви читаєте блог мовою: {0}</value></data>
Resources/Controllers/HomeController.en-US.resx
<data name="WelcomeTitle"><value>Welcome!</value></data>
<data name="WelcomeDescription"><value>You are reading this blog in: {0}</value></data>
Resources/Controllers/HomeController.pl-PL.resx
<data name="WelcomeTitle"><value>Witaj!</value></data>
<data name="WelcomeDescription"><value>Czytasz tego bloga w języku: {0}</value></data>
Views/Home/Index.cshtml
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<div class="hero">
    <h1>@ViewData["Title"]</h1>
    <p class="lead">@ViewBag.Description</p>

    <div class="stats mt-4">
        @* Числа форматуються автоматично за CurrentCulture *@
        <span>@(123456.78.ToString("N2"))</span>
        <span>@DateTime.Today.ToString("D")</span>
    </div>
</div>

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

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

Завдання 1.1. Додайте локалізацію до існуючого MovieController: для Index i Details Views використайте IViewLocalizer для перекладу заголовків та кнопок («Всі фільми» / «All Movies» / «Wszystkie filmy», «Деталі» / «Details» / «Szczegóły»). Перевірте що після перемикання cookie перемикач мови правильно змінює лише рядки без перезавантаження даних.

Завдання 1.2. Реалізуйте .resx файли для трьох мов з мінімум 8 рядками кожен. Переконайтеся що при відсутньому перекладі (відсутній ключ) — IStringLocalizer повертає сам ключ, а не кидає виняток. Продемонструйте це тестом: localizer["NonExistentKey"].Value == "NonExistentKey".

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

Завдання 2.1. Реалізуйте URL-based culture switching з RouteDataRequestCultureProvider:

  • Маршрут: {culture=uk-UA}/{controller=Home}/{action=Index}/{id?}
  • CultureRouteConstraint обмежує лише uk-UA, en-US, pl-PL
  • Tag Helper Link автоматично зберігає поточну culture у генерованих URL
  • При зміні культури через випадаючий список — RedirectToAction з новим culture у route

Завдання 2.2. Забезпечте локалізацію повідомлень валідації для форми реєстрації (RegisterDto з попередньої статті): ключі замість конкретних рядків у ErrorMessage, .resx файли для трьох мов. Перевірте: при відправці порожньої форми з cookie en-US — помилки по-англійськи; при uk-UA — по-українськи.

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

Завдання 3.1. Реалізуйте повністю локалізований застосунок з підтримкою RTL (right-to-left) на прикладі арабської:

  • Додайте ar-SA як четверту культуру
  • _Layout.cshtml: <html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName" dir="@(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? "rtl" : "ltr")">
  • CSS Bootstrap RTL: <link rel="stylesheet" href="~/lib/bootstrap/rtl/bootstrap.rtl.min.css" ...> — підключати лише для RTL культур
  • Мінімум 10 рядків перекладу для ar-SA для Home та Article Views

Резюме

  • Globalization = форматування дат, чисел, валют за CultureInfo.CurrentCulture. Відбувається автоматично при ToString("D"), ToString("C") тощо
  • Localization = переклад рядків інтерфейсу через .resx файли + IStringLocalizer<T>
  • AddLocalization(options => options.ResourcesPath = "Resources") — реєстрація сервісів
  • AddViewLocalization()IViewLocalizer у .cshtml. AddDataAnnotationsLocalization() — локалізовані повідомлення валідації
  • .resx файл{Namespace}.{ControllerName}.{culture}.resx в папці Resources/
  • IStringLocalizer<T> — в Controllers. IViewLocalizer / IHtmlLocalizer<T> — у Views
  • RequestLocalizationMiddleware + провайдери: RouteData (URL), Cookie, Accept-Language
  • CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)) — зберегти культуру у cookie
  • Форматування автоматичне: 1234.5.ToString("N2")"1 234,50" (uk-UA) або "1,234.50" (en-US)

У фінальній статті — Підсумковий проєкт: Blog-платформа що поєднує всі концепції курсу — Areas, Filters, View Components, HTMX, File Upload та Localization.