Oto klasa, której nie przetestujesz jednostkowo bez działającej bazy danych i serwera SMTP:
| |
OrderService tworzy własne instancje SqlOrderRepository i SmtpEmailSender przez new, razem z connection stringiem. Każdy test dotykający PlaceOrder potrzebuje prawdziwej bazy Orders i działającego serwera pocztowego, inaczej padnie, zanim dojdzie do asercji. Logika, którą chcesz przetestować, to dwie linijki: zapisz zamówienie, wyślij potwierdzenie. Jest zespawana z dwiema konkretnymi klasami, których nie da się podmienić.
Co w praktyce oznacza dependency injection
Dependency injection polega na przekazaniu klasie obiektów, których potrzebuje, zamiast pozwalać jej samodzielnie je tworzyć. Klasa deklaruje, od czego zależy. Kod poza klasą decyduje, jaką konkretną implementację dostanie. Kontenery i cykle życia to narzędzia zbudowane wokół tego jednego pomysłu.
Przepisz OrderService tak, żeby przyjmował zależności przez konstruktor, jako interfejsy, a nie konkretne klasy:
| |
OrderService nie wie już, czy repozytorium zapisuje do SQL Server, czy do listy w pamięci, ani czy e-mail leci przez SMTP, czy donikąd. Zna tylko dwa interfejsy i wywołuje ich metody. To, co zostanie podpięte, decyduje ten, kto tworzy OrderService: test przekazuje fake’i, aplikacja webowa przekazuje prawdziwe implementacje.
To jest constructor injection i tej formy będziesz używać niemal zawsze. Property injection (ustawianie publicznej właściwości po skonstruowaniu obiektu) i method injection (przekazanie zależności tylko do tej jednej metody, która jej potrzebuje) istnieją dla zależności opcjonalnych i wartości używanych jednorazowo. Wbudowany kontener w ASP.NET Core w ogóle nie obsługuje property injection. Zasada jest krótka: jeśli klasa nie może wykonać swojej pracy bez czegoś, to coś trafia do konstruktora.
Jak dependency injection ułatwia testowanie klasy
Skoro zależności są przekazywane jako parametry interfejsowe, test może wstrzyknąć do OrderService cokolwiek zechce:
| |
Test wykonuje się w milisekundach i nie dotyka żadnej bazy danych ani sieci. To skrajny, jednostkowy koniec spektrum opisanego w Testy Jednostkowe vs Integracyjne vs End-to-End: jedna klasa, podmienione kolaboratory, sprawdzone jedno zachowanie. Te ręcznie napisane klasy to fake’i, małe działające implementacje z własnym stanem. Biblioteka do mockowania, jak Moq czy NSubstitute, generuje za ciebie ten sam rodzaj zastępnika, więc nie musisz pisać klasy dla każdego interfejsu.
Co robi kontener DI i dlaczego wcale go nie potrzebujesz
Nic z powyższego nie wymagało frameworka. Prawdziwe zależności możesz spiąć ręcznie w punkcie wejścia aplikacji:
| |
Nazywa się to „pure DI” (albo „poor man’s DI”) i dla małej aplikacji to w zupełności wystarczy. Wzorzec polega na przekazywaniu zależności z zewnątrz. Kontener automatyzuje to spinanie i zaczyna się zwracać, gdy masz dziesiątki klas z nakładającymi się zależnościami, a zmiana jednego konstruktora oznacza edycję kodu spinającego w pięciu miejscach.
ASP.NET Core ma wbudowany kontener. Każdy interfejs rejestrujesz względem implementacji raz, przy starcie, a framework rozwiązuje cały graf zależności za każdym razem, gdy buduje kontroler, handler minimal API albo dowolny inny zarejestrowany serwis:
| |
SqlOrderRepository i SmtpEmailSender przyjmują connection string i nazwę hosta, a nie bezparametrowy konstruktor, więc każda rejestracja jest fabryką: lambdą, która dostaje kontener (sp) i zwraca instancję. Gdy żadna implementacja nie potrzebuje takiej wartości, krótsza jest forma z dwoma argumentami typu: AddScoped<IOrderRepository, SqlOrderRepository>().
Zarejestruj tak samo sam OrderService (builder.Services.AddScoped<OrderService>()) i możesz poprosić o niego w konstruktorze kontrolera. Kontener zbuduje go, rozwiąże IOrderRepository i IEmailSender na podstawie tych rejestracji, a ty nigdy nie wywołasz new.
Cykle życia serwisów w ASP.NET Core: singleton, scoped, transient
AddScoped, AddTransient i AddSingleton rejestrują w ten sam sposób; nazwa metody to cykl życia, a wybór złego cyklu powoduje większość błędów DI w ASP.NET Core.
| Cykl życia | Nowa instancja tworzona | Typowe zastosowanie |
|---|---|---|
Transient | Za każdym razem, gdy jest potrzebna | Bezstanowe, tanie serwisy (formatter, walidator) |
Scoped | Raz na żądanie HTTP | Wszystko związane z bieżącym żądaniem: DbContext, unit of work |
Singleton | Raz, na cały czas życia aplikacji | Współdzielony, bezpieczny wątkowo stan: konfiguracja, pula połączeń, cache |
Powyższa rejestracja AddScoped daje każdemu żądaniu własny SqlOrderRepository. Jeśli repozytorium opakowuje DbContext, dwa równoległe żądania nigdy nie dzielą niedokończonej transakcji. Rejestracja AddTransient tworzy nowy SmtpEmailSender przy każdym wstrzyknięciu. To nie problem, bo sender nie trzyma żadnego stanu związanego z żądaniem.
Captive dependencies: serwis scoped uwięziony w singletonie
To jest zasada cyklu życia, która potrafi ugryźć. Serwis może bezpiecznie zależeć tylko od czegoś, co żyje co najmniej tak długo jak on sam. Singleton może przyjmować inne singletony. Serwis scoped może przyjmować scoped albo singleton. Transient może przyjąć wszystko. Problem pojawia się, gdy singleton przyjmuje w konstruktorze zależność scoped:
| |
Kontener buduje ReportCache raz, a otrzymany AppDbContext zostaje w tym polu na cały czas życia aplikacji. To jest captive dependency. Każde kolejne żądanie dzieli jeden DbContext, klasę, która nie jest ani bezpieczna wątkowo, ani pomyślana do długiego życia. Objawia się to nieaktualnymi odczytami albo wyjątkami InvalidOperationException mówiącymi, że druga operacja na tym kontekście zaczęła się, zanim poprzednia się zakończyła.
ASP.NET Core łapie to już w trakcie developmentu. Przy włączonej walidacji zakresów, co jest domyślne w środowisku Development, rozwiązanie singletona potrzebującego serwisu scoped rzuca InvalidOperationException przy starcie, zamiast psuć dane na produkcji. Jeśli singleton naprawdę od czasu do czasu potrzebuje serwisu scoped, wstrzyknij IServiceScopeFactory, utwórz zakres tam, gdzie go potrzebujesz, i rozwiąż serwis z tego zakresu, zamiast trzymać go w polu.
Antywzorce dependency injection: service locator i przeładowane konstruktory
Service locator w przebraniu. Wstrzyknięcie samego kontenera i wyciąganie z niego zależności wewnątrz metody wygląda jak DI, ale wyrzuca to, co DI daje:
| |
Konstruktor nie mówi już, czego potrzebuje OrderService. Żeby się tego dowiedzieć, trzeba przeczytać każdą metodę, a brakująca rejestracja wychodzi na jaw jako null w środku PlaceOrder, zamiast jako błąd przy budowaniu klasy. Wstrzykuj konkretne interfejsy, których używasz, a nie obiekt, który ci je rozdaje.
Konstruktor z dwunastoma parametrami. Klasa, która potrzebuje IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger i siedmiu kolejnych, robi za dużo rzeczy naraz. DI tego nie spowodowało, tylko to uwidoczniło. To ten sam objaw, który opisuje Czym Jest Spaghetti Code i Jak Go Zrefaktoryzować. Podziel klasę według odpowiedzialności, a konstruktor sam się skurczy. Upchnięcie dwunastu zależności w jednym obiekcie „services” ukrywa problem, zamiast go rozwiązać.
Oba te antywzorce łatwo prześlizgują się przez code review. Przestają się prześlizgiwać, gdy ktoś po raz pierwszy spróbuje napisać dla tej klasy test jednostkowy.
Od czego zacząć z dependency injection
Wybierz w swoim kodzie jedną klasę, która wywołuje new na kliencie bazy danych, kliencie HTTP albo senderze poczty. Zmień jej konstruktor tak, żeby przyjmował interfejs, podłącz prawdziwą implementację ręcznie w Program.cs i napisz jeden test, który przekazuje fake’a. W aplikacji ASP.NET Core pomiń ręczne spinanie i zarejestruj klasę w builder.Services, skoro kontener już tam jest. Dla wszystkiego, co trzyma stan, wybieraj cykl życia świadomie, i zostaw włączoną walidację zakresów, żeby framework wyłapał captive dependency, zanim zrobią to twoi użytkownicy.