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: