Architektura Aplikacji Internetowych / Programowanie .NET

Rok akademicki: 2026/27

Instrukcja laboratoryjna: Architektura ASP.NET Core MVC (.NET 8/9), Tag Helpers, Potok Żądań HTTP i Walidacja

Prowadzący: Tomasz Goluch
Wersja: 1.0


1. Wprowadzenie i ewolucja platformy

Cel: Zrozumienie założeń wzorca Model-View-Controller (MVC), ewolucji od klasycznego ASP.NET do wieloplatformowego ASP.NET Core (.NET 8 LTS / .NET 9), mechanizmów kompilacji widoków oraz architektury potoku żądań.

Wzorzec architektoniczny MVC (Model-View-Controller) realizuje zasadę separacji odpowiedzialności (Separation of Concerns) poprzez podział aplikacji na trzy autonomiczne warstwy:

       Żądanie HTTP (GET / POST)
                  │
                  ▼
         [ Potok Middleware ] (Obsługa błędów, HTTPS, Pliki statyczne, Sesja)
                  │
                  ▼
         [ Silnik Routingu ] ──► Dopasowanie URL do Kontrolera i Akcji
                  │
                  ▼
         [ Kontroler (Controller) ]
            │                ▲
  Pobiera / │                │ Zwraca dane /
  Modyfikuje│                │ Obiekt ViewModel
            ▼                │
         [ Model / Baza Danych ]
            │
            ▼ Kontroler przekazuje Model do Widoku
         [ Widok Razor (View .cshtml) ] ──► Kompilacja do HTML ──► Odpowiedź HTTP

🧠 Zrozumieć różnicę pokoleniową: ASP.NET (.NET Framework) vs ASP.NET Core (.NET 8/9+)
W klasycznym ASP.NET MVC aplikacja była nierozerwalnie powiązana z systemem Windows, biblioteką System.Web.dll oraz serwerem IIS/IIS Express. Środowiska alternatywne, takie jak Mono pod Linuksem z serwerem Apache i modułem mod_mono lub serwerem XSP, wymagały złożonej konfiguracji i nie gwarantowały pełnej zgodności.
ASP.NET Core (.NET 8/9) to platforma modularna, otwartoźródłowa i natywnie wieloplatformowa. Domyślnie wykorzystuje wbudowany, ultrawydajny serwer Kestrel (aplikacja jest w pełni samohostująca – self-hosted), eliminując konieczność instalacji zewnętrznych serwerów aplikacyjnych do uruchomienia procesu produkcyjnego.


2. Środowisko uruchomieniowe, narzędzia CLI i konteneryzacja

Cel: Konfiguracja środowiska Visual Studio 2022 oraz .NET CLI, opanowanie wieloplatformowego uruchamiania aplikacji i konteneryzacji w środowisku Docker.

1. Konfiguracja środowiska

2. Inicjalizacja i uruchomienie z wiersza poleceń (.NET CLI)

Tworzenie nowego projektu typu ASP.NET Core MVC:

dotnet new mvc -n MvcLabApp -f net8.0 --auth None
cd MvcLabApp
dotnet run

Aplikacja uruchomi serwer Kestrel i wyświetli adresy nasłuchiwania w konsoli (np. https://localhost:7001 oraz http://localhost:5001).

3. Konteneryzacja aplikacji w Dockerze

Aplikacje ASP.NET Core mogą być w prosty sposób uruchamiane w odizolowanych kontenerach linuksowych. Wykorzystujemy wieloetapowy proces budowania (multi-stage build), oddzielający środowisko kompilacji SDK od odchudzonego obrazu wykonawczego:

# ETAP 1: Kompilacja i publikacja aplikacji (Build Stage)
FROM [mcr.microsoft.com/dotnet/sdk:8.0](https://mcr.microsoft.com/dotnet/sdk:8.0) AS build
WORKDIR /src
COPY ["MvcLabApp.csproj", "./"]
RUN dotnet restore "MvcLabApp.csproj"
COPY . .
RUN dotnet publish "MvcLabApp.csproj" -c Release -o /app/publish /p:UseAppHost=false

# ETAP 2: Minimalistyczne środowisko uruchomieniowe (Runtime Stage)
FROM [mcr.microsoft.com/dotnet/aspnet:8.0](https://mcr.microsoft.com/dotnet/aspnet:8.0) AS final
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MvcLabApp.dll"]

3. Struktura projektu i potok przetwarzania żądań (Middleware Pipeline)

Cel: Poznanie modułowej struktury aplikacji, rejestracji usług w kontenerze Dependency Injection oraz konfiguracji potoku HTTP w modelu Minimal Hosting (Program.cs).

1. Organizacja katalogów projektu

2. Rejestracja usług i middleware w Program.cs

W ASP.NET Core konfiguracja odbywa się z wykorzystaniem zunifikowanego pliku Program.cs:

var builder = WebApplication.CreateBuilder(args);

// 1. Rejestracja usług w kontenerze Dependency Injection
builder.Services.AddControllersWithViews();

// Rejestracja pamięci podręcznej i stanu sesji
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
    options.IdleTimeout = TimeSpan.FromMinutes(15);
    options.Cookie.HttpOnly = true;
    options.Cookie.IsEssential = true;
});

// Konfiguracja polityki ciasteczek
builder.Services.Configure<CookiePolicyOptions>(options =>
{
    options.CheckConsentNeeded = context => false;
    options.MinimumSameSitePolicy = SameSiteMode.Lax;
});

var app = builder.Build();

// 2. Konfiguracja potoku przetwarzania żądań HTTP (Kolejność ma znaczenie!)
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}
else
{
    app.UseDeveloperExceptionPage();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseSession(); // Sesja musi być umieszczona po UseRouting(), a przed UseAuthorization()

app.UseAuthentication();
app.UseAuthorization();

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

app.Run();
💡 Wyjaśnienie: Dlaczego kolejność komponentów Middleware w potoku jest krytyczna? (Kliknij, aby rozwinąć/zwinąć)

Potok żądań w ASP.NET Core działa dwukierunkowo – żądanie przepływa przez kolejne komponenty middleware w głąb potoku, a wygenerowana odpowiedź wraca przez nie w kolejności odwrotnej:

  1. UseExceptionHandler / UseDeveloperExceptionPage: Musi znajdować się na samym początku, aby przechwytywać wszelkie wyjątki zgłoszone przez komponenty umieszczone dalej w potoku.
  2. UseStaticFiles: Umieszczone wcześnie, aby pliki statyczne (.css, .js, obrazy z wwwroot) były natychmiast zwracane bez niepotrzebnego uruchamiania mechanizmów routingu czy sprawdzania uprawnień.
  3. UseRouting: Dopasowuje adres URL do właściwego punktu końcowego (kontrolera i akcji).
  4. UseSession: Ładuje stan sesji powiązany z ciasteczkiem użytkownika przed wywołaniem logiki biznesowej.
  5. UseAuthentication i UseAuthorization: Sprawdzają tożsamość i uprawnienia użytkownika dla wskazanego punktu końcowego.
  6. MapControllerRoute: Ostatecznie uruchamia wskazaną akcję kontrolera.

4. Routing: Konwencjonalny vs Atrybutowy oraz Ograniczenia Tras (Constraints)

Cel: Opanowanie reguł dopasowywania ścieżek URL, tworzenia szablonów parametrów oraz implementacji własnych ograniczeń tras.

1. Routing konwencjonalny (Conventional Routing)

Trasy konwencjonalne rejestrowane są centralnie w pliku Program.cs przy pomocy metody MapControllerRoute. Trasa składa się z unikalnej nazwy (name), szablonu adresu (pattern) oraz opcjonalnych wartości domyślnych (defaults):

app.MapControllerRoute(
    name: "users_route",
    pattern: "Uzytkownicy/{action}/{id?}",
    defaults: new { controller = "Users", action = "Index" });

2. Wbudowane ograniczenia tras (Route Constraints)

Ograniczenia pozwalają odrzucić dopasowanie trasy na poziomie routingu, jeśli przekazany parametr nie spełnia kryteriów technicznych:

Ograniczenie Składnia w szablonie Opis działania
int / long {id:int} Wymaga poprawnej liczby całkowitej.
alpha {kod:alpha} Dopuszcza wyłącznie litery alfabetu łacińskiego [a-zA-Z].
range(min, max) {wiek:range(18, 100)} Wartość liczbowa mieszcząca się w zadanym przedziale.
minlength / maxlength {login:minlength(3):maxlength(20)} Ograniczenie minimalnej i maksymalnej długości tekstu.
regex(wzorzec) {data:regex(^\\d{{4}}-\\d{{2}}-\\d{{2}}$)} Weryfikacja zgodności z wyrażeniem regularnym.
guid {id:guid} Weryfikacja poprawności formatu GUID.

Wiele ograniczeń można łączyć kolejnymi dwukropkami, np. {id:int:min(1)}.

3. Własne ograniczenie trasy: implementacja IRouteConstraint

W przypadku niestandardowych reguł dopasowania tworzymy klasę implementującą interfejs IRouteConstraint:

using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Http;
using System.Globalization;

public class DivisibleBy10Constraint : IRouteConstraint
{
    public bool Match(
        HttpContext? httpContext,
        IRouter? route,
        string routeKey,
        RouteValueDictionary values,
        RouteDirection routeDirection)
    {
        if (values.TryGetValue(routeKey, out var value) && value != null)
        {
            if (int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int number))
            {
                return number % 10 == 0;
            }
        }
        return false;
    }
}

Rejestracja w Program.cs i użycie w trasie:

builder.Services.Configure<RouteOptions>(options =>
{
    options.ConstraintMap.Add("div10", typeof(DivisibleBy10Constraint));
});

app.MapControllerRoute(
    name: "calculator",
    pattern: "Obliczenia/Dziesiatki/{liczba:div10}",
    defaults: new { controller = "Calculator", action = "Process" });

4. Routing atrybutowy (Attribute Routing)

Routing atrybutowy definiowany jest bezpośrednio nad klasami kontrolerów i metodami akcji za pomocą atrybutu [Route] oraz atrybutów czasowników HTTP ([HttpGet], [HttpPost]):

[Route("produkty")]
public class ProductsController : Controller
{
    // Adres: /produkty/szczegoly/15
    [HttpGet("szczegoly/{id:int:min(1)}", Name = "ProductDetails")]
    public IActionResult Details(int id)
    {
        return View(id);
    }

    // Prefiks '/' na początku trasy akcji odcina trasę nadrzędną kontrolera: /cennik
    [HttpGet("/cennik")]
    public IActionResult PriceList()
    {
        return View();
    }
}

5. Kontrolery, Akcje i Przepływ Danych do Widoku

Cel: Zrozumienie cyklu życia kontrolera, obsługiwanych typów rezultatów (IActionResult) oraz bezpiecznych mechanizmów przekazywania danych do widoku.

1. Zasady budowy kontrolerów

2. Typy zwracane przez akcje (IActionResult)

Współczesne aplikacje zwracają implementacje interfejsu IActionResult za pośrednictwem metod pomocniczych klasy bazowej:

3. Zestawienie mechanizmów przekazywania danych do widoku

Mechanizm Typowanie Czas życia Zastosowanie
Model / ViewModel Silne (Strongly-typed) Bieżące żądanie Zalecany standard. Bezpieczeństwo typów w czasie kompilacji.
ViewData Słownik string → object Bieżące żądanie Drobne dane pomocnicze (np. tytuł strony w nagłówku ViewData["Title"]).
ViewBag Obiekt dynamiczny (dynamic) Bieżące żądanie Dynamiczna powłoka na ViewData (brak kontroli błędów kompilatora).
TempData Magazyn klucz-wartość Przetrwa jedno przekierowanie Komunikaty po akcjach POST (wzorzec Post-Redirect-Get).

Przykład zastosowania wzorca Post-Redirect-Get (PRG):

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create(ProductViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View(model); // Zwrot formularza z błędami w tym samym żądaniu
    }

    _repository.Add(model);
    TempData["SuccessMessage"] = "Produkt został pomyślnie dodany do katalogu.";
    return RedirectToAction(nameof(Index)); // Przekierowanie GET po zapisie POST
}

6. Silnik Widoków Razor i Nowoczesne Tag Helpers

Cel: Opanowanie składni szablonów Razor, mechanizmów zabezpieczeń przed atakami XSS oraz Tag Helperów.

1. Podstawy składni Razor

2. Szablony nadrzędne i sekcje (RenderBody / RenderSectionAsync)

Główny układ aplikacji (_Layout.cshtml) definiuje wspólną strukturę wizualną. Treść widoku wstrzykiwana jest za pomocą metody RenderBody(), a nazwane bloki za pośrednictwem RenderSectionAsync:

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="utf-8" />
    <title>@ViewData["Title"] - System Centralny</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
            <div class="container-fluid">
                <a class="navbar-brand" asp-controller="Home" asp-action="Index">System</a>
            </div>
        </nav>
    </header>

    <div class="container mt-4">
        @RenderBody()
    </div>

    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

3. Tag Helpers w formularzach

Tag Helpers zastąpiły dawne metody HTML Helpers (@Html.TextBoxFor), oferując składnię zgodną ze standardem HTML5 i pełne wsparcie edytora:

Rejestracja w Views/_ViewImports.cshtml:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

Kluczowe Tag Helpers:


7. Model Binding, Walidacja Hybrydowa i Zdalna (Remote Validation)

Cel: Opanowanie wiązania parametrów HTTP do modeli C#, ochrona przed podatnością Mass Assignment oraz konfiguracja walidacji wielopoziomowej.

1. Mechanizm Model Bindingu i bezpieczeństwo Mass Assignment

Silnik ASP.NET Core automatycznie odczytuje wartości parametrów z pól formularza (Form), danych trasy (RouteData) oraz ciągu zapytania (QueryString), konwertując je na odpowiednie typy w C#.

⚠️ Podatność Mass Assignment (Over-Posting)
Przekazywanie bezpośrednich encji bazy danych jako parametrów akcji kontrolera stwarza lukę bezpieczeństwa – użytkownik może przesłać dodatkowe pola w żądaniu HTTP (np. IsAdmin=true), które zostaną automatycznie powiązane z encją.
Dobra praktyka: Formularze powinny zawsze operować na dedykowanych modelach widoku (ViewModels / DTO), lub korzystać z atrybutu [Bind] określającego dozwoloną listę pól:

public IActionResult UpdateProfile([Bind("FirstName,LastName,Email")] UserProfileViewModel model)

2. Walidacja po stronie serwera i adnotacje DataAnnotations

Właściwości modelu opisujemy atrybutami z przestrzeni System.ComponentModel.DataAnnotations:

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

public class RegisterViewModel
{
    [Required(ErrorMessage = "Pole {0} jest wymagane.")]
    [StringLength(20, MinimumLength = 3, ErrorMessage = "{0} musi zawierać od {2} do {1} znaków.")]
    [Display(Name = "Nazwa użytkownika")]
    [Remote(action: "VerifyUsername", controller: "Account", ErrorMessage = "Podana nazwa jest już zajęta.")]
    public string Username { get; set; } = string.Empty;

    [Required(ErrorMessage = "Adres e-mail jest wymagany.")]
    [EmailAddress(ErrorMessage = "Nieprawidłowy format adresu e-mail.")]
    [Display(Name = "Adres e-mail")]
    public string Email { get; set; } = string.Empty;

    [Required]
    [Range(18, 100, ErrorMessage = "Wymagany wiek: od {1} do {2} lat.")]
    [Display(Name = "Wiek")]
    public int Age { get; set; }

    [Required]
    [DataType(DataType.Password)]
    [StringLength(100, MinimumLength = 8, ErrorMessage = "Hasło musi mieć co najmniej 8 znaków.")]
    [Display(Name = "Hasło")]
    public string Password { get; set; } = string.Empty;
}

Weryfikacja w kontrolerze z wykorzystaniem obiektu ModelState:

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Register(RegisterViewModel model)
{
    if (model.Username.Equals("administrator", StringComparison.OrdinalIgnoreCase))
    {
        ModelState.AddModelError(nameof(model.Username), "Nazwa 'administrator' jest zastrzeżona.");
    }

    if (!ModelState.IsValid)
    {
        return View(model); // Powrót do widoku formularza z prezentacją błędów
    }

    return RedirectToAction("Success");
}

3. Walidacja po stronie klienta (Client-Side Unobtrusive Validation)

Zapobiega wysyłaniu formularza z błędami do serwera, sprawdzając reguły w przeglądarce przy pomocy biblioteki JavaScript. Wymaga dołączenia skryptów w sekcji widoku:

@section Scripts {
    @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

4. Walidacja zdalna (Remote Validation przez AJAX)

Pozwala asynchronicznie zweryfikować unikalność lub poprawność danych po stronie serwera bez przeładowywania strony (np. sprawdzenie dostępności loginu):

[HttpGet]
public IActionResult VerifyUsername(string username)
{
    bool exists = _userService.CheckIfUserExists(username);
    if (exists)
    {
        return Json($"Użytkownik o loginie '{username}' już istnieje.");
    }
    return Json(true); // Zwrócenie wartości true oznacza walidację pomyślną
}

8. Asynchroniczny Upload Plików i Bezpieczeństwo

Cel: Prawidłowa obsługa przesyłania plików na serwer, asynchroniczny zapis strumieniowy oraz zabezpieczenie przed atakami Path Traversal.

Formularz przesyłający pliki musi posiadać atrybut enctype="multipart/form-data":

<form asp-action="UploadAttachment" method="post" enctype="multipart/form-data">
    <div class="mb-3">
        <label class="form-label">Załącznik (PDF, JPG, PNG - max 5 MB):</label>
        <input type="file" name="fileUpload" class="form-control" />
    </div>
    <button type="submit" class="btn btn-primary">Prześlij plik</button>
</form>

Bezpieczna implementacja akcji przyjmującej IFormFile:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadAttachment(IFormFile? fileUpload)
{
    if (fileUpload == null || fileUpload.Length == 0)
    {
        ModelState.AddModelError(string.Empty, "Nie wybrano pliku do przesłania.");
        return View();
    }

    // 1. Walidacja rozmiaru pliku (np. max 5 MB)
    if (fileUpload.Length > 5 * 1024 * 1024)
    {
        ModelState.AddModelError(string.Empty, "Plik przekracza dopuszczalny limit 5 MB.");
        return View();
    }

    // 2. Walidacja rozszerzenia (Biała lista formatów)
    var allowedExtensions = new[] { ".pdf", ".jpg", ".png" };
    var extension = Path.GetExtension(fileUpload.FileName).ToLowerInvariant();
    if (!allowedExtensions.Contains(extension))
    {
        ModelState.AddModelError(string.Empty, "Niedozwolony format pliku.");
        return View();
    }

    // 3. Generowanie unikalnej nazwy pliku (Ochrona przed Path Traversal)
    var trustedFileName = $"{Guid.NewGuid()}{extension}";
    var targetFolder = Path.Combine(Directory.GetCurrentDirectory(), "App_Data", "Uploads");
    Directory.CreateDirectory(targetFolder);
    var targetPath = Path.Combine(targetFolder, trustedFileName);

    // 4. Asynchroniczne strumieniowanie pliku na dysk
    using (var stream = new FileStream(targetPath, FileMode.Create))
    {
        await fileUpload.CopyToAsync(stream);
    }

    TempData["Status"] = "Plik został pomyślnie przesłany.";
    return RedirectToAction(nameof(Index));
}

9. Zarządzanie Stanem: Sesja JSON i Polityka Ciasteczek

Cel: Wykorzystanie stanu sesji HTTP, bezpieczna serializacja obiektów z biblioteką System.Text.Json oraz konfiguracja ciasteczek sesyjnych.

Obiekt ISession w ASP.NET Core obsługuje natywnie jedynie operacje na typach int, string oraz tablicach bajtów. Aby przechowywać złożone obiekty biznesowe, tworzymy metody rozszerzające oparte na serializacji JSON:

using System.Text.Json;
using Microsoft.AspNetCore.Http;

public static class SessionExtensions
{
    public static void SetObject<T>(this ISession session, string key, T value)
    {
        session.SetString(key, JsonSerializer.Serialize(value));
    }

    public static T? GetObject<T>(this ISession session, string key)
    {
        var json = session.GetString(key);
        return json == null ? default : JsonSerializer.Deserialize<T>(json);
    }
}

Użycie w kontrolerze:

// Zapis obiektu do sesji
var userPreferences = new UserSettings { Theme = "Dark", PageSize = 25 };
HttpContext.Session.SetObject("Preferences", userPreferences);

// Odczyt obiektu z sesji
var currentSettings = HttpContext.Session.GetObject<UserSettings>("Preferences") ?? new UserSettings();

10. Filtry Akcji (Action Filters) i Globalna Obsługa Błędów

Cel: Opanowanie potoku filtrów MVC do obsługi zadań przekrojowych (Cross-Cutting Concerns) oraz konfiguracja stron błędów HTTP.

1. Implementacja filtru akcji (ActionFilterAttribute)

Filtry akcji pozwalają na uruchomienie wspólnego kodu przed (OnActionExecuting) oraz po (OnActionExecuted) wykonaniu metody akcji:

using Microsoft.AspNetCore.Mvc.Filters;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;

public class ExecutionBenchmarkAttribute : ActionFilterAttribute
{
    private Stopwatch? _stopwatch;

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        _stopwatch = Stopwatch.StartNew();
    }

    public override void OnActionExecuted(ActionExecutedContext context)
    {
        _stopwatch?.Stop();
        if (context.Controller is Controller controller)
        {
            controller.ViewData["ExecutionTimeMs"] = _stopwatch?.ElapsedMilliseconds;
        }
    }
}

Użycie filtru nad akcją lub całym kontrolerem:

[ExecutionBenchmark]
public IActionResult Report()
{
    return View();
}

2. Re-ekzekucja kodów błędów HTTP (UseStatusCodePagesWithReExecute)

Domyślnie serwer Kestrel zwraca pustą stronę w przypadku błędu HTTP 404 lub 403. Użycie middleware UseStatusCodePagesWithReExecute zachowuje poprawny kod odpowiedzi HTTP w nagłówkach, a jednocześnie przekazuje wykonanie do dedykowanego widoku błędu:

// W Program.cs:
app.UseStatusCodePagesWithReExecute("/Errors/Status", "?code={0}");

Kontroler obsługujący błędy:

public class ErrorsController : Controller
{
    public IActionResult Status(int code)
    {
        ViewBag.Code = code;
        ViewBag.Description = code switch
        {
            404 => "Nie znaleziono żądanego zasobu.",
            403 => "Brak uprawnień do przeglądania tej strony.",
            _ => "Wystąpił nieoczekiwany błąd protokołu HTTP."
        };
        return View();
    }
}

11. Wielojęzyczność i Lokalizacja (Localization)

Cel: Przygotowanie aplikacji do obsługi wielu wersji językowych za pomocą plików zasobów (.resx) oraz wstrzykiwania serwisu IViewLocalizer.

1. Rejestracja usług lokalizacji w Program.cs

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

builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

builder.Services.AddControllersWithViews()
    .AddViewLocalization()
    .AddDataAnnotationsLocalization();

var app = builder.Build();

var supportedCultures = new[]
{
    new CultureInfo("pl-PL"),
    new CultureInfo("pl"),
    new CultureInfo("en-US"),
    new CultureInfo("en")
};

app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("pl"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures
});

2. Tłumaczenie treści w widoku Razor

Pliki zasobów umieszczamy w katalogu Resources/Views/{Kontroler}/{Akcja}.{kultura}.resx. W widoku wstrzykujemy interfejs IViewLocalizer:

@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<h1>@Localizer["NaglowekGlowny"]</h1>
<p>@Localizer["OpisStrony"]</p>