Http Pipeline – czyli middleware HTTP

Http Pipeline – czyli middleware HTTP

Wstęp

Jak zapewne wiesz, sercem .Net jest middleware pipeline. To sprawia, że możemy sobie napisać dowolny komponent i wpiąć go w łańcuch przetwarzania żądania.

Jednak HttpClient też posiada swój „rurociąg”. Możesz napisać małe komponenty, które w odpowiedni sposób będą procesować żądanie. Dzięki temu możemy osiągnąć naprawdę bardzo fajne efekty, np. zautomatyzować wersjonowanie żądań albo odnawianie bearer tokena. W tym artykule pokażę Ci oba takie przykłady.

Czym jest HttpMessageHandler?

HttpMessageHandler zajmuje się najbardziej podstawową obsługą komunikatów. Każdy HttpClient zawiera HttpMessageHandler (domyślnie HttpClientHandler).

Czyli wyobraź sobie, jakby HttpClient był panem, który każe wysłać wiadomość, a MessageHandler był takim gołębiem pocztowym, który dalej się tym już zajmuje. To jest jednak klasa abstrakcyjna, po której dziedziczy kilka innych, m.in. DelegatingHandler, jak też wspomniany HttpClientHandler – gołąb pocztowy.

Czym jest DelegatingHandler?

I tu dochodzimy do sedna. DelegatingHandler to klasa, którą możesz wpiąć w łańcuch handlerów. Co więcej, każdy DelegatingHandler ma pod spodem HttpClientHandlera, który służy do faktycznego, fizycznego przekazania wiadomości.

To brzmi trochę jak czeskie kino, więc wejdźmy w przykład. Stwórzmy handler, który zapisze w logach wiadomość, że odbywa się żądanie.

Pierwszy handler

class LoggerHandler: DelegatingHandler
{
    private ILogger<LoggerHandler> _logger;
    public LoggerHandler(ILogger<LoggerHandler> logger)
    {
        _logger = logger;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"Żądanie na adres: {request.RequestUri.OriginalString}");
        
        var response = await base.SendAsync(request, cancellationToken);

        _logger.LogInformation($"Żądanie zakończone, odpowiedź: {response.StatusCode}");
        return response;
    }
}

Jak widać na załączonym obrazku, trzeba zrobić 3 rzeczy:

  • napisać klasę dziedziczącą po DelegatingHandler
  • przeciążyć metodę Send/SendAsync
  • wywołać Send/SendAsync z klasy bazowej.

Dopiero wywołanie SendAsync z klasy bazowej pchnie cały request do Internetów. Czyli, jeśli byś chciał, mógłbyś napisać takiego handlera, który niczego nie przepuści i zwróci jakiś ResponseMessage z dowolnym kodem.

Mając takiego handlera, musimy go wpiąć do pipeline’a http. Można to zrobić na dwa sposoby.

Rejestracja Handlera

Generalnie rejestrujemy go podczas rejestrowania serwisów. Konkretnie – podczas rejestrowania HttpClienta. O prawidłowym użyciu HttpClienta i tworzeniu go przez fabrykę, pisałem w tym artykule.

services.AddScoped<LoggerHandler>();
services.AddHttpClient("c1", client =>
{
    client.BaseAddress = new Uri("https://www.example.com");
})
.AddHttpMessageHandler<LoggerHandler>();

Najpierw rejestrujemy naszego handlera w DependencyInjection. Potem rejestrujemy HttpClient i dodajemy do niego naszego handlera przez metodę AddHttpMessageHandler. Pamiętaj tylko, żeby doinstalować z NuGeta paczkę Microsoft.Extensions.Http.

Tutaj możesz zarejestrować cały łańcuch takich handlerów. Oczywiście kolejność jest istotna. Handlery będą się wykonywały w kolejności ich rejestracji.

Jest jeszcze druga metoda. Jeśli z jakiegoś powodu tworzysz HttpClient ręcznie, możesz też utworzyć instancje swoich handlerów i umieścić jednego w drugim – jak w ruskiej babie, np:

services.AddScoped<LoggerHandler>();
services.AddScoped(sp =>
{
    var loggerHandler = sp.GetRequiredService<LoggerHandler>();
    var otherHandler = new OtherHandler();
    loggerHandler.InnerHandler = otherHandler;
    otherHandler.InnerHandler = new HttpClientHandler();

    var client = new HttpClient(loggerHandler);
    return client;
});

Spójrz, co ja tutaj robię. Na początku rejestruję LoggerHandler w dependency injection. Nie muszę tego oczywiście robić, ale mogę 🙂

Potem rejestruję fabrykę dla HttpClienta – tzn. metodę fabryczną (nie myl z HttpClientFactory) – ta metoda utworzy HttpClient, gdy będzie potrzebny.

I teraz w tej metodzie najpierw tworzę (za pomocą Dependency Injection) swój handler – LoggerHandler, potem tworzę jakiś inny handler o nazwie OtherHandler i teraz robię całą magię.

W środku LoggerHandlera umieszczam OtherHandlera. A w środku OtherHandlera umieszczam HttpClientHandler – bo jak pisałem wyżej – na samym dole zawsze jest HttpClientHandler (gołąb pocztowy), który zajmuje się już fizycznie przekazaniem message’a.

Czyli mamy LoggerHandlera, który ma w sobie OtherHandlera, który ma w sobie ostatnie ogniwo łańcucha – HttpClientHandlera.

Na koniec tworzę HttpClient, przekazując mu LoggerHandlera.

Oczywiście zgodnie z tym artykułem przestrzegam Cię przed takim tworzeniem HttpClienta. Zawsze na początku idź w stronę HttpClientFactory.

Do roboty – wersjonowanie API

W tym akapicie pokażę Ci bardzo prosty handler, który dodaje informację o wersji żądanego API.

O wersjonowaniu API pisałem już kiedyś tutaj. Napisałem w tym artykule: „Jeśli tworzę API, a do tego klienta, który jest oparty o HttpClient, bardzo lubię dawać informację o wersji w nagłówku. Wtedy w kliencie mam wszystko w jednym miejscu i nie muszę się martwić o poprawne budowanie ścieżki

W momencie, gdy zaczynamy zabawę z handlerami, ten argument o wyższości przekazywania wersji w nagłówku jest już trochę inwalidą. No bo przecież handler nam wszystko załatwi.

Załóżmy, że chcemy wywołać taką końcówkę: https://example.com/api/v1/clients

Normalnie w każdym wywołaniu requesta z HttpClienta musielibyśmy dbać o tę wersję:

httpClient.GetAsync("api/v1/clients");

ale, używając odpowiedniego DelegatingHandlera ten problem nam odpada i końcówkę możemy wywołać tak:

httpClient.GetAsync("api/clients");

Nasz delegating handler może wyglądać tak:

class ApiVersionHandler: DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var uriWithVersion = CreateUriWithVersion(request.RequestUri, "v1");
        request.RequestUri = uriWithVersion;
        
        return await base.SendAsync(request, cancellationToken);
    }

    private Uri CreateUriWithVersion(Uri originalUri, string version)
    {
        UriBuilder builder = new UriBuilder(originalUri);
        var segments = builder.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
        int apiSegmentIndex = segments.FindIndex(s => string.Compare("api", s, true) == 0);
        if (apiSegmentIndex < 0)
            return originalUri;

        segments.Insert(apiSegmentIndex + 1, version);
        builder.Path = String.Join('/', segments);
        return builder.Uri;
    }
}

Cała magia dzieje się w metodzie CreateUriWithVersion.

Na początku posługuję się magiczną klasą UriBuilder. Biorę część, która nazywa się Path (czyli np: /api/clients zamiast całego https://example.com/api/clients) i rozwalam ją na poszczególne elementy.

Teraz na tej liście szukam fragmentu ścieżki „api”. Jeśli nie znajdę, to znaczy że to nie jest ścieżka do wersjonowania i zwracam oryginalne Uri. Jeśli jednak znajdę, to dodaję info o wersji do tej listy. Na koniec wszystko łączę z powrotem do jednego stringa, uzyskując: /api/v1/clients.

Jak widzisz w metodzie SendAsync – swobodnie mogę sobie działać na wychodzącej wiadomości i nawet zmienić jej endpoint, na który strzela – co też czynię w tym momencie.

Na koniec wywołuję bazowe SendAsync, które wychodzi już na dobrą drogę z wersją: https://example.com/api/v1/clients.

Odświeżanie bearer tokena

To jest zdecydowanie bardziej użyteczny przypadek. Jeśli nie wiesz co to Bearer Token i jak działa uwierzytelnianie w API, koniecznie przeczytaj ten artykuł zanim pójdziesz dalej.

Generalnie, jeśli wyślesz jakieś żądanie z ustawionym Bearer Tokenem i dostaniesz odpowiedź 401 to znaczy, że token jest niepoprawny – jeśli wcześniej działał, to wygasł. W tym momencie trzeba go odświeżyć. Możemy w tym celu posłużyć się Delegating Handlerem. I wierz mi – zanim poznałem ten mechanizm, robiłem to na inne sposoby, ale DelegatingHandler jest najlepszym, najprostszym i najczystszym rozwiązaniem. A robię to w taki sposób:

public class AuthDelegatingHandler: DelegatingHandler
{
    private readonly ILogger<AuthDelegatingHandler> _logger;
    private readonly IAuthTokenProvider _tokenProvider;
    public AuthDelegatingHandler(ILogger<AuthDelegatingHandler> logger, IAuthTokenProvider tokenProvider)
    {
        _logger = logger;
        _tokenProvider = tokenProvider;
    }
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        await AssignAuthHeader(request);
        var response = await base.SendAsync(request, cancellationToken);

        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            if (!await TryRefreshingTokens(request))
                return response;
            else
            {
                await AssignAuthHeader(request);
                return await base.SendAsync(request, cancellationToken);
            }
        }
        else
            return response;
    }

    private async Task AssignAuthHeader(HttpRequestMessage request)
    {
        TokenInfo tokens = await _tokenProvider.ReadToken();
        if (tokens == null)
        {
            request.Headers.Authorization = null;
            return;
        }

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
    }

    private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
    {
        _logger.LogInformation("Unauthorized, trying reauthorization");
       
        var rtResponse = await CallRefreshEndpoint(request);
        if (!rtResponse.IsSuccessStatusCode)
            return false;
        else
        {
            await ExchangeTokens(rtResponse);
            return true;
        }
    }

    private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
    {
        using var refreshRequest = new HttpRequestMessage();

        try
        {
            var currentTokens = await _tokenProvider.ReadToken();
            refreshRequest.Method = HttpMethod.Post;
            refreshRequest.Content = JsonContent.Create(currentTokens);
            refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);

            return await base.SendAsync(refreshRequest, CancellationToken.None);
        }catch(Exception ex)
        {
            _logger.LogError(ex, "");
            throw;
        }            
    }

    private Uri GetTokenRefreshEndpoint(HttpRequestMessage request)
    {
        var baseAddress = request.RequestUri.GetLeftPart(UriPartial.Authority);
        var baseUri = new Uri(baseAddress);
        var endpointPart = "api/token/refresh";
        return new Uri(baseUri, endpointPart);
    }

    private async Task ExchangeTokens(HttpResponseMessage msg)
    {
        JsonSerializerOptions o = new();
        o.FromTradesmanDefaults();
        TokenResultDto data = await msg.Content.ReadFromJsonAsync<TokenResultDto>(o);

        TokenInfo ti = new TokenInfo
        {
            AccessToken = data.AccessToken,
            RefreshToken = data.RefreshToken
        };
        await _tokenProvider.WriteToken(ti);
    }
}

A teraz wyjaśnię Ci ten kod krok po kroku.

Przede wszystkim wstrzykuję do tej klasy interfejs IAuthTokenProvider. To jest mój własny interfejs. Zadaniem klasy, która go implementuje jest zapisanie i odczytanie informacji o tokenach – bearer token i refresh token. Miejsce i sposób zapisu zależy już ściśle od konkretnego projektu. W aplikacji desktopowej lub mobilnej może to być po prostu pamięć. W aplikacji internetowej (np. Blazor) może to być local storage. Unikałbym zapisywania takich informacji w ciastkach, ponieważ ciastka są wysyłane przez sieć z każdym żądaniem. LocalStorage to miejsce do przetrzymywania danych na lokalnym komputerze. Dane tam trzymane nigdzie nie wychodzą. Dlatego to jest dobre miejsce jeśli chodzi o aplikacje internetowe.

W każdym razie spójrz teraz na metodę SendAsync:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Dodanie nagłówka autoryzacyjnego

W pierwszej linijce dodaję nagłówek autoryzacyjny do żądania. A robi się to w ten sposób:

private async Task AssignAuthHeader(HttpRequestMessage request)
{
    TokenInfo tokens = await _tokenProvider.ReadToken();
    if (tokens == null)
    {
        request.Headers.Authorization = null;
        return;
    }

    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
}

Czyli po prostu odczytuję informacje o tokenach. Jeśli ich nie ma, to znaczy, że albo nastąpiło wylogowanie, albo tokeny jeszcze nie zostały uzyskane. W takim przypadku upewniam się, że nagłówek autoryzacyjny nie istnieje.

Jeśli jednak tokeny istnieją, to wystarczy utworzyć nagłówek autoryzacyjny.

W kolejnym kroku po prostu przesyłam żądanie z nagłówkiem autoryzacyjnym:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Odświeżenie tokena

I teraz tak, w drugiej linijce przesyłam żądanie dalej. I sprawdzam odpowiedź. I jeśli odpowiedź jest inna niż 401, zwracam tą odpowiedź i fajrant. Jeśli jednak otrzymałem zwrotkę 401 – Unauthorized, to tak jak pisałem wcześniej – prawdopodobnie bearer token wygasł i trzeba go odświeżyć. A robię to w taki sposób:

private async Task<bool> TryRefreshingTokens(HttpRequestMessage request)
{
    _logger.LogInformation("Unauthorized, trying reauthorization");
   
    var rtResponse = await CallRefreshEndpoint(request);
    if (!rtResponse.IsSuccessStatusCode)
        return false;
    else
    {
        await ExchangeTokens(rtResponse);
        return true;
    }
}

private async Task<HttpResponseMessage> CallRefreshEndpoint(HttpRequestMessage request)
{
    using var refreshRequest = new HttpRequestMessage();

    try
    {
        var currentTokens = await _tokenProvider.ReadToken();
        refreshRequest.Method = HttpMethod.Post;
        refreshRequest.Content = JsonContent.Create(currentTokens);
        refreshRequest.RequestUri = GetTokenRefreshEndpoint(request);

        return await base.SendAsync(refreshRequest, CancellationToken.None);
    }catch(Exception ex)
    {
        _logger.LogError(ex, "");
        throw;
    }            
}

Spójrz na metodę CallRefreshEndpoint, bo ona jest tutaj sercem.

Na początku tworzę zupełnie nowy RequestMessage. Ustawiam go tak, żeby strzelił na końcówkę do odświeżania tokenów i wysyłam. Tak więc, jak widzisz mogę przesłać zupełnie inny message niż ten, który dostałem.

W każdym razie dostaję zwrotkę, która mogła się zakończyć wydaniem nowych tokenów. Jeśli tak, to podmieniam je na stare. I spójrz teraz ponownie na metodę SendAsync:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    await AssignAuthHeader(request);
    var response = await base.SendAsync(request, cancellationToken);

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        if (!await TryRefreshingTokens(request))
            return response;
        else
        {
            await AssignAuthHeader(request);
            return await base.SendAsync(request, cancellationToken);
        }
    }
    else
        return response;
}

Jeśli nie udało się odświeżyć tokenów (Refresh Token też mógł wygasnąć), wtedy nie robę już niczego więcej, po prostu zwracam odpowiedź do aplikacji. Teraz aplikacja może zarządzić, co zrobić z takim klopsem. Może np. pokazać stronę do logowania.

Jeśli jednak udało się odświeżyć tokeny, to ponownie przypisuję nagłówek autoryzacyjny (z nowym bearer tokenem) i jeszcze raz przesyłam oryginalną wiadomość. W tym momencie autoryzacja powinna się już powieść.

Handlery są łańcuchem

Pamiętaj, że DelegatingHandlers działają jak łańcuch. Czyli pierwszy uruchamia drugi, drugi trzeci itd. Jeśli wywołujesz metodę base.SendAsync, to do roboty rusza kolejny handler w łańcuchu. Aż w końcu dochodzi do HttpClientHandler, który fizycznie przesyła komunikat do serwera.

Czyli nie obawiaj się rekurencji. Jeśli wywołujesz base.SendAsync, to nie dostaniesz tego żądania w tym handlerze, tylko w kolejnym.


Dzięki za przeczytanie artykułu. Mam nadzieję, że w głowie pojawiły Ci się pomysły, jak można wykorzystać delegating handlery na różne sposoby. Jeśli czegoś nie zrozumiałeś lub znalazłeś błąd w artykule, koniecznie daj znać w komentarzu 🙂

Obrazek dla artykułu: Matryoshka plik wektorowy utworzone przez macrovector – pl.freepik.com

Podziel się artykułem na:
Middleware pipeline, czyli rurociąg w .NET Core

Middleware pipeline, czyli rurociąg w .NET Core

Wstęp

Z tego tekstu dowiesz się czym jest middleware pipeline w .NET, jak go konfigurować i jak nim zarządzać. Większość blogów jakie widziałem, traktowały ten temat po macoszemu, ja postaram się go opisać dogłębnie. W końcu middleware pipeline to serce internetowych aplikacji w .NET.

Co to jest

Spójrzmy najpierw na middleware. Co to? To nic innego jak metoda (Action<HttpContext>), która w jakiś sposób przetwarza żądanie HTTP. Może je odczytywać, może zapisać coś w odpowiedzi na to żądanie, a także w jakiś sposób na nie zareagować. Więc – middleware to jest metoda, która przyjmuje w parametrze HttpContext (i dodatkowo kolejny middleware). Profesjonalnie nazywa się „oprogramowaniem pośredniczącym”, ale my będziemy mówić „komponent”. Bo w gruncie rzeczy tym właśnie jest.

To teraz czym jest pipeline? Po polsku nazywa się to „potokiem”… No i cześć… Można powiedzieć, że to taki „rurociąg” przez który przechodzi żądanie HTTP, a w rurociągu żyją sobie komponenty middleware.

Innymi słowy można powiedzieć, że to coś w rodzaju taśmy produkcyjnej.

Middleware pipeline jako taśma produkcyjna

Wyobraź sobie fabrykę, która produkuje różne surówki. W pewnym momencie dostaje żądanie wyprodukowania surówki z buraków.

Pierwsza osoba, która stoi przy taśmie produkcyjnej (komponent) przygotowuje buraki na podstawie tego żądania – obiera je i myje. Gdy wykona swoją robotę, przekazuje żądanie dalej – do kolejnej osoby.

Kolejna osoba ściera wcześniej przygotowane buraki na tarce. I żądanie przekazuje dalej. Kolejna osoba do tego wszystkiego dodaje przyprawy. Na koniec w odpowiedzi otrzymujemy smaczną surówkę z buraków.

Każda z tych osób (komponentów) przetworzyła na swój sposób żądanie i na koniec można było zwrócić odpowiedź (gotową surówkę, czy też stronę www – bez różnicy 🙂 ).

Zwróć uwagę na to, że każda z tych osób musi zadziałać w odpowiedniej kolejności. Gdybyśmy na początku postawili typa od tarcia buraków – co miałby zetrzeć, skoro jeszcze nie ma buraków? Albo gościa od przypraw przed starciem tych warzyw. Wynik byłby dziwny.

To teraz pogadajmy bardziej technicznie.

Jak działa pipeline w .NET

Komponenty w pipeline działają w określonej kolejności. W .NetCore pipeline był definiowany w metodzie Configure. Natomiast w .NET6 jest to analogicznie – po zarejestrowaniu serwisów. Zwyczajowo komponenty „wkłada się” do pipeline’a za pomocą metod Use, Map, czy Run:

//utworzenie buildera aplikacji
var builder = WebApplication.CreateBuilder(args);

//rejestrowanie serwisów
builder.Services.AddControllersWithViews();

//budowanie middleware pipeline
var app = builder.Build();

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

app.Run();

Każdy z takich komponentów może zrobić coś z żądaniem i przekazać je dalej, ale wcale nie musi. Żądanie może być zatrzymane w każdym komponencie pipeline’a. Taki komponent, który nie przekazuje żądania dalej jest nazywany „końcowym” (terminal middleware) i powinien zwrócić odpowiedź.

Spójrz teraz na ten diagram ze strony Microsoftu:

Ten schemat przedstawia sposób działania Middleware Pipeline. Na początku przychodzi żądanie, które przechodzi przez różne middleware’y w odpowiedniej kolejności. Każdy z nich może wykonać jakąś pracę. Na koniec generowana jest odpowiedź.

Kolejność komponentów

Już kilka razy mówiłem o tym, że komponenty muszą występować w odpowiedniej kolejności. Na szczęście z domyślnymi nie musisz się… domyślać. Kolejność jest ustalona przez Microsoft w oficjalnej dokumentacji:

  • obsługa wyjątków – powinna być jak najwcześniej w potoku, żeby móc obsłużyć jak największą ilość błędów (również tych, które mogą wystąpić w innych middleware’ach)
  • HSTS
  • HttpsRedirection – analogicznie do HSTS – przekierowanie na HTTPS powinno odbyć się jak najszybciej
  • StaticFiles – obsługa statycznych plików takich jak html, js, css (domyślnie wszystko z katalogu wwwroot) – umożliwia wczytanie tych plików
  • Routing – dzięki temu .NetCore wie na rzecz jakiego kontrolera/strony wywołać żądanie
  • CORS
  • Authentication
  • Authorization – uwierzytelnianie i autoryzacja muszą występować właśnie w takiej kolejności. Żeby użytkownik mógł zostać autoryzowany (czy ma konkretne uprawnienia np. do wyświetlenia danej strony) musi zostać najpierw uwierzytelniony (utworzenie obiektu ClaimsPrincipal)
  • Twoje własne komponenty middleware
  • Endpoint

Trzymaj się tej kolejności, a wszystko będzie dobrze. Rzecz jasna może zdarzyć się taka sytuacja, że Twój własny komponent będzie musiał wystąpić w innym miejscu, np. przed routingiem. Nikt Ci nie zabroni go tam umieścić.

Niemniej jednak weź pod uwagę, że ta kolejność ma kluczowe znaczenie jeśli chodzi o bezpieczeństwo, wydajność i funkcjonalność. Więc komponenty musisz dodawać świadomie.

Kolejność standardowych komponentów

Spójrz teraz na fragment kodu Microsoftu, który prezentuje typową kolejność standardowych komponentów. Możesz sobie wydrukować ten fragment i używać jako ściągi:

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage(); //obsługa wyjątków w środowisku developerskim
    app.UseDatabaseErrorPage();
}
else
{
    app.UseExceptionHandler("/Error"); //obsługa wyjątków w środowisku produkcyjnym
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy(); 
app.UseRouting();
app.UseRequestLocalization(); 

app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();
app.UseResponseCompression();
app.UseResponseCaching();

app.MapRazorPages();

Dla niektórych scenariuszy, możliwa jest zmiana pewnych kolejności. Np. Caching może być przed Compression. Ale UseCors, UseAuthentication i UseAuthorization muszą być dokładnie w takiej kolejności. Co więcej, UseCors (jeśli używane) musi być przed UseResponseCaching.

Forwarded headers

Jeśli używasz middleware’u do forwardowania nagłówków, koniecznie umieść go na pierwszym miejscu – możesz nawet przed obsługą wyjątków:

app.UseForwardedHeaders();
//app.UseCertificateForwarding();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage(); //obsługa wyjątków w środowisku developerskim
    app.UseDatabaseErrorPage();
}

//i tak dalej

Rozgałęzianie pipeline

Jeśli chcesz, możesz na podstawie warunków rozgałęzić pipeline, dzięki czemu dla pewnych warunków zostanie wykonana inna ścieżka. Można to zrobić na kilka sposobów:

Mapowanie ścieżki

Używając metody Map, możesz rozgałęzić swój pipeline na podstawie ścieżki, która się wykonuje.

Spójrz na ten kod:

var app = builder.Build();
//konfiguracja standardowego pipeline, a potem

app.Map("/daj-mi-google", GetGoogle);

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

app.Run();

static void GetGoogle(IApplicationBuilder app)
{
    app.Run(async (context) =>
    {
        await Task.Run(() => context.Response.Redirect("https://www.google.pl")); //to jest końcowy middleware
    });
}

MapControllerRoute mapuje standardowe ścieżki dla kontrolerów (MVC). Przed nim rozgałęziłem ścieżkę. Teraz, jeśli wywołasz adres: https://localhost/daj-mi-google, to właśnie ta alternatywna ścieżka zostanie uruchomiona. I w efekcie zostaniesz przekierowany na stronę Google. Standardowa ścieżka zostanie pominięta:

Przykład mapowania – pominięcie standardowej ścieżki
Przykład mapowania – standardowa ścieżka

Takich map możesz zrobić ile tylko chcesz. Możesz tworzyć też dłuższe ścieżki, np:

app.Map("/redirect/google", GetGoogle);
app.Map("/redirect/youtube", GetYouTube);

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

app.Run();

static void GetGoogle(IApplicationBuilder app)
{
    app.Run(async (context) =>
    {
        await Task.Run(() => context.Response.Redirect("https://www.google.pl")); //to jest końcowy middleware
    });
}

static void GetYouTube(IApplicationBuilder app)
{
    app.Run(async (context) =>
    {
        await Task.Run(() => context.Response.Redirect("https://www.youtube.com"));  //to jest końcowy middleware
    });
}

Zagnieżdżone mapowanie ścieżki

Jeśli spojrzysz na przykład powyżej, można go napisać też w inny sposób – zagnieżdżając fragmenty ścieżki:

app.Map("/redirect", redirectApp =>
{
    redirectApp.Map("/google", GetGoogle);
    redirectApp.Map("/youtube", GetYouTube);
});

Mapowanie warunkowe

Używając metody MapWhen, możesz rozgałęzić ścieżkę na podstawie HttpContext. Załóżmy, że chcesz mieć inny pipeline dla przeglądarki Internet Explorer. Gdzie masz informacje o przeglądarce? W nagłówkach żądania:

app.MapWhen((context) =>
{
    string userAgentName = context.Request.Headers["User-Agent"].ToString();
    return userAgentName.Contains("Explorer");
}, HandleIEBrowser);

static void HandleIEBrowser(IApplicationBuilder app)
{
    app.Run(async (context) =>
    {
        await context.Response.WriteAsync("Nie obsługujemy tej przeglądarki"); // to jest końcowy middleware
    });
}

Metoda MapWhen przyjmuje w parametrze obiekt Func<HttpContext, bool>. Jeśli taki delegat zwróci true, wtedy ruszy akcja z drugiego parametru. W tym przypadku po prostu sprawdzamy nazwę przeglądarki z nagłówka żądania. Ale to może być jakikolwiek warunek. Może to być sprawdzenie daty (np. gdy dzisiaj jest luty, to przekieruj na stronę z promocją -> albo dodaj middleware, który zarządza promocją)

Umożliwia Ci to również warunkowanie na podstawie query stringa (zapytań w adresie, np: https://localhost?akcja=logowanie). Generalnie wszystkiego, co możesz wyciągnąć z HttpContext.

Odpowiedź na żądanie

Wszystkie rozgałęzienia używające Map, kierują na końcowy middleware. Tak, to co zrobiliśmy wyżej, to pewna forma własnego middleware’u. O tym będzie więcej w innym artykule. Na razie wiedz, że w taki prosty sposób można napisać bardzo prosty middleware.

Nasze końcowe middlewar’y zawsze zwracały jakąś odpowiedź – albo przekierowanie, albo tekst. I nie wywoływały kolejnych. To znaczy, że rozgałęzienie używające Map lub MapWhen, rozgałęzia Middleware na dobre:

Map tworzy po prostu zupełnie oddzielną drogę. Każda z nich na końcu musi zwrócić jakąś odpowiedź. Każda z nich jest niezależna.

OK, a co jeśli chcielibyśmy tylko na chwilę rozdzielić ścieżkę? Do tego służy UseWhen.

Użyj i wróć, czyli UseWhen

UseWhen działa podobnie do MapWhen, z tą różnicą, że wraca do głównego pipeline:

UseWhen sprawdza warunek. Jeśli warunek się nie zgadza, idzie standardową drogą – pipeline 1. Jeśli jednak warunek jest prawdziwy, idzie alternatywną drogą – pipeline 2, następnie wraca do pipeline 1 (chyba że w pipeline 2 znajdzie się middleware końcowy).

Przykład – rabat na luty

Spróbuj zrobić teraz taki przykład za pomocą UseWhen i MapWhen. MapWhen zakończy się wyjątkiem.

Scenariusz jest taki – w lutym sklep ma super promocję. I w lutym wszystkie ceny są podmieniane. Na początek stwórzmy sobie prostą klasę przechowującą ceny:

public class PriceProvider
{
    public bool IsPromoMode { get; set; } = false;
    public decimal CurrentPrice { get { return IsPromoMode ? promoPrice : normalPrice; } }

    decimal promoPrice;
    decimal normalPrice;

    public PriceProvider()
    {
        promoPrice = 10.0m;
        normalPrice = 15.0m;
    }
}

Mamy tutaj cenę promocyjną, normalną i aktualną. Mamy również jakąś flagę, która określa, czy jest promocja.

Teraz zarejestrujmy tę klasę w dependency injection (jeśli nie wiesz co to, koniecznie przeczytaj ten artykuł) jako scoped:

builder.Services.AddScoped<PriceProvider>();

A teraz napiszmy prosty middleware do promocji. UWAGA! Poniżej pokazuję „przypadkiem” jak tworzyć własny middleware, ale o tym będzie osobny artykuł:

public class PromoMiddleware
{
    private readonly RequestDelegate next;

    public PromoMiddleware(RequestDelegate next)
    {
        this.next = next;
    }

    public async Task Invoke(HttpContext ctx, PriceProvider prov)
    {
        prov.IsPromoMode = true;
        await next(ctx);
    }
}

Tutaj w metodzie Invoke wstrzykiwany jest serwis PriceProvider, flaga IsPromoMode jest ustawiana na true, a na koniec jest wywoływany kolejny middleware z pipeline (next).

A teraz zarejestrujemy warunkowo ten middleware:

app.UseWhen((context) =>
{
    return DateTimeOffset.Now.Month == 2;
}, (app) => app.UseMiddleware<PromoMiddleware>());

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

app.Run();

Rozgałęzienie następuje na podstawie aktualnego miesiąca. Jeśli jest luty, to wtedy tworzymy rozgałęzienie (dodajemy do pipeline PromoMiddleware) i wracamy do głównego pipeline’a. Dzięki temu nie mamy dwóch całkowicie niezależnych ścieżek, ale warunkowo możemy zarejestrować middleware gdzieś w środku.

Teraz tylko wstrzyknij do swojej strony/widoku Index.cshtml PriceProvider:

@using WebApplication.Services
@inject PriceProvider priceProvider

@if(priceProvider.IsPromoMode)
{
    <h1>PROMOCJA!</h1>
}

<b>Aktualna cena:</b> @priceProvider.CurrentPrice

Jeśli teraz uruchomisz tę aplikację i jest luty, zobaczysz:

A teraz zmień warunek w UseWhen tak, żeby zwrócił false. Zobaczysz normalną stronę bez promocji:

W ramach ćwiczeń zrób to samo, używając MapWhen. Zobaczysz wywałkę przy warunku z dostępną promocją, ponieważ w swoim rozgałęzieniu nie masz kończącego middleware, który zwraca odpowiedź.

Podsumowanie

To w zasadzie tyle jeśli chodzi o konfigurację middleware pipeline. Możesz mieć tylko jeden pipeline, ale możesz też go rozgałęzić za pomocą Map/MapWhen – tworząc dwie niezależne ścieżki. A możesz też go rozgałęzić za pomocą UseWhen – dodając warunkowo middlewary w środek pipeline lub wykonując jakąś inną pracę.

Domyślne middlewar’y

Na koniec pokażę Ci jeszcze listę domyślnych middleware’wó ze strony Microsoftu. Opis znajdziesz tutaj: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0#built-in-middleware


Jeśli czegoś nie rozumiesz, znalazłeś błąd w artykule lub uważasz go za użyteczny, daj znać w komentarzu 🙂

Podziel się artykułem na:
Tłumaczenie aplikacji internetowych – RAZOR

Tłumaczenie aplikacji internetowych – RAZOR

Wstęp

To jest kolejny artykuł z serii o globalizacji i lokalizacji. Jeśli nie czytałeś poprzednich, koniecznie to nadrób. W tym artykule opisuję TYLKO jak ładować tłumaczenia w aplikacjach internetowych tworzonych w RAZOR. Poprzedni artykuł – Tłumaczenie aplikacji cz. 3 – jak to ogarnąć? – daje całą podstawę.

W aplikacjach internetowych możemy uwzględniać język na kilka sposobów:

  • informacji wysyłanej z przeglądarki (nagłówek żądania)
  • parametru w zapytaniu (np. https://example.com?lang=en)
  • ciasteczka
  • fragmentu URL (np. https://example.com/en-US/)

Popatrzymy na te wszystkie możliwości.

Żeby w ogóle cała machina ruszyła, trzeba skonfigurować lokalizację… To naprawdę proste, wystarczy zrozumieć 🙂

Czym jest middleware pipeline?

Jeśli wiesz, czym jest middleware pipeline w .NetCore, możesz przejść dalej. Jeśli nie wiesz – też możesz, ale dalsza część artykułu będzie trochę niejasna.

Napiszę bardzo ogólnie jak to działa, dużo lepiej jest to opisane w artykule o middleware pipeline 🙂

Pipeline (czyli potok) to seria akcji wykonywanych jedna po drugiej podczas odbierania żądania od klienta i wysyłania odpowiedzi. W metodzie Configure ustawiasz właśnie te komponenty w pipelinie za pomocą metod, których nazwy rozpoczynają się zwyczajowo od Use. Np. UseAuthentication, UseAuthorization itd. Spójrz na przykładowe kody:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
}

Dla takiego kodu pipeline będzie prawie pusty:

Dodajmy teraz przekierowanie https:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   app.UseHttpsRedirection();
   app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");
}

Teraz pipeline będzie wyglądał tak:

Żądanie przejdzie najpierw przez HttpsRedirection, który może sobie na nim pracować i może przekazać wywołanie do kolejnego middleware (ale wcale nie musi). Żądanie może następnie trafić do RouterMiddleware, który wie, jaką stronę ma pokazać. Następnie generowana jest odpowiedź, która przechodzi przez middleware’y w odwrotnej kolejności (w tym momencie nie można już zmodyfikować nagłówków).

Widzisz zatem, że kolejność dodawania middleware’ów do pipeline jest istotna, a nawet dokładnie opisana na stronie Microsoftu: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-6.0#middleware-order . No bo co się stanie, jeśli dodasz autoryzację za routerem? Autoryzacja zadziała dopiero za routerem, a więc użytkownik zobaczy już stronę, której nie powinien.

Konfiguracja języków

Najpierw trzeba skonfigurować języki w aplikacji RAZOR. Przede wszystkim zajrzyj do pliku Startup.cs i tam odnajdź metodę ConfigureServices. (jeśli używasz .NET6, możesz nie widzieć Startup.cs, wszystko dzieje się w pliku Program.cs)

Teraz musisz w niej skonfigurować serwis odpowiedzialny za lokalizację. Są takie metody (extensions) w IServiceCollection jak AddControllers*, AddMVC*, czy też AddRazorPages. Każda z nich zwraca obiekt implementujący IMvcBuilder. Z kolei ten, ma w sobie rejestrację lokalizacji (AddViewLocalization()), a więc np:

using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;

//...
public void ConfigureServices(IServiceCollection services)
{
  services.AddControllersWithViews()
      .AddViewLocalization();
}

Najprostszą konfigurację lokalizacji robimy w metodzie Configure – PRZED mapowaniem ścieżek. A więc dodajemy to do pipeline. Wygląda to tak:

IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
    new CultureInfo("en-US"),
    new CultureInfo("pl"),
};

var localizationOptions = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en-US"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures        
};

app.UseRequestLocalization(localizationOptions);

Teraz przyda się kilka słów wyjaśnienia.

Najpierw trzeba użyć oprogramowania pośredniczącego (middleware) do lokalizacji. Robimy to przez włączenie do pipeline UseRequestLocalization. Można to zrobić na kilka sposobów:

  • app.UseRequestLocalization() – bez parametrów – odczyta lokalizację z nagłówka żądania, który wysyłany jest przez przeglądarkę. I tyle. Niczego tu nie można zmienić.
  • app.UseRequestLocalization(RequestLocalizationOptions) – od razu skonfiguruje middleware RequestLocalization zgodnie z przekazanymi opcjami
  • app.UseRequestLocalization(Action) – podobnie jak wyżej, tyle że przekazujemy tutaj akcję, w której konfigurujemy middleware.

W naszym przykładzie włączamy RequestLocalization do pipeline (pamiętaj, że ZANIM zmapujemy ścieżki), przekazując opcje.

Wróćmy do kodu:

IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
    new CultureInfo("en-US"),
    new CultureInfo("pl"),
};

var localizationOptions = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en-US"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures        
};

app.UseRequestLocalization(localizationOptions);

Najpierw tworzona jest lista kultur, które wspieramy, a w drugim kroku ustawiamy opcje lokalizacji:

  • przekazujemy domyślną kulturę (DefaultRequestCulture)
  • przypisujemy wspierane kultury UI i zwykłe

Taka konfiguracja daje nam dostęp do:

  • odczytu lokalizacji z przeglądarki (z nagłówka żądania)
  • odczytu lokalizacji z parametrów zapytania (?culture=pl-PL)
  • odczytu lokalizacji z ciasteczka

Czyli konfigurując w taki sposób (z przekazaniem RequestLocalizationOptions) mamy dużo więcej niż po prostu włączając middleware do pipeline bez jego konfiguracji.

To teraz pytanie, skąd system wie, w jaki sposób ma pobrać dane o aktualnej kulturze? Czary? Nie! Z pomocą przychodzi…

RequestCultureProvider

To jest klasa abstrakcyjna, której zadaniem jest zwrócić informacje o kulturze na podstawie danych z żądania. Kilka domyślnych providerów jest już utworzonych i właściwie nie potrzeba więcej, chociaż możesz stworzyć własne (np. odczyt kultury z bazy danych).

W klasie RequestLocalizationOptions (opcje lokalizacyjne) poza obsługiwanymi kulturami znajduje się też lista RequestCultureProvider. Domyślnie utworzone są takie:

QueryStringRequestCultureProvider

zwraca kulturę z zapytania w adresie, np: https://example.com/Home/Index?culture=en-US; świetnie nadaje się to do debugowania. Domyślnie operuje na dwóch kluczach: culture i ui-culture. Wystarczy, że w zapytaniu będzie jeden z nich, drugi otrzyma taką samą wartość. Jeśli są oba, np: ?culture=en-US&ui-culture=en-GB, wtedy inne będą ustawienia dla CurrentCulture i CurrentUICulture.

Oczywiście klucze możesz sobie zmieniać za pomocą właściwości

  • QueryStringKey (domyślnie „culture”)
  • UIQueryStringKey (domyślnie „ui-culture”)

Także zamiast ?culture=en-US będziesz mógł podać np. ?lang=en

CookieRequestCultureProvider

zwraca kulturę z ciasteczka. Sam możesz zdecydować o tym, jak ma nazywać się dane ciasteczko (za pomocą właściwości CookieName). Domyślnie to: „.AspNetCore.Culture”.

Żeby to zadziałało, oczywiście jakieś ciasteczko musi zostać wcześniej zapisane. Ta klasa ma dwie przydatne metody statyczne: ParseCookieValue i MakeCookieValue. MakeCookieValue zwróci Ci dokładną zawartość ciasteczka, jakie musisz zapisać.

AcceptLanguageHeaderRequestCultureProvider

zwraca kulturę zapisaną w przeglądarce (a właściwie wysłaną przez przeglądarkę w nagłówkach).

Kolejność tych providerów jest istotna. Jeśli pierwszy nie zwróci danych, drugi spróbuje. Jeśli w przeglądarce masz zapisaną kulturę pl-PL, ale w zapytaniu w adresie strony wpiszesz ?culture=en-US, zobaczysz stronę po angielsku, ponieważ pierwszy w kolejności jest QueryStringRequestCultureProvider.

Oczywiście manipulując tą listą możesz zmienić kolejność providerów, usuwać ich i dodawać nowych.

Pobieranie języka z adresu

Pewnie nie raz widziałeś (chociażby na stronach Microsoftu) taki sposób przekazywania kultury: https://example.com/en-US/Home/Index

gdzie informacje o niej są zawarte w adresie (w URL). Tutaj też tak można, a z pomocą przychodzi RouteDataRequestCultureProvider. Ten provider nie jest domyślnie tworzony, więc trzeba stworzyć obiekt tej klasy samemu i dodać go do RequestLocalizationOptions na pierwszym miejscu:

IList<CultureInfo> supportedCultures = new List<CultureInfo>
{
    new CultureInfo("en-US"),
    new CultureInfo("pl"),
};

var localizationOptions = new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en-US"),
    SupportedCultures = supportedCultures,
    SupportedUICultures = supportedCultures        
};

var requestProvider = new RouteDataRequestCultureProvider();
localizationOptions.RequestCultureProviders.Insert(0, requestProvider);

app.UseRequestLocalization(localizationOptions);

Żeby to zadziałało, trzeba jeszcze poinformować router, że w ścieżce są informacje o kulturze:

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

Tutaj analogicznie jak przy QueryStringRequestCultureProvider możesz zmienić właściwościami klucze culture i uiculture. Oczywiście musisz pamiętać wtedy o zmianie template’a ścieżki.

Tą metodę wywołaj w metodzie Configure, która jest odpowiedzialna za konfigurację zarejestrowanych serwisów – zrób to przed konfiguracją endpointów.

Pobieranie tłumaczenia na widoku

Teraz już możesz pobierać tłumaczenia. Wystarczy, że dodasz do usingów w widokach: Microsoft.AspNetCore.Mvc.Localization i wstrzykniesz interfejs IStringLocalizer:

@using Microsoft.AspNetCore.Mvc.Localization
@inject IStringLocalizer<LangRes> SharedLocalizer
@inject IStringLocalizer<WebLangRes> WebLocalizer

<h>@WebLocalizer[nameof(WebLangRes.HelloMsg)]</h>

Jak widzisz, możesz wstrzyknąć do jednego widoku kilka takich „lokalizerów”. W zmiennej generycznej określasz tylko klasę z Twoimi zasobami (czyli to, co robiliśmy w tym artykule). Ja tutaj mam dwa takie zasoby – jeden główny w jakimś projekcie współdzielonym (LangRes) i drugi tylko w projekcie MVC (WebLangRes), w którym są teksty bardzo ściśle związane z serwisem www.

Przy takim prostym wywołaniu jak wyżej (tekst w tagu HTML) nic więcej nie trzeba robić. Natomiast jeśli chcesz przekazać tłumaczenie do tag helpera, musisz dołożyć po prostu właściwość Value, np.:

<p>@SharedLocalizer[nameof(LangRes.Contact)]
  <mail prompt="@WebLocalizer[nameof(WebLangRes.MailPrompt)].Value" />
</p>

IHtmlLocalizer

Mamy do dyspozycji jeszcze coś takiego jak IHtmlLocalizer. Działa prawie tak samo jak IStringLocalizer, z tą różnicą, że możesz mu przekazać zasoby z tagami html, np: <b>Hello!</b>. Jednak nie używam go, bo trochę mi śmierdzi wpisywanie kodu html do zasobów.

To tyle. Jeśli czegoś nie zrozumiałeś lub znalazłeś w tekście błąd, daj znać w komentarzu.

Jeśli uważasz ten artykuł za przydatny, udostępnij go.

Podziel się artykułem na: