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:
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 🙂