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. ServiceB i ServiceC zarejestrowane w ListComponent przysłaniają ServiceB i ServiceC 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 listy najbliższy wspólny rodzic, czyli komponent listy
serwis wspomagający edycję rekordu w tabelce komponent, 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ś unikatowego i symbolicznego.

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 [email protected]/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 i @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ć ;)

Michał Miszczyszyn

Programista z doświadczeniem w JavaScripcie po stronie klienta i serwera. Wielki fan TypeScripta.

Subscribe to Type of Web

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!