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:
Oraz tak zarejestrowane zależności:
AppComponent
–ServiceA, ServiceB, ServiceC
ListComponent
–ServiceB, ServiceC
ListItemComponent
–ServiceC
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:
@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 serwisu | miejsce 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 '@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
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 :)
“Są tylko dwie rzeczy trudne w informatyce - pierwsza to nazewnictwo, druga to inwalidacja cache.” Za nazwę ListHelper ktoś powinien oberwać ;) ↩