Dependency Injection w Angular 2

Angular 2 aktywnie korzysta ze wzorca projektowego Dependency Injection. Ten wpis poświęciłem wyłącznie implementacji DI w tym frameworku. Jest ona bardzo rozbudowana i niezwykle ciekawa, a jej dokładne poznanie pozwoli na lepsze zrozumienie wstrzykiwania zależności w Angularze oraz sprawi, że będziemy tworzyć aplikacje bardziej świadomie i łatwiej.

Samemu tematowi Dependency Injection przeznaczyłem osobny wpis. Piszę tam o wzorcu projektowym, niezależnie od implementacji.

Zacznijmy może od tego, że Dependency Injection jest bardzo ważną częścią Angular 2. Bez korzystania z DI nie możemy budować nawet prostych aplikacji. Angular 2 ma własny framework DI (który ma być udostępniony jako moduł niezależny od Angulara, do wykorzystania w dowolnej aplikacji). Co takiego robi dla nas DI w Angular 2? Spójrzmy na prosty przykład. Podobny kod widzieliśmy już wielokrotnie:

// child.component.ts
class MyChildComponent {  
  constructor(private dataService: DataService) { … }
}

// data.service.ts
@Injectable()
export class DataService { … }

// app.component.ts
@Component({
  selector: 'my-app',
  directives: [MyChildComponent],
  providers: [DataService],
  template: `
  <my-child-component></my-child-component>
  <my-child-component></my-child-component>
  `
})
class MyAppComponent { … }  

To autentyczny kod z wpisu Komunikacja pomiędzy komponentami w Angular 2. Widzimy tutaj komponent MyChildComponent, który do konstruktora ma wstrzykniętą instancję serwisu DataService. Aby wstrzykiwanie w ogóle było możliwe, klasa DataService jest dodatkowo dodana do tablicy providers w komponencie AppComponent. Wcześniej założyliśmy, że to po prostu działa – Angular 2 magicznie wie, że powinien tę zalożność wstrzyknąć i voilà – tak się stawało! Teraz jednak zastanówmy się jak to się dzieje pod maską.

Rejestracja zależności

Zauważmy, że klasa DataService została oznaczona dekoratorem @Injectable. W jakim celu? Jako dobrą praktykę polecam oznaczać wszystkie serwisy jako @Injectable. Jest to informacja dla modułu Angular 2 odpowiedzialnego za wstrzykiwanie zależności, zwanego injector. Dzięki temu staje się on świadomy istnienia naszej klasy i pozwala na wstrzykiwanie zależności również do niej.

Tutaj może pojawić się pewna myśl: „Ale przecież nigdy nie korzystaliśmy z żadnego modułu injector!”. Okazuje się, że korzystaliśmy z niego wielokrotnie, ale nigdy świadomie – nie musimy sami tworzyć tego modułu, Angular robi to za nas w trakcie powoływania do życia aplikacji.

bootstrap

Aby Dependency Injection mogło działać, Angular musi wiedzieć co i skąd powinien wstrzykiwać. Dlatego każdą zależność musimy wcześniej zarejestrować. Kiedyś krótko wspomniałem o tym, że funkcja bootstrap(…) przyjmuje jako drugi argument listę zależności – to właśnie one trafiają do głównego, najwyższego Injectora w Angularze. Jest to jedna z metod rejestrowania zależności. Moglibyśmy więc zrobić coś takiego:

bootstrap(AppComponent,  
         [DataService]); // nie róbcie tego

I dzięki temu instancja serwisu DataService byłaby dostępna z poziomu każdego komponentu w naszej aplikacji! To działa, jednak nie jest to najlepszy sposób na wykorzystanie DI. Drugi argument funkcji bootstrap przewidziany jest do trzech rzeczy:

  • rejestrowania modułów, które naprawdę muszą być globalne i są niezbędne do działania aplikacji w ogóle – na przykład Redux
  • nadpisywania modułów wbudowanych w Angular 2
  • konfiguracji modułów w zależności od środowiska (np. przeglądarka / serwer)

Pozostałe przypadki lepiej jest obsłużyć używając tablicy providers w konkretnych komponentach.

Tablica providers w komponentach

W kodzie wyżej zarejestrowaliśmy serwis DataService na poziomie komponentu AppComponent. Dlaczego? Co prawda sam AppComponent z niego nie korzysta, ale jego dzieci – ChildComponent – już tak. W naszym przykładzie DataService jest serwisem, który ma być współdzielony przez oba komponenty ChildComponent.

Moglibyśmy nie rejestrować tego serwisu w komponencie ApppComponent, a zamiast tego zrobić to w ChildComponent. W takim przypadku jednak instancje wstrzyknięte do tych komponentu byłyby różne i nie mogłyby posłużyć do komunikacji! Porównajmy ze sobą dwa poniższe przykłady. Jedyną różnicą jest właśnie miejsce rejestracji serwisu:

W pierwszym przypadku rejestracja DataService ma miejsce w komponencie MyChildComponent i przez to każdy z komponentów MyChildComponent otrzymał swoją własną instancję serwisu. W drugim przypadku serwis zostaje zarejestrowany w rodzicu (AppComponent) i dzięki temu komponenty-dzieci współdzielą tę samą instancję DataService. Nasuwa się tutaj jeden ważny wniosek: Instancje zależności zarejestrowanych w komponencie są współdzielone przez wszystkie jego dzieci.

Innymi słowy, zależności w Angular 2 są singletonami na poziomie danego injectora.

Wiele injectorów

Widząc zmianę zachowania wstrzykiwania związaną z tym, gdzie zarejestrujemy zależność, możemy zadać sobie pytanie: Czy injector musi zapamiętywać, gdzie zarejestrowane były zależności? A może injectorów jest kilka?

Ten drugi strzał okazuje się strzałem w dziesiątkę! Rzeczywiście, Angular 2 tworzy, równolegle do drzewa komponentów, drzewo injectorów. Koncepcyjnie możemy sobie wyobrazić, że injector jest tworzony razem z każdym komponentem, chociaż w rzeczywistości jest to trochę bardziej skomplikowane (i bardziej zomptymalizowane), jednak faktem jest, że każdy komponent posiada injector.

Hierarchiczne DI

Wynika z tego bezpośrednio, że Dependency Injection w Angular 2 jest hierarchiczne. Jednak co to oznacza? Kiedy komponent żąda wstrzyknięcia jakiegoś serwisu, Angular próbuje tę zależność spełnić. Sprawdza najpierw injector na poziomie komponentu – jeśli ten nie ma zarejestrowanego serwisu, Angular przechodzi o poziom wyżej i sprawdza injector rodzica. Jeśli ten również go nie posiada – sprawdzany jest kolejny komponent i kolejny, coraz wyżej, aż serwis zostanie odnaleziony. W przeciwnym wypadku – Angular rzuca wyjątek. Na prostym przykładzie. Wyobraźmy sobie takie drzewo komponentów:

Drzewo komponentów

Oraz tak zarejestrowane zależności:

  • AppComponentServiceA, ServiceB, ServiceC
  • ListComponentServiceB, ServiceC
  • ListItemComponentServiceC

Jeśli ListItemComponent proprosi o wstrzyknięcie zależności ServiceA, ServiceB, ServiceC to otrzyma on te zależności od najbliższych komponentów w górę, w których zostały one zarejestrowane, czyli w tym przypadku każdy z serwisów otrzyma od innego komponentu. Wynika z tego, że np. ServiceBServiceC zarejestrowane w ListComponent przysłaniają ServiceBServiceC zarejestrowane w AppComponent. Wykropkowane linie określają rejestrację zależności, a strzałki wstrzykiwanie:

Hierarchiczne DI

@Optional

W poprzednim akapicie napisałem, że jeśli zależność nie zostanie odnaleziona to Angular rzuca wyjątek. Zazwyczaj jest to zachowanie, którego oczekujemy, bo chroni nas przed typowymi pomyłkami, np. niezarejestrowaną zależnością lub literówką w nazwie. Co jednak, gdy wyjątkowo chcemy, aby Angular daną zależność po prostu zignorował, jeśli nie zostanie ona odnaleziona? Możemy użyć do tego dekoratora @Optional:

class MyChildComponent {  
  constructor(
      @Optional() private dataService: DataService
  ) { … }
}

W powyższym przykładzie, jeśli serwis DataService nie został zarejestrowany to dataService przyjmie wartość null.

Gdzie rejestrować zależności

Jak widzimy na powyższych przykładach, decyzja o tym gdzie zarejestrowany został serwis wpływa na zachowanie aplikacji. W takim razie nasuwa się pytanie: Gdzie rejestrować zależności?

Najlepszym podejściem jest rejestracja zależności najniżej, jak tylko się da. Innymi słowy – najbliżej komponentu, który tej zależności potrzebuje. Proste przykłady zamieszczam poniżej w tabelce:

przykład użycia serwisumiejsce rejestracji
stan aplikacji (np. Redux)najwyższy komponent aplikacji lub funkcja bootstrap
komunikacja pomiędzy elementami listynajbliższy wspólny rodzic, czyli komponent listy
serwis wspomagający edycję rekordu w tabelcekomponent, który umożliwia edycję

Providers

Cały czas mówimy o tym, że zależności należy zarejestrować przed użyciem. Jednak do tej pory widzieliśmy tylko jeden sposób rejestracji zależności, poprzez podanie klasy:

providers: [MyService] // MyService jest klasą  

Jednak co w sytuacji, gdy nasza zależność nie jest klasą?

Tokeny

Zależności w Angular 2 rozróżniane są na podstawie tzw. tokenów. Klasa jest jednym z tokenów, ale możliwości jest więcej. Token może być również po prostu ciągiem znaków – nazwą zależności. Możliwa jest taka rejestracja zależności:

providers: [  
    { provide: 'MyDependency' useValue: 'Hello, world!' })
]

Łatwo domyślić się, że zarejestrowaliśmy tutaj zależność o nazwie MyDependency, która po wstrzyknięciu będzie po prostu wartością: ’Hello, world!'. Możemy teraz ją wstrzyknąć, ale robimy to również w nieco odmienny sposób:

class MyChildComponent {  
  constructor(
      @Inject('MyDependency') private myDependency: string
  ) { … }
}

Używamy dekoratora @Inject(…) i podajemy do niego nazwę zależności.

Powraca tutaj jednak pewien problem: Używając stringa do reprezentacji zależności możemy przypadkiem mieć konflikt nazw. Załóżmy, że dwie osoby w zespole stworzą zupełnie różne zależności i obie nazwą 'ListHelper'1. Jedna może przypadkiem przysłonić drugą… Do odróżniania zależności potrzebujemy więc czegoś więcej niż prostego stringa. Czegoś unikatowegosymbolicznego.

OpaqueToken

Oba te wymagania spełnia specjalny obiekt OpaqueToken udostępniony przez Angulara. Jest to konstruktor, który możemy wykorzystać w następujący sposób:

// MyDependency.ts
import { OpaqueToken } from '@angular/core';  
export const MY_DEPENDENCY_TOKEN = new OpaqueToken('MyDependency');

// komponent
import { MY_DEPENDENCY_TOKEN, MyDependency } from './MyDependency';  
providers: [  
   {provide: MY_DEPENDENCY_TOKEN, useValue: MyDependency })
]

Następnie taki OpaqueToken wykorzystujemy również do wstrzykiwania:

import { MY_DEPENDENCY_TOKEN } from './MyDependency';

class MyChildComponent {  
  constructor(
      @Inject(MY_DEPENDENCY_TOKEN) private myDependency: string
  ) { … }
}

Zaawansowane zależności

Jak można zobaczyć w przykładzie wyżej, tablica providers przyjmuje nie tylko klasy, lecz również obiekty, które pozwalają na bardziej zaawansowaną konfigurację. Omówmy teraz sposoby rejestracji zależności:

useValue – wartości

Dodanie do obiektu własności useValue pozwala na podmianę zależności na konkretną wartość. Jest to bardzo przydatne w przypadku konfigurowania wartości, które zależą od informacji dostępnych dopiero w trakcie uruchamiania aplikacji – przykładowo adres strony. Dodatkowo useValue przydaje się w trakcie pisania testów jednostkowych, gdyż pozwala w łatwy sposób podmienić zależność na jej mock:

{ provide: DataService, useValue: dataServiceMock }

useClass – klasy

Możemy skorzystać z tego atrybutu, aby podmienić klasę na jej alternatywną implementację. Przydatne np. w zależności od środowiska lub w trakcie testów jednostkowych:

class DataService { … }  
class LocalStorageDataService { … }  
…
{ provide: DataService, useClass: LocalStorageDataService }

Jeśli myślisz teraz o wzorcu projektowym strategia – to dobrze. Jest to jeden ze scenariuszy gdy useClass jest przydatne.

useExisting – tworzenie aliasów

DI w Angular 2 umożliwia również tworzenie aliasów zależności. Jednym z zastosowań jest przysłanianie pewnych metod w zależności od sposobu wstrzyknięcia. Przykładem może być stworzenie wersji serwisu „tylko do odczytu”. Pod spodem nadal jest to ten sam serwis, jednak w zależności od tego co wstrzykujemy, otrzymujemy dostęp tylko do pewnych metod:

class DataService {  
    private data: Array<string>;
    add(value:string) {
        this.data.push(value);
    }
    getLast():string {
        return this.data[this.data.length - 1];
    }
}

abstract class ReadonlyDataService {  
    getLast: () => string;
}

{ provide: ReadonlyDataService, useExisting: DataService }

Jeśli teraz wstrzykniemy ReadonlyDataService, będziemy mieli dostęp tylko do metody getLast – mimo, że w rzeczywistości będziemy pracować na instancji klasy DataService.

useFactory – fabryka

Według dokumentacji należy skorzystać z tej własności wtedy, gdy tworzona zależność jest kombinacją wstrzykniętych serwisów i stanu aplikacji. Fabryka jest funkcją, która zwraca wartość. Przykładowo:

{
    provide: MY_DEPENDENCY_TOKEN,
    useFactory: myDependencyFactor(true),
    deps: [DataService]
}

Zauważmy, że w trakcie rejestracji przekazujemy do fabryki argument zależny od stanu aplikacji. W tym przypadku jest to true – mógłby to być na przykład parametr ustawiany w trakcie budowania aplikacji, oznaczający czy aplikacja jest w trybie debug, czy nie, ale równie dobrze może to być dowolna wartość, obiekt… cokolwiek. Dodatkowo fabryka wymaga też zależności zarejestrowanych w Angularze (DataService). Nasza myDependencyFactory wygląda tak:

export function myDependencyFactory(isDebug) {  
    return (dataService: DataService) => {
        if (isDebug) {
            …
        } else {
            …
        }
    };
}

useFactory vs useValue

Uważne osoby dostrzegły pewnie, że we wpisie Angular 2 i Redux wykorzystałem useFactory, podczas gdy mógłbym to zrobić prościej i skorzystać z useValue. Przypomnijmy sobie fragment kodu:

const appStoreFactory = () => {  
  const appStore = createStore(rootReducer, undefined, window.devToolsExtension && window.devToolsExtension());
  return appStore;
};
provide('AppStore', { useFactory: appStoreFactory })  

Czy ten kod nie byłby równoważny z następującym?

const appStore = createStore(rootReducer, undefined, window.devToolsExtension && window.devToolsExtension());  
provide('AppStore', { useValue: appStore })  

Na pierwszy rzut oka: Tak. Jednak po głębszej analizie okazuje się, że ich zachowania minimalnie się różnią. Jeśli wykorzystalibyśmy ten drugi, prostszy kod, nie moglibyśmy skorzystać z wtyczki Redux do Chrome, gdyż zmiany w niej wprowadzane nie miałyby odzwierciedlenia w aplikacji. Dlaczego? Ponieważ kod wewnątrz useFactory wykonywany jest po stworzeniu przez Angulara tzw. Zone. Warto o tym pamiętać. Więcej na ten temat tego czym jest w ogóle Zone w Angularze w innej części kursu Angular 2.

Zaawansowane DI

Czy to już wszystko, co oferuje DI w Angularze? Absolutnie nie. Jednak pozostałe elementy są tak szczegółowe, że nie zmieściłyby się w tym wpisie! W razie potrzeby warto doczytać o takich aspektach jak wstrzykiwanie rodzica w komponencie-dziecku, dekoratorach @Host@SkipSelf oraz funkcji forwardRef. Szczegółowe informacje na te tematy można znaleźć w dokumentacji.

Podsumowanie

W tej części kursu Angular 2 omówiłem Dependency Injection w Angularze. Jest to moduł bardzo rozbudowany i szczegółowo opisałem wiele jego możliwości. Warto pamiętać o potencjale, który DI nam daje – o drzewie injectorów i zastępowaniu istniejących zależności mockami w czasie testów. Mam nadzieję, że wiedza, którą tutaj zawarłem pomoże w bardziej świadomym tworzeniu aplikacji w Angular 2. Zachęcam do komentowania 🙂

  1. “Są tylko dwie rzeczy trudne w informatyce – pierwsza to nazewnictwo, druga to inwalidacja cache.” Za nazwę ListHelper ktoś powinien oberwać 😉

  • Emil

    Cześć, spotkałem się z taką opinią, że Angulara używają tylko duże firmy
    programistyczne przy tworzeniu stron typu enterprise. Czy to prawda?
    Pytam pod kątem przyszłych zleceń/pacy bo wolę jednak mniejsze firmy.
    Pozdr.

    • Trudno jednoznacznie odpowiedzieć na to pytanie. Duże firmy typu enterprise raczej nie używają Angulara *jeszcze*, ale bez wątpienia nowy Angular celuje trochę w środowisko enterprise.

    • Dariusz

      Angular daje duże możliwości zarówno po stronie front-endu jak i backendu. Ja co prawda jestem bardziej na początku tej drogi to jednak widzę jego duże możliwości wykorzystania.

  • Witam
    od kilku godzin bacznie poczytuję pańską stronę..i bardzo dziękuję za szereg rzetelnych informacji o „Angularzeniu” w języku narodowym 🙂

    Być może nie jest to odpowiednie miejsce ku temu lecz nie znalazłem odpowiedzi.. Angularze aby zrobić uniwersalną funkcję np Sumowania powinienem użyć DI ? czy jakoś inaczej do tego podejść, i kożystając z CLI, czy utworzenie „service” jest równoznaczne z utworzeniem struktury pod DI?
    „`JavaScript
    function sigma(a,z,fn) {
    var s = 0;
    for ( ; a <= z; a++) s += fn(a);
    return s;
    }
    „`

    • Właściwie wszystko, z czego korzystasz w Angularze, powinno być wstrzykiwane przez DI.
      Gdy używasz Angular CLI i generujesz service, to on również może być wstrzykiwany przez DI – trzeba go tylko zarejestrować w odpowiednim module.