Wstrzykiwanie zależności (ang. Dependency Injection, DI) to jeden z najpopularniejszych wzorców w projektowaniu oprogramowania. Szczególnie wśród programistów, którzy przestrzegają zasad czystego kodu. Co zaskakujące, różnice i relacje między zasadą odwrócenia zależności (dependency inversion principle), odwróceniem kontroli (reversion of control) i wstrzykiwaniem zależności (dependency injection) zazwyczaj nie są jasno określone.
Dlatego, wpis ten ma na celu wyjaśnienie granic między tymi terminami. W formie przystępnego przewodnika, przedstawię w jaki sposób można zaimplementować i wykorzystać wstrzykiwanie zależności (DI).
Podsumowując, po przeczytaniu tego materiału dowiesz się:
Projektowanie oprogramowania stale się rozwija. Pomimo ogromnych implikacji praktycznych, wciąż często nie potrafimy odróżnić świetnego kodu aplikacji od tego niskiej jakości. Dlatego myślę, że warto kontynuować dyskusję na temat tego, jak scharakteryzować świetny kod.
Jedna z najpopularniejszych opinii głosi, że Twój kod musi być czysty, aby był dobry.
Ten sposób myślenia jest szczególnie dominująca wśród programistów korzystających z paradygmatu programowania obiektowego. Dlatego proponują dekompozycję części rzeczywistości objętej aplikacją na obiekty definiowane przez klasy. W związku z tym, pogrupuje je na trzy warstwy:
Zwolennicy czystego kodu uważają, że te warstwy nie są równe i istnieje między nimi hierarchia. Mianowicie implementacja i warstwy interfejsu użytkownika powinny zależeć od logiki biznesowej. Wstrzykiwanie zależności (DI) to wzorzec projektowy, który pomaga osiągnąć ten cel.
Zanim przejdziemy dalej, musimy poczynić pewne wyjaśnienia. Choć tradycyjnie wyróżniamy trzy warstwy aplikacji, ich liczba mogłaby być większa. Warstwy „wyższego poziomu” są bardziej abstrakcyjne i odległe od operacji wejścia/wyjścia.
Zwolennicy czystej architektury wskazują, że dobrą architekturę oprogramowania można opisać jako niezależność warstw wyższego poziomu od modułów niższego poziomu. Nazywa się to zasadą odwrócenia zależności (DIP). Słowo „inwersja” wywodzi się z odwrócenia tradycyjnego przepływu aplikacji, w którym interfejs użytkownika zależał od logiki biznesowej. Z kolei logika biznesowa zależała od warstwy implementacyjnej (jeśli zostały zdefiniowane trzy warstwy). W architekturze, w której twórcy oprogramowania stosują się do DIP, warstwy interfejsu użytkownika i implementacji zależą od logiki biznesowej.
Kilka technik programowania zostało wynalezionych, aby osiągnąć DIP. W związku z tym są one określane razem pod wspólnym terminem: odwrócenie kontroli (Inversion of control, IoC). Wzorzec wstrzykiwania zależności pomaga odwrócić kontrolę nad programem podczas tworzenia obiektów. Tak więc IoC nie ogranicza się do wzorca DI. Poniżej znajdziesz kilka innych przypadków użycia IoC:
Jeśli używasz technik, które odwracają przepływ aplikacji, wiąże się to z ciężarem pisania/używania dodatkowych komponentów. Niestety zwiększa to koszt utrzymania. Rzeczywiście, rozszerzając w ten sposób bazę kodu, zwiększamy również złożoność systemu. W efekcie bariera wejścia dla nowych deweloperów jest wyższa. Dlatego warto omówić potencjalne korzyści płynące z IoC i to kiedy warto z niego korzystać.
Aby opisać wstrzykiwanie zależności (DI), oddzielamy inicjalizację obiektów używanych przez klasę od samej klasy. Innymi słowy, oddzielamy konfigurację klasy od jej zainteresowania. Zależności klasowe są zwykle nazywane usługami. Tymczasem opisana jest klasa – klient.
W tym przypadku założyłem, że wzorzec DI opiera się na wstawieniu zainicjowanych usług jako argumentów do konstruktora klienta. Istnieją jednak inne formy tego wzorca. Przykładem może być dyspozytor wstrzykiwania interfejsu. Na potrzeby tego wpisu, opisuję tylko najpopularniejszy wstrzyknięcie konstruktora.
Ogólnie rzecz biorąc, DI pozwala aplikacjom stać się:
Kiedy te korzyści są warte wysiłku programistów? Odpowiedź jest prosta! Gdy system ma być długowieczny i obejmuje dużą domenę, co skutkuje złożonym grafem zależności.
Po pierwsze, powinniśmy rozważyć i zrozumieć zalety i wady DI. Jak jest w Twoim przypadku? Czy Twój system jest wystarczająco duży, aby skorzystać z tego wzorca? Jeśli tak, to dobry moment na zastanowienie się nad wdrożeniem.
Ten temat zabierze nas w podróż metaprogramowania. Zobaczmy, jak stworzyć program skanujący kod, a następnie działający na podstawie zebranych danych.
Czego potrzebujemy stworzyć framework wykonujący DI? Zwróćmy uwagę na kilka punktów konstrukcyjnych:
W odniesieniu do punktów ogólnych wymienionych powyżej, warto skupić się na warunkach struktur danych, które mogą nam pomóc w implementacji specyfikacji.
W sferze TypeScript możemy z łatwością użyć:
To była długa podróż! Cieszę się że wytrwałeś do tego momentu. Zebraliśmy wszystkie elementy, aby zrozumieć, jak zaimplementować wstrzykiwanie zależności w jednym z najpopularniejszych frameworków NodeJS – NestJS.
Spójrzmy na kod źródłowy frameworka. Proszę sprawdź ten link.
Tutaj interesują nas dwie definicje dekoratorów.
Oba te pliki eksportują funkcje dodające metadane do obiektu. Metoda uruchamia funkcję defined metadata z refleksji interfejsu API.
W pierwszym przypadku (Injectable.decorator.ts) oznaczamy klasy jako dostawców. W związku z tym mogą być wstrzykiwane przez system zależności NestJS. Ten ostatni przypadek (Injectable.decorator.ts) jest inny. Informujemy system DI, że musi podać konkretny parametr konstruktora z klasy.
NestJS grupuje dostawców i inne klasy w grupy o wyższej architekturze zwane modułami. Używamy dekoratorów znajdujących się nest/packages/common/decorators/modules, jako module.decorator.ts.
Podobnie jak w przypadku dekoratorów zdefiniowanych powyżej, główną rolą tego kodu jest eksportować funkcję, która uruchamia refleksję interfejsu API. Ta klasa jest modułem, wobec którego musimy odpowiedzieć na dwa zasadnicze pytania.
Wtedy system DI może ocenić, jak utworzyć wystąpienie zależności.
NestJS dodaje metadane, które pozwala podzielić kod w logiczne jednostki:
W jaki sposób NestJS wykorzystuje informacje? Każdy projekt NestJS zaczyna się mniej więcej podobnym stwierdzeniem:
const app = await NestFactory.create()
Uruchamia ono funkcję create z NestFactoryStatic, która inicjuje między innymi funkcję Initialize.
Jakie jest zadanie funkcji initalize
?
scanModulesForDependencies
, który w istocie uruchamia 4 funkcje: reflectImports
, reflectProviders
, reflectControllers
i reflectExports
.instanceLoader
inicjuje zależności, uruchamiając funkcję createInstancesOfDependencies
, która tworzy i ładuje odpowiedni obiekt.Opisany obraz jest mniej skomplikowany niż cały kod systemu. Musi obsługiwać skrajne przypadki zależności kołowych, i innych, a przy tym dalej przekazywać jego sedno.
Podsumujmy naszą podróż! Najpierw dowiedzieliśmy się, że klasy są pogrupowane w warstwy. Nie są też równe. Aby zachować wśród nich właściwą hierarchię, musimy odwrócić kontrolę. Innymi słowy, musimy ujednolicić przepływ aplikacji.
W przypadku tworzenia obiektów możemy odwrócić sterowanie za pomocą systemu wstrzykiwania zależności (DI). Świetnym przykładem jest NestJS. Tutaj system działa z wykorzystaniem dekoratorów i interfejsu refleksji API. Dlatego, umożliwia transformację metaprogramowania w TypeScript.
Krótko mówiąc, cały ten proces jest wart wysiłku w przypadku złożonych i długotrwałych zastosowań.