Wróć do strony głównej
Angular

Wszystko co musisz wiedzieć o Dependency Injection w Angularze

Wstrzykiwanie zależności, czyli Dependency Injection jest jednym z najważniejszych mechanizmów dostępnych w Angularze. Wzorzec ten zakłada odwrócenie sterowania i przekazanie gotowych instancji do klas, które z nich korzystają, zamiast tworzenia ich wewnątrz samej klasy. Pozwala to na tworzenie luźnych zależności oraz ułatwia testowanie.

W tym artykule chciałbym, abyśmy przyjrzeli się bliżej temu jak to działa. Dowiemy się w jaki sposób zależności są definiowane, tworzone i dostarczane do komponentów oraz jak programiści mogą dostosowywać ten proces. Zapraszam do zgłębienia tajników Dependency Injection w Angularze i odkrycia, dlaczego jest kluczową koncepcją w projektowaniu aplikacji w Angularze, jakie korzyści płyną z jego używania i jak skutecznie stosować go w praktyce.

Ten artykuł jest inspirowany serią Angular Dependency Injection na kanale Decoded Frontend. Jeżeli szukasz treści związanych z Angularem na zaawansowanym poziomie polecam odwiedzić to miejsce.

Jak wstrzykiwać zależności w Angularze

Angular pozwala na wstrzykiwanie niezbędnych zależności takich jak klasy, funkcje czy typy prymitywne do klas opatrzonych dekoratorami @Component, @Directive, @Pipe, @Injectable oraz @NgModule poprzez zdefiniowanie ich jako parametrów konstruktora:

lub użycie funkcji inject:

Funkcja inject w obecnym kształcie została wprowadzona w wersji 14. Poza wygodną i czytelną deklaracją zależności niesie szereg innych korzyści:

  • pozwala ominąć jawne typowanie – TypeScript może wywnioskować typ za nas
  • ułatwia rozszerzanie klas – brak konieczności przekazywania parametrów do konstruktora klasy bazowej
  • umożliwia przeniesienie logiki do reużywalnych funkcji – tutaj jednak wadą jest ukrywanie zależności wewnątrz funkcji

Należy pamiętać, że funkcja inject może zostać użyta tylko wewnątrz injection context, czyli wewnątrz konstruktora, jako definicja pola klasy, wewnątrz factory function (funkcja useFactory w interfejsie Provider oraz dekoratorze @Injectable lub factory podczas definiowania InjectionToken), w API, które zawsze działa wewnątrz injection context np. router guard lub w callbacku funkcji runInInjectionContext.

Jak działa Injector?

Za dostarczenie zależności odpowiedzialna jest abstrakcja zwana Injector. Jeżeli Injector przechowuje już instancję wymaganej zależności jest ona przekazywana do danej klasy. W innym przypadku tworzy nową instancję i zwraca ją jako argument konstruktora, wcześniej zapisując ją w pamięci, ponieważ w obrębie jednego Injectora każda zależność jest singleton, co oznacza, że istnieje tylko jedna instancja.

Aby lepiej zobrazować ten proces możemy posłużyć się prostym przykładem. Załóżmy, że mamy klasę reprezentującą pewien serwis:

Oraz klasę reprezentującą komponent, który z niego korzysta:

Injector odpowiada za przechowywanie i zwracanie instancji zależności:

Podczas kompilacji Angular tworzy Injector i rejestruje zależności, które następnie są przekazywane do komponentów:

Rodzaje i hierarchia Injectorów

Zależności mogą być definiowane na kilku poziomach, które tworzą hierarchie:

  • Element Injector – rejestruje zależności zdefiniowane w tablicy providers wewnątrz dekoratora @Component lub @Directive. Te zależności dostępne są dla danego komponentu i jego potomków.

  • Enviroment Injector – potomne hierarchie Enviroment Injector są tworzone wraz z dynamicznym utworzeniem komponentu np. poprzez Router – taki Injector jest dostępny dla danej ścieżki oraz jej potomków jednocześnie będąc w hierarchii wyżej niż Element Injector danego komponentu:

  • Enviroment Root Injector – zawiera globalnie dostępne zależności, które oznaczone są dekoratorem @Injectable z polem providedIn ustawionym na “root” lub “platform”:

lub umieszczone w tablicy providers w ApplicationConfig:

W celu uzyskania lepszej optymalizacji zaleca się korzystanie z opcji providedIn, która pozwala na tree-shaking, czyli usunięcie zależności, które nie są wykorzystywane. 

  • Module Injector – w przypadku aplikacji opartej na modułach zawiera globalne zależności oznaczone dekoratorem @Injectable z wartością “root” lub “platform” oraz zdefiniowane w tablicy providers w dekoratorze @NgModule. Podczas kompilacji Angular globalnie rejestruje również wszystkie zależności zdefiniowane w importowanych modułach (eager-loaded). Potomne hierarchie Module Injector są tworzone po lazy-loadingu kolejnych modułów.
  • Platform Injector – konfigurowany przez Angulara zawiera zależności związane z platformą, na której działa aplikacja np. DomSanitizer lub token PLATFORM_ID. Dodatkowe zależności na tym poziomie mogą być zdefiniowane poprzez wskazanie ich jako tablica extraProviders przekazana jako parametr funkcji platformBrowserDynamic.
  • Null Injector – najwyższy element w hierarchii, którego zadaniem jest wyrzucenie błędu “NullInjectorError: No provider for …”, chyba że zastosowano modyfikator @Optional.

Jeżeli komponent wymaga jakiejś zależności Angular początkowo szuka jej w Element Injector danego komponentu. Jeżeli nie jest zdefiniowana jako provider sprawdzany jest komponent-rodzic. Ten proces powtarza się do momentu, aż uda się odnaleźć zależność lub nie istnieje kolejny komponent-rodzic.

W tym drugim przypadku następuje przejście do fazy drugiej – sprawdzany jest hierarchia Enviroment Injector-ów (lub Module Injector-ów w przypadku aplikacji opartej na modułach) odpowiednia dla danego komponentu aż to Enviroment Root Injector-a. Jeżeli Angular zdoła dotrzeć do Null Injector-a następuje wyrzucenie błędu. 

Taki hierarchiczny układ sprawia, że jeżeli dwie te same zależności zdefiniowane są na różnych poziomach do komponentu zwracana jest instancja z injectora, którego został sprawdzony wcześniej

Modyfikatory rezolucji zależności

Na opisany wyżej proces wpływ mają modyfikatory:

  • @Optional – oznacza, że zależność jest opcjonalna, czyli zamiast wyrzucenia błędu w NullInjector do komponentu zostanie zwrócona wartość null
  • @Self – w poszukiwaniu zależności z tym dekoratorem Angular sprawdzi jedynie ElementInjector danego komponentu, czyli musi być ona zdefiniowana w tablicy providers komponentu. W przeciwnym wypadku wyrzucony zostanie błąd “NodeInjector: NOT_FOUND”
  •  @SkipSelf – przeciwieństwo modyfikatora @Self. Angular pominie ElementInjector danego komponentu i rozpocznie poszukiwanie zależności od jego rodzica
  • @Host  – ten modyfikator ogranicza proces przeszukiwania do hosta danego elementu. Aby lepiej to wytłumaczyć posłużmy się przykładem. Załóżmy, że mamy komponent MyComponent, który w swoim widoku ma dwie dyrektywy: ParentDirective i ChildDirective, z czego ta druga wymaga wstrzyknięcia serwisu MyService. Po zbudowaniu aplikacji kod HTML odpowiedzialny za ten fragment mógłby wyglądać następująco:

  • Obszar ograniczony tagiem <app-my-component> to właśnie host naszego komponentu. To oznacza, że w poszukiwaniu providera dla serwisu MyService Anguar sprawdzi kolejno jedynie:
    – tablicę providers w ChildDirective
    – tablicę providers w ParentDirective
    – tablicę viewProviders w MyComponent

    Jeżeli nie zostanie znaleziony w tych miejscach aplikacja wyrzuci błąd.

Opcja viewProviders jest dostępna tylko dla komponentów, a zdefiniowane w niej zależności są dostępne w widoku (host) komponentu – oznacza to, że nie są widoczne dla elementów wstawionych do jego widoku za pomocą <ng-content> pomimo, że tego, że są logicznymi potomkami tego komponentu.

Wymienione dekoratory mają zastosowanie w przypadku wstrzykiwania zależności poprzez konstruktor. W przypadku użycia funkcji inject należy ustawić odpowiednie flagi, których nazwy odpowiadają dekoratorom, w obiekcie opcji, na przykład:

Czym jest Dependency Provider?

W tym momencie warto szerzej opisać czym jest dependency provider. W skrócie jest to przepis, który mówi Angularowi w jaki sposób należy utworzyć zależność.

Najprostszym i domyślnym sposobem jest TypeProvider, czyli użycie referencji klasy jako tokenu. Instancja tej klasy zostanie utworzona za pomocą operatora new. W istocie jest to skrót, który Angular rozwija w pełną definicję opisaną przez interfejs Provider. Obiekt ten zawiera pole provide, które zawiera token, służący do identyfikacji zależności oraz definicję jak ją utworzyć.

Class provider

Class provider zawiera opcję useClass i jego zadaniem jest utworzenie i zwrócenie nowej instancji zdefiniowanej klasy. Pozwala na zastąpienie klasy zdefiniowanej jako token poprzez jej rozszerzenie, klasę różniącą się implementacją lub jej mockiem używanym w testach.

Ten przykład pokazuje jak zmienić implementację zależności bez wprowadzania zmian w samym komponencie.

Alias provider

Alias provider za pomocą opcji useExisting mapuje podaną klasę na token w polu provider. Tym sposobem Angular nie tworzy nowej instancji, ale zwraca do komponentu już utworzoną.

Taka definicja sprawia, że jeżeli komponent wymaga klasy Logger lub TimeLogger zawsze zostanie zwrócona do niego instancja klasy TimeLogger. Warto zwrócić uwagę na różnicę pomiędzy useExisting a useClass. W przypadku użycia tej drugiej opcji zostałaby utworzona druga, niezależna instancja klasy TimeLogger.

Factory provider

Factory provider umożliwia tworzenie zależności opartych na dynamicznych wartościach przechowywanych w innych miejscach aplikacji poprzez wywołanie funkcji zdefiniowanej w polu useFactory.

Ten rodzaj providera zawiera dodatkowe pole deps, które jest tablicą tokenów przekazywanych kolejno jako argumenty funkcji, dlatego ważna jest kolejność w jakiej są zdefiniowane. W przypadku funkcji z większą ilością argumentów wygodniejszym i bardziej elastycznym rozwiązaniem może być zastąpienie ich jednym – Injectorem, który pozwoli na pobranie potrzebnych zależności już wewnątrz funkcji. Przykładowa implementacja providera mogłaby wyglądać następująco:

Innym ciekawym przykładem zastosowania factory provider jest przypadek, kiedy z góry nie wiemy jaką zależność chcemy wykorzystać i jest to zdeterminowane przez jakiś warunek dostępny dopiero podczas działania aplikacji. Posługując się prostym przykładem – mamy serwis, który łączy się z zewnętrznym API i chcemy ograniczyć liczbę wysyłanych żądań, aby np. nie generować dodatkowych kosztów:

Value provider

Value provider za pomocą pola useValue umożliwia powiązanie statycznej wartości z tokenem DI. Ta technika jest zazwyczaj wykorzystywana do dostarczenia stałych konfiguracyjnych czy mockowania danych podczas testów.

Dlaczego używamy Injection Tokenu?

W przypadku value providera niezbędny jest InjectionToken, ale dlaczego go potrzebujemy? Każda zależność w Injectorze musi być opisana przez unikalny identyfikator – token (pole provide), tak aby Angular wiedział, co powinien dostarczyć do komponentu, który jakiejś wymaga. W przypadku klas, takich jak serwisy, za token służy sama referencja klasy. Co jednak w przypadku, gdy zależność nie jest klasą, a np. obiektem lub nawet typem prymitywnym? Jako token nie możemy wykorzystać interfejsu, ponieważ taki konstrukt nie istnieje w JavaScript i jest usuwany podczas transpilacji. Teoretycznie jako token możemy wykorzystać string:

Jednak takie rozwiązanie ma szereg wad. Łatwo sobie wyobrazić zrobienie literówki lub przypadkowe wykorzystanie tej samej wartości dla różnych zależności. Tutaj z pomocą przychodzi InjectionToken:

Wartość podana jako argument konstruktora nie jest identyfikatorem, a jedynie opisem – identyfikator tworzony przez InjectionToken jest zawsze unikalny.

Jak widać na powyższym przykładzie, aby wstrzyknąć InjectionToken wykorzystujemy dekorator @Inject() podając referencję do danego tokenu jako argument.

Jeżeli chcemy, aby token globalnie reprezentował daną wartość i był poddawany procesowi tree-shakingu możemy dodatkowo wykorzystać obiekt opcji:

Multi-providers

Kolejnym parametrem, który możemy skonfigurować w providerze jest pole multi. Ustawienie wartości true pozwala na powiązanie wielu zależności z jednym tokenem i zwrócenie ich jako tablicy. Zapobiega to domyślnemu zachowaniu jakim jest nadpisywanie zależności. Aby to zilustrować stwórzmy token, do którego następnie przypiszemy dwie wartości. Oto rezultat jaki otrzymamy:

Jednym z najpowszechniejszych przypadków ich zastosowania są Interceptory. Zgodnie z zasadą Single Responsibility Principle każdy z nich odpowiedzialny jest inne działanie, a multi-provider pozwala na działanie każdego z nich pomimo, że używają tego samego tokenu.

ForwardRef

Funkcja forwardRef służy do tworzenia pośrednich referencji, które nie są rozwiązywane od razu. Ponieważ kolejność definiowania klas ma znaczenie, jest szczególnie przydatna w przypadku zapętlenia referencji lub, gdy klasa próbuje wykorzystać referencję do samej siebie:

Dodatkowe korzyści płynące z Dependency Injection

Tworzenie luźnych zależności poza modularnością kodu i większą elastyczności znacząco ułatwia również testowanie. Zastąpienie zależności przez jest mock pozwala na izolowanie testowanych funkcjonalności i sprawdzenie ich zachowania w kontrolowanym środowisku. Co prawda frameworki do testowania zajmują się tym za nas, jednak szczególnie w przypadku bardziej skomplikowanych serwisów możemy podmienić zależności ręcznie:

Wzorcem projektowym, który możemy wykorzystać korzystając z Dependency Injection jest port-adapter. Zakłada on, że jeden z modułów definiuje kształt abstrakcji, a inny dostarcza jej implementację. Pozwala to na odseparowanie logiki oraz poluźnienie zależności pomiędzy modułami, ponieważ implementacja może być zamieniona w łatwy sposób. Tutaj świetnie sprawdza się klasa abstrakcyjna, która tworzy interfejs i jednocześnie może być wykorzystana jako token:

Zakończenie

Dependency Injection to nie tylko wzorzec programowania, ale także filozofia projektowania aplikacji, która promuje modułowe, elastyczne i łatwe do testowania rozwiązania. W tym artykule omówiliśmy kluczowe aspekty jego działania w Angularze. Stosowanie tego wzorca zapewnia szereg korzyści, w tym większą czytelność kodu, łatwiejsze zarządzanie zależnościami i elastyczność w modyfikowaniu aplikacji. Zachęcamy do eksperymentowania z jego wykorzystaniem we własnych projektach oraz dalszego pogłębiania wiedzy na temat najlepszych praktyk. Niech Dependency Injection stanie się integralną częścią Twojego podejścia do tworzenia aplikacji, przynosząc korzyści zarówno w krótkiej jak i długiej perspektywie.

O autorze

Milosz Rutkowski

Angular Developer w House of Angular. Miłośnik tworzenia aplikacji webowych z zapałem do coraz lepszego poznawania Angulara. Fan czystego kodu, dobrych praktyk i świetnie skomponowanej architektury.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *