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:
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:
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:
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).
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:
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ę.
Ten artykuł opisuje czym jest dependency injection. A także jak z tego korzystać w .NET i po co. Jeśli wiesz, znasz, stosujesz, to raczej niczego nowego się tutaj nie dowiesz 🙂 On jest kierowany głównie do młodych programistów lub programistów nie znających tych mechanizmów.
O co chodzi we wstrzykiwaniu zależności?
Przede wszystkim musimy zdefiniować sobie zależność. O zależności mówimy wtedy, kiedy jedna klasa zależy od drugiej. Weźmy sobie przykładową klasę Writer, która umie wypisywać komunikaty i klasę Worker, która wykonuje jakąś operację i posługuje się klasą Writer.
class Writer
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
class Worker
{
Writer writer = new Writer();
public void Foo()
{
writer.Write("Rozpoczynam pracę...");
}
}
Jak widzisz, klasa Worker zależy od klasy Writer.
Co więcej, klasa Worker samodzielnie tworzy i używa obiekt klasy Writer. Takie coś nazywamy silnym związaniem (tight coupling). I chociaż tight coupling to pojęcie szersze, to jednak dobrze jest prezentowany przez ten przykład. Dwie klasy są mocno ze sobą związane.
Takie zakodowane na sztywno zależności (silne związania) są złe dla aplikacji i powinieneś ich unikać. Dlaczego?
jeśli chciałbyś aby komunikaty były wpisywane do pliku, a nie na konsolę, musiałbyś zmienić klasę Writer lub utworzyć nową i zmienić klasę Worker (sprzeczność z zasadą OpenClose).
jeśli klasa Writer miałaby inne zależności, te zależności musiałby także zostać utworzone (lub w jakiś sposób przekazane) przez klasę Worker. Lub utworzone w klasie Writer, co jeszcze bardziej zacieśnia kod. Ponadto daje nam już zbyt dużo odpowiedzialności (możliwa sprzeczność z zasadą Single Responsibility) i zdecydowanie zaciemnia obraz.
Te wszystkie problemy można rozwiązać stosując wstrzykiwanie zależności…
Siostro! Zastrzyk!
OK, teraz wyobraźmy sobie jak lepiej mogłaby wyglądać klasa Worker:
class Worker
{
Writer writer;
public Worker(Writer writer)
{
this.writer = writer;
}
public void Foo()
{
writer.Write("Rozpoczynam pracę...");
}
}
Spójrz, co się stało. Wstrzyknęliśmy obiekt klasy Writer do Worker za pomocą konstruktora. Obiekt Writer w tym momencie jest już poprawnie stworzony (ma utworzone swoje wszystkie zależności) i można go używać. Klasa Worker nie musi tworzyć już tego obiektu i nie daj Boże innych jego zależności.
Po prostu Worker używa Writer. A skąd go ma? To w zasadzie nie jest istotne. Czy Ciebie interesuje kto zrobił Ci przelew na konto? Czy ważne, że masz te pieniądze? 😉
Jednak cały czas nie rozwiązaliśmy jednego problemu. Silnego związania. Klasa Worker cały czas jest silnie związana z Writer. A gdyby tak posłużyć się interfejsem?
interface IWriter
{
void Write(string message);
}
class Writer: IWriter
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
public void Foo()
{
writer.Write("Rozpoczynam pracę...");
}
}
Na początku zdefiniowaliśmy sobie interfejs IWriter – z jedną metodą. Potem utworzyliśmy klasę Writer implementującą ten interfejs i na koniec do klasy Worker wstrzyknęliśmy interfejs.
W .NET można wstrzykiwać zależności przez konstruktor (najczęściej używane), właściwość (częściej używane w Blazor, gdzie to jest jedyna możliwość w komponencie będącym widokiem), a nawet przez parametr (stosowane raczej w kontrolerze webowej aplikacji)
To nam rozwiązuje ostatni problem. Dlaczego? Bo możemy sobie teraz rozszerzyć naszą aplikację, pisząc nieco inną implementację klasy Writer:
class FileWriter : IWriter
{
public void Write(string message)
{
File.AppendText(message);
}
}
Teraz klasa Worker może dostać obiekt Writer lub FileWriter. Nie ma już silnego związania z klasą Writer. Otrzymaliśmy luźne powiązanie (loose coupling). Daje to też możliwość napisania oszukanej klasy (Fake), którą można wykorzystać później w testach automatycznych:
class FakeWriter : IWriter
{
public void Write(string message)
{
//żadnego ciała albo Debug.WriteLine
}
}
Powyższy przykład pokazuje również wzorzec projektowy „Strategia”. Wzorzec ten jest poniekąd jednym z przykładów wstrzykiwania zależności.
Kontenery IoC
Zostaje jeszcze pytanie, jak tworzyć obiekty jak np. Writer? NAJPROSTSZYM przykładem Dependency Injection jest po prostu:
Worker worker = new Worker(new Writer());
To jest NAJPROSTSZY przykład, najbardziej banalny i całkowicie bezużyteczny w prawdziwym świecie (chociaż czasem nie da się inaczej). Co więcej, powoduje dużo problemów. Załóżmy, że masz taki kod rozsiany po całej aplikacji i nagle konstruktor klasy Writer potrzebuje jeszcze jednego obiektu… Musisz to zmieniać w wielu miejscach. Zupełna strata czasu.
Na szczęście powstało coś takiego jak kontenery DI.
Czym jest kontener DI
To specjalny kontener (pomyśl o tym jak o klasie Dictionary<Type, object> na mocnych sterydach), który konfigurujesz podczas inicjalizowania aplikacji. Np. w metodzie Main. Możesz spotkać się też z określeniem „kontener IoC” – IoC to „Inversion of Control” – wzorzec projektowy, którego jedną z implementacji jest wstrzykiwanie zależności.
Konfigurując taki kontener, rejestrujesz w nim klasy, interfejsy, długości życia, a także sposoby w jakie konkretne obiekty mają zostać tworzone. Kontenery dają też możliwość rejestrowania własnych metod do tworzenia obiektów. Na końcu to właśnie kontener tworzy dla Ciebie w pełni działający obiekt.
W C# mamy do dyspozycji różne kontenery IoC. Najbardziej znane to chyba Autofac i Microsoft.Extensions.DependencyInjection. Autofac był wcześniej, natomiast w .NetCore przyszły mechanizmy z Microsoftu. Jako, że Autofac jest dużo starszy, PRAWDOPODOBNIE ma więcej możliwości, ale Microsoftowy odpowiednik jest wystarczający. Moim zdaniem jest też prostszy w użyciu i dlatego to nim się zajmiemy.
Długość życia serwisu
W związku z tym, że mechanizm DI musi widzieć kiedy tworzyć i zwalniać obiekty, podczas konfiguracji podajemy długość życia. Czyli mówimy kontenerowi jak długo obiekt powinien żyć, czy też kiedy go tworzyć. To może wyglądać strasznie, ale w rzeczywistości jest bardzo proste.
Niezależnie od tego, czy używasz Autofaca, Microsoft Dependency Injection, czy jeszcze innego mechanizmu, długości życia będą analogiczne:
Transient
Obiekt zarejestrowany jako transient będzie tworzony za każdym razem, gdy będzie potrzebny. To znaczy, że każda klasa, do której wstrzykujesz obiekt transient, będzie miała własną niepowtarzalną instancję, np:
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
}
class Manager
{
IWriter writer;
public Manager(IWriter writer)
{
this.writer = writer;
}
}
Jeśli klasa Writer zostanie zarejestrowana jako transient, to instancje Writera w Worker i Manager zawsze będą różne. Po prostu klasa Writer zostanie utworzona na nowo przy każdym takim wstrzyknięciu.
Można by to przyrównać do tego kodu:
Writer w1 = new Writer();
Worker worker = new Worker(w1);
Writer w2 = new Writer();
Manager manager = new Manager(w2);
Scoped
W przypadku aplikacji desktopowych i mobilnych nie różni się to od singleton niczym (chyba że sam tworzysz scope). W przypadku aplikacji webowych, powstanie tylko jedna instancja takiego obiektu na żądanie http. Tzn.:
class Worker
{
IWriter writer;
public Worker(IWriter writer)
{
this.writer = writer;
}
}
class Manager
{
IWriter writer;
public Manager(IWriter writer)
{
this.writer = writer;
}
}
Jeśli teraz Worker zostanie zarejestrowany jako scoped i jesteśmy w obrębie jednego żądania HTTP, wtedy w Worker i Manager będziemy mieli tę samą instancję klasy Writer. Po prostu obiekt Writer zostanie utworzony raz i wstrzyknięty do wszystkich innych obiektów w ramach jednego żądania. Obiekt umiera, gdy żądanie się kończy i nie jest już dłużej używany.
Można by to zademonstrować takim kodem:
Response ManageRequest()
{
Writer scopedWriter = new Writer();
Worker worker = new Worker(scopedWriter);
Manager manager = new Manager(scopedWriter);
//tutaj praca na żądaniu..., a na koniec
worker = null;
manager = null;
scopedWriter = null;
}
Singleton
Zostanie utworzona TYLKO JEDNA instancja takiej klasy w całej aplikacji. I ta jedna instancja będzie przekazywana innym obiektom przez cały okres działania aplikacji. Obiekt umiera wraz z aplikacją.
Można to porównać do takiego kodu:
if (mainWriter == null)
mainWriter = new Writer();
Worker worker = new Worker(mainWriter);
Manager manager = new Manager(mainWriter);
DI od Microsoftu
Jeśli tworzysz aplikację webową, to masz już to w standardzie. Natomiast aplikacja konsolowa, WPF, czy WinForms wymaga, żebyś zainstalował paczkę NuGet: Microsoft.Extensions.DependencyInjection
Następnie musisz dodać do usings:
using Microsoft.Extensions.DependencyInjection;
Kontenery
Zwróć teraz uwagę na dwie klasy: ServiceCollection i ServiceProvider. W klasie ServiceCollection dodajemy wszystkie nasze serwisy – konfigurujemy kontener DI. Klasa ServiceProvider tworzy i zwraca nam konkretny obiekt, który potrzebujemy w danej chwili.
Weźmy teraz prostą aplikację konsolową:
class MainClass
{
static void Main()
{
Console.Write("Podaj imię: ");
string name = Console.ReadLine();
Console.WriteLine($"Cześć {name}!");
Console.ReadKey();
}
}
Spróbujmy ją przerobić tak, żeby używała Dependency Injection. Standardowe podejście jest takie:
w metodzie main konfigurujemy dependency injection
pobieramy obiekt jakiejś głównej klasy (np. w WPF/WinForms byłoby to najpewniej MainForm, czy też MainWindow)
wywołujemy metodę z tej klasy
Ponieważ pracujemy na konsoli, musimy stworzyć główną klasę aplikacji. Klasa Main w tym wypadku służy tylko do konfiguracji:
class App
{
public void Run()
{
}
}
W metodzie Main utworzymy instancję klasy App, następnie wywołamy metodę Run, która zrobi dokładnie to samo, co Main po staremu. Moglibyśmy tutaj użyć klasy Console, ale żeby pobawić się dependency injection, zrobimy to inaczej.
Używaj abstrakcji
Zamiast posługiwać się bezpośrednio klasą Console, utworzymy interfejs i zaimplementujemy go:
interface IUserInteraction
{
void Print(string message);
string Read();
}
class ConsoleInteraction : IUserInteraction
{
public void Print(string message)
{
Console.Write(message);
}
public string Read()
{
return Console.ReadLine();
}
}
Konfiguracja
Teraz w metodzie Main skonfigurujemy nasze DI. Aby to zrobić, najpierw trzeba utworzyć obiekt klasy ServiceCollection (pamiętaj, że w aplikacjach webowych masz już to dostępne):
class MainClass
{
static void Main()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddTransient<IUserInteraction, ConsoleInteraction>();
serviceCollection.AddSingleton<App>();
using(var serviceProvider = serviceCollection.BuildServiceProvider())
{
var app = serviceProvider.GetService<App>();
app.Run();
}
}
}
W linijce 5 tworzymy ServiceCollection, a następnie rejestrujemy dwa serwisy:
ConsoleInteraction jako interfejs IUserInteraction – pamiętaj, że w Microsoft DI najpierw podajesz interfejs, a później klasę, która ten interfejs implementuje (jeśli zrobisz na odwrót, aplikacja się nie skompiluje). Teraz mechanizm DI wszędzie tam, gdzie zobaczy interfejs IUserInteraction, utworzy obiekt klasy ConsoleInteraction.
Klasę App jako singleton. Jak widzisz – nie jest wymagany interfejs żeby zarejestrować klasę. W przypadku klasy App interfejs nie ma większego sensu, bo to główna klasa aplikacji. Ale często interfejs ma sens (głównie tam, gdzie jest zależnością dla innej klasy), więc pamiętaj o tym.
Jeśli nie wiesz czym jest singleton to po prostu obiekt który jest utworzony raz w całej aplikacji i żyje przez cały cykl jej życia. Możesz się też spotkać z określeniem, że singleton jest antywzorcem, ale tu chodzi o inną sytuację – ręczne klasyczne tworzenie singletona. W naszym przypadku rejestrujemy klasę jako singleton w kontenerze DI i wszystko jest w porządku.
Teraz tak. Dlaczego klasę ConsoleInteraction zarejestrowaliśmy jako transient? W przypadku tej aplikacji nie ma to żadnego znaczenia, bo i tak użyjemy jej tylko w jednym miejscu. Chciałem po prostu pokazać, że tak to się robi. Zazwyczaj klasy logujące będziesz rejestrował jako singletony.
A dlaczego klasa App jest jako singleton? No przypomnij sobie czym jest singleton – jedna instancja klasy, która żyje przez cały czas życia aplikacji. Czyli idealny zakres dla klasy, która reprezentuje całą aplikację.
Pobieranie serwisów
W linijce 9 tworzymy ServiceProvider ze skonfigurowanego ServiceCollection. Od tego momentu ServiceProvider będzie dostarczał nam obiekty, których potrzebujemy. Nie można już niczego zmienić w service collection (oczywiście nikt Ci nie broni, żeby mieć kilka ServiceCollection i Providerów, ale jakoś nie widzę w tym sensu).
Na końcu używamy ServiceProvider, żeby otrzymać obiekt klasy App. Dosłownie –
– Ej Ty! ServiceProvider, dej mnie no w pełni działający obiekt klasy App!
To może nie jest niczym wyglądającym super. Po prostu nie musiałeś tworzyć obiektu przez new, tylko za pomocą ServiceProvidera. Ale pamiętasz IUserInteraction? Teraz dodajmy go do klasy App:
class App
{
readonly IUserInteraction ui;
public App(IUserInteraction ui)
{
this.ui = ui;
}
public void Run()
{
ui.Print("Podaj imię: ");
string name = ui.Read();
ui.Print($"Cześć {name}!");
Console.ReadKey();
}
}
W metodzie Main – już nic więcej nie musisz robić. Otrzymasz znowu w pełni działający obiekt klasy App! Klasa implementująca IUserInteraction zostanie automagicznie utworzona i wstrzyknięta do App.
ServiceProvider i zwalnianie zasobów
Pewnie chodzi Ci po głowie pytanie – co z klasami IDisposable? I czy są one zwalniane?
Zasadniczo tak. Spójrz, jak został utworzony ServiceProvider:
using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
var app = serviceProvider.GetRequiredService<App>();
app.Run();
}
Teraz wszystkie obiekty zostaną zwolnione po zwolnieniu ServiceProvidera. Natomiast ServiceProvider może też utworzyć tzw. zakres:
using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
using (var scope = serviceProvider.CreateScope())
{
var app = scope.ServiceProvider.GetRequiredService<App>();
app.Run();
}
}
W aplikacji możemy mieć wiele takich zakresów. Np. w aplikacji webowej taki zakres jest tworzony na całe żądanie HTTP. Obiekty zarejestrowanie jako scope i transient zostaną usunięcie po wyjściu z takiego zakresu, chyba że są zależnościami dla innych obiektów. Jeśli implementują interfejs IDisposable lub IAsyncDisposable, odpowiednie metody Dispose też zostaną automatycznie wywołane. Singletony zostaną zwolnione po zakończeniu życia ServiceProvidera.
Ale uwaga! Jeśli zarejestrowałeś singleton, który jest zależny od innych klas i np. taką zależność zarejestrowałeś jako transient (lub scoped), to zwróć uwagę na to, że te zależności zostaną zwolnione dopiero po śmierci singletona. Przecież singleton na nich polega, dlatego też musi mieć dostęp do nich przez cały czas. W niektórych bibliotekach (np. boost::di dla C++) musisz rejestrować zależności dla singletona jako singleton. W .NET tak nie jest (przynajmniej nie w chwili pisania artykułu), ale miej świadomość długości życia.
Więcej na ten temat w kolejnym artykule, opisujących typowo Microsoft Dependency Injection
Skąd ten ServiceProvider jest taki mądry?
W baaaardzo dużym skrócie można by opisać jego działanie tak:
Jaki chcesz typ? App? OK, sprawdźmy go
Typ App ma konstruktor z parametrami. Jaki jest pierwszy parametr? IUserInteraction
OK, muszę stworzyć teraz obiekt IUserInteraction. Co go implementuje? Z konfiguracji wynika, że ConsoleInteraction.
Jaki konstruktor ma ConsoleInteraction? Domyślny. No to tworzymy ConsoleInteraction
Skoro mam utworzone ConsoleInteraction, mogę teraz utworzyć App
ServiceProvider przeleci przez wszystkie zależności (i zależności tych zależności) i utworzy je (jeśli musi) w odpowiedniej kolejności. Na koniec zwróci Ci w pełni działający obiekt, o który prosiłeś.
Możesz teraz uruchomić aplikację i zobaczyć jak działa. Przedebuguj ją sobie linijka po linijce i sprawdź kiedy wywołują się poszczególne konstruktory.
Wydajność aplikacji
Oczywiście taki mechanizm musi wpływać na wydajność aplikacji. Jednak w dzisiejszych czasach nie ma się tym co przejmować. Raczej nie powinno być to zauważalne. Używaj tego i będzie git 🙂 Pamiętaj, żeby nie optymalizować programu jeśli faktycznie nie musisz. Jeśli jest to problem, pomyśl czy klas, które są tworzone najdłużej nie zarejestrować jako singletony.
To na tyle, jeśli chodzi o wstrzykiwanie zależności. W innym artykule opiszę niedługo co jeszcze bardziej zaawansowanego można osiągnąć tym mechanizmem. Więc koniecznie zapisz się do newslettera, żeby nie pominąć go.
Jeśli masz pytania lub znalazłeś błąd w artykule, daj znać w komentarzu. Jeśli spodobał Ci się, udostępnij go 🙂
Obsługujemy pliki cookies. Jeśli uważasz, że to jest ok, po prostu kliknij "Akceptuj wszystko". Możesz też wybrać, jakie chcesz ciasteczka, klikając "Ustawienia".
Przeczytaj naszą politykę cookie