Вас попросили зробити застосунок доступним для клієнтів у трьох країнах: Україна, США та Польща. Кожна з них очікує свою мову інтерфейсу, формат дат (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.
Система локалізації складається з трьох рівнів:
Запит
↓
RequestLocalizationMiddleware ← визначає культуру для запиту
↓
CultureInfo.CurrentCulture ← встановлена культура (дати, числа)
CultureInfo.CurrentUICulture ← UI-культура (рядки перекладу)
↓
IStringLocalizer<T> ← повертає переклад рядка
↓
IResourceManager → .resx files ← читає переклади з ресурсних файлів
CurrentCulture визначає форматування (дати, числа). CurrentUICulture визначає яку мову використовувати для перекладів. Зазвичай вони однакові.
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/articles → culture=en-US → CultureInfo("en-US").
Ресурсні файли розташовуються у папці 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 з парами ключ-значення:
<?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>
<?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>
.resx файлів — таблиця ключ/значення/коментар. Але їх також можна редагувати як звичайний XML. Існують також інструменти типу ResX Resource Manager (VS extension) для зручного управління перекладами.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 файл.
namespace BlogApp.Resources;
// Порожній клас — лише маркер для IStringLocalizer<SharedResource>
public class SharedResource { }
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();
}
}
У View використовується IViewLocalizer — специфічний для View варіант що шукає .resx за шляхом View:
@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.cshtml → Resources/Views/Home/Index.{culture}.resx).
AddDataAnnotationsLocalization() дозволяє перекладати повідомлення DataAnnotations:
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; } = "";
}
<data name="FieldRequired"><value>Поле '{0}' є обов'язковим</value></data>
<data name="UsernameLength"><value>Від {2} до {1} символів</value></data>
<data name="InvalidEmail"><value>Введіть коректний email</value></data>
<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>
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);
}
}
@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>
При маршруті {culture=uk-UA}/{controller}/{action} URL містить мову: /en-US/articles. Це краще для SEO — кожна мовна версія має унікальний URL.
Щоб посилання автоматично містили поточну культуру:
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() ?? "");
}
}
@{
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:
public IActionResult Dashboard()
{
var stats = new DashboardStats
{
TotalRevenue = 1_234_567.89m,
OrderCount = 42_150,
AverageOrderValue = 29.30m,
LastUpdated = DateTime.Now
};
return View(stats);
}
@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" *@
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();
}
}
<data name="WelcomeTitle"><value>Ласкаво просимо!</value></data>
<data name="WelcomeDescription"><value>Ви читаєте блог мовою: {0}</value></data>
<data name="WelcomeTitle"><value>Welcome!</value></data>
<data name="WelcomeDescription"><value>You are reading this blog in: {0}</value></data>
<data name="WelcomeTitle"><value>Witaj!</value></data>
<data name="WelcomeDescription"><value>Czytasz tego bloga w języku: {0}</value></data>
@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. Додайте локалізацію до існуючого MovieController: для Index i Details Views використайте IViewLocalizer для перекладу заголовків та кнопок («Всі фільми» / «All Movies» / «Wszystkie filmy», «Деталі» / «Details» / «Szczegóły»). Перевірте що після перемикання cookie перемикач мови правильно змінює лише рядки без перезавантаження даних.
Завдання 1.2. Реалізуйте .resx файли для трьох мов з мінімум 8 рядками кожен. Переконайтеся що при відсутньому перекладі (відсутній ключ) — IStringLocalizer повертає сам ключ, а не кидає виняток. Продемонструйте це тестом: localizer["NonExistentKey"].Value == "NonExistentKey".
Завдання 2.1. Реалізуйте URL-based culture switching з RouteDataRequestCultureProvider:
{culture=uk-UA}/{controller=Home}/{action=Index}/{id?}CultureRouteConstraint обмежує лише uk-UA, en-US, pl-PLculture у генерованих URLRedirectToAction з новим culture у routeЗавдання 2.2. Забезпечте локалізацію повідомлень валідації для форми реєстрації (RegisterDto з попередньої статті): ключі замість конкретних рядків у ErrorMessage, .resx файли для трьох мов. Перевірте: при відправці порожньої форми з cookie en-US — помилки по-англійськи; при uk-UA — по-українськи.
Завдання 3.1. Реалізуйте повністю локалізований застосунок з підтримкою RTL (right-to-left) на прикладі арабської:
ar-SA як четверту культуру_Layout.cshtml: <html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName" dir="@(CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft ? "rtl" : "ltr")"><link rel="stylesheet" href="~/lib/bootstrap/rtl/bootstrap.rtl.min.css" ...> — підключати лише для RTL культурar-SA для Home та Article ViewsCultureInfo.CurrentCulture. Відбувається автоматично при ToString("D"), ToString("C") тощо.resx файли + IStringLocalizer<T>AddLocalization(options => options.ResourcesPath = "Resources") — реєстрація сервісівAddViewLocalization() — IViewLocalizer у .cshtml. AddDataAnnotationsLocalization() — локалізовані повідомлення валідації.resx файл — {Namespace}.{ControllerName}.{culture}.resx в папці Resources/IStringLocalizer<T> — в Controllers. IViewLocalizer / IHtmlLocalizer<T> — у ViewsRequestLocalizationMiddleware + провайдери: RouteData (URL), Cookie, Accept-LanguageCookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)) — зберегти культуру у cookie1234.5.ToString("N2") → "1 234,50" (uk-UA) або "1,234.50" (en-US)У фінальній статті — Підсумковий проєкт: Blog-платформа що поєднує всі концепції курсу — Areas, Filters, View Components, HTMX, File Upload та Localization.
Завантаження та обробка файлів
File Upload в ASP.NET Core MVC: IFormFile та IFormFileCollection, валідація (MIME-тип, розмір, розширення), збереження у wwwroot та поза webroot, streaming великих файлів, FileResult та PhysicalFileResult, захист доступу до файлів. Демо: UserProfileController — аватар, галерея, захищені файли.
Підсумковий проєкт: Блог-платформа
Наскрізний проєкт курсу ASP.NET Core MVC — Blog-платформа з двома Areas (Public + Admin), Filters (авторизація, аудит), View Components (кошик коментарів, категорії), Display Templates (Article Card), FluentValidation, HTMX-коментарі та live-search, завантаження обкладинок статей, локалізація uk-UA/en-US.