Dziś nastał dzień, w którym rozwiejemy nieco wątpliwości odnośnie działania mechanizmu DI w Angular. W polskim Internecie artykułów na ten temat jest jak na lekarstwo. Większość z nich nie mówi zbyt wiele o tym czym jest wstrzykiwanie zależności, co nam ono daje i jak działa w Angular. Jako programista Java nie mogę przejść obok tego obojętnie (jak wiadomo javowcy są teoretykami i lubią wiedzieć jak coś działa). Mało kto zadaje sobie trud, aby zajrzeć głębiej, do samych podstaw. Tym właśnie dziś się zajmiemy. Uprzedzam, że poradnik pisany jest w języku polskim i wszystkie nazwy zostały przetłumaczone na ich polskie odpowiedniki.
Programowanie bez odwróconego sterowania
Przed rokiem 1998, kiedy mało kto słyszał o odwróceniu sterowania (IoC), to programista był odpowiedzialny za tworzenie obiektów (instancji klas) zależności. Jeśli kiedykolwiek programowaliście w Pascalu czy języku C, to na pewno wiecie, że aby stworzyć obiekt, który korzystał z jakiejś zależności, trzeba było za każdym razem tworzyć dwa obiekty.
Prosty przykład. Mamy aplikację, która na jednym ekranie wyświetla listę użytkowników, a na drugim profil wybranego użytkownika. Dane pobierane są z bazy danych, z którą musimy oczywiście nawiązać połączenie. Aby utworzyć ekran UsersListScreen musimy utworzyć UserService. Aby utworzyć UserService musimy utworzyć DBConnector. Pseudokod:
class DBConnector { DbConnector(String serverAddress, String dbName, String username, String password) { // inicjalizacja } // metoda do nawiązywania połączenia z bazą danych } class UserService { UserService(DbConnector dbConnector) { // inicjalizacja } List<User> getAll() { // zwraca listę użytkowników } User getUser(Long id) { // zwraca wybranego użytkownika } } class UsersListScreen { run() { DBConnector dbConnector = new DBConnector("localhost:3306", "nazwa-bazy", "login", "hasło"); UserService userService = new UserService(dbConnector); userService.getAll(); } } class UserDetailsScreen { run() { DBConnector dbConnector = new DBConnector("localhost:3306", "nazwa-bazy", "login", "hasło"); UserService userService = new UserService(dbConnector); userService.getUser(1); } }
Kiedy zmienimy parametry przekazywane do konstruktora naszej zależności – trzeba będzie to poprawić w każdym miejscu, w którym jest ona wykorzystywana. Wystarczy, że zmienimy adres serwera bazy danych i jeśli nasza aplikacja składa się z dziesięciu, bardzo rozbudowanych ekranów, to piekło mamy gotowe. Już nie mówię o przypadkach, w których potrzebujemy zależności, która potrzebuje dwóch innych zależności, a każda z nich potrzebuje jeszcze trzech innych zależności. Kiedyś programista musiał stworzyć wszystkie te obiekty ręcznie.
Dependency Injection
Nadchodzi rozwiązanie naszych problemów, czyli Dependency Injection. Jest to jeden ze sposobów implementacji IoC, w którym bezpośrednie wiązania pomiędzy komponentami zostały usunięte, a zamiast nich wprowadzono zarządcę, który zajmuje się tworzeniem obiektów zależności. Oczywiście na życzenie klasy, która tych zależności potrzebuje.
O wiele prościej napisać taki kod, prawda?
class UsersListScreen { UsersListScreen(@Inject UserService userService) {} run() { userService.getAll(); } }
Potrzebujemy UserService, aby pobrać listę wszystkich użytkowników, więc prosimy zarządcę DI o jego wstrzyknięcie. To tyle. Reszta nas nie interesuje. Zarządca ma wiedzę na temat tego jak stworzyć dla nas taką zależność, więc my nie musimy się tym teraz martwić. Oczywiście trzeba mu takiej wiedzy uprzednio dostarczyć i o tym za chwilę.
Wstrzykujemy zależności w Angular 17
Załóżmy, że mamy komponent odpowiedzialny za wyświetlanie listy użytkowników. Zostajemy przy tym samym przykładzie. Wstrzykujemy do niego UserService. W przypadku Angulara dostępne mamy wyłącznie wstrzykiwanie przez konstruktor, a więc w momencie tworzenia obiektu, który tej zależności potrzebuje.
@Component({ selector: 'app-users-list', templateUrl: './list.component.html', styleUrls: ['./list.component.css'] }) export class UsersListComponent implements OnInit { users: User[] = []; constructor(private service: UserService) {} ngOnInit() { this.service.getList().subscribe((data: User[]) => { this.users = data; }); } }
Co się stanie po uruchomieniu takiej aplikacji? Wstrzykiwacz obsługujący nasz komponent będzie szukał dostawcy odpowiedzialnego za dostarczenie gotowej zależności dla wybranego tokenu, ale go nie znajdzie (No provider for UserService!) Musimy się zatem teraz dowiedzieć czym dokładnie jest token, czym dostawca (provider), a czym wstrzykiwacz (injector), aby zmodyfikować odpowiednio nasz kod i sprawić, że wstrzykiwanie zależności zadziała.
Tokeny
Token jest identyfikatorem, który jednoznacznie wskazuje na daną zależność, przechowywaną przez wstrzykiwacz. Może być zarówno klasą jak i dowolnie wybranym ciągiem znaków. W powyższym przykładzie tokenem był UserService. W przypadku klasy sytuacja jest prosta – plik znajduje się w wybranej przez nas lokalizacji wewnątrz projektu i jest jednoznacznie identyfikowalny. Jeżeli chodzi o ciąg znaków, to tutaj pojawia się mały problem.
W naszych aplikacjach bardzo często korzystamy z zewnętrznych bibliotek, a wewnątrz nich są zdefiniowane przeróżne tokeny. Może zdarzyć się sytuacja, w której chcemy użyć jakiegoś ciągu znaku dla naszego tokenu, ale ta nazwa jest już zajęta (wykorzystał ją inny programista). Aby uniknąć takich komplikacji, zespół developerów Angulara wymyślił OpaqueToken, który został zastąpiony w Angular 4 przez InjectionToken. Zasada działania jest podobna. String jest opakowany w obiekt, a jak wiadomo żadne dwa obiekty nie są sobie równe, nawet gdy mają takie same wartości wszystkich właściwości. Są umieszczone w innym bloku pamięci i to je rozróżnia – chyba, że jeden z nich przechowuje referencję do drugiego.
InjectionToken w odróżnieniu od wycofanego OpaqueToken jest typem generycznym i może opakowywać nie tylko ciągi znaków, ale także tablice, interfejsy. Więcej o nim w oficjalnej dokumentacji. Teraz skupimy się na sposobie jego wykorzystania.
Tworzymy obiekt przechowujący ustawienia aplikacji i opakowujemy go w InjectionToken – app.config.ts
import { InjectionToken } from '@angular/core'; export interface AppConfig { apiEndpoint: string; title: string; } export const APP_CONFIG = new InjectionToken<AppConfig>('app.config'); export const HERO_CONFIG: AppConfig = { apiEndpoint: 'api.ithero.pl', title: 'Dependency Injection' };
Tworzymy dostawcę w app.module.ts
providers: [ { provide: APP_CONFIG, useValue: HERO_CONFIG } ]
Oraz komponent, do którego chcemy wstrzyknąć ustawienia aplikacji – user.component.ts
@Component({ selector: 'app-user', template: '' }) export class UserComponent { constructor(@Inject(APP_CONFIG) config: AppConfig) {} }
Pokazaliśmy właśnie wszystkie kroki wymagane do korzystania ze wstrzykiwania zależności w Angular. Wiemy już jak zarejestrować prostego dostawcę. Dowiedzmy się o nich więcej.
Dostawcy (providers)
Zadaniem dostawcy jest dostarczenie wstrzykiwaczowi informacji o tym w jaki sposób ma tworzyć instancje zależności. Jest to możliwe dzięki powiązaniu tokenu z wybraną klasą, wartością, aliasem, bądź też fabryką klas.
Sposoby wiązania tokenu:
a) z klasą – useClass
Najprostszy i zarazem domyślny typ wiązania.
providers: [ { provide: UserService, useClass: UserService } ]
provide – token, który chcemy dostarczać
useClass – wskazuje na klasę, której obiekt ma utworzyć wstrzykiwacz
Możemy to zapisać w skróconej formie, ponieważ jak już wspomniałem, jest to domyślny sposób wiązania:
providers: [ UserService ]
b) z wartością – useValue
Czasem istnieje konieczność powiązania tokenu ze statyczną wartością. Angular daje nam taką możliwość. Korzystamy z niej, kiedy chcemy utworzyć zmienną globalną np. przechowującą konfigurację. Pamiętajmy o korzystaniu z InjectionToken.
providers: [ { provide: IS_PROD_ENVIRONMENT, useValue: environment.production ]
c) z fabryką – useFactory
To rozwiązanie przydaje się kiedy typ dostarczanej usługi chcemy uzależnić od jakichś warunków. Weźmy przykład z życia. Nasza aplikacja posiada ekran do tworzenia nowych użytkowników. W środowisku produkcyjnym wysyła zapytanie do API, przekazując w nim dane z formularza. Możemy spotkać się z sytuacją, w której API jeszcze nie istnieje, albo nie chcemy z niego korzystać np. podczas uruchamiania testów. Jeśli chcemy przetestować dodawanie nowego użytkownika, to przecież nie będziemy przy każdym uruchomieniu testu wrzucać do bazy danych przykładowych danych i tym samym ją zaśmiecać. Od takich rzeczy są Mocki i właśnie taką atrapę usługi UserService stworzymy. Tutaj z pomocą przychodzi nam wiązanie za pośrednictwem fabryki.
Możemy przekazać do naszej fabryki jako argument wartość zmiennej globalnej, która przyjmuje wartość logiczną true, gdy aplikacja jest uruchamiana w środowisku produkcyjnym. Utworzyliśmy dla niej dostawcę w punkcie dotyczącym useValue. W przypadku budowania wersji produkcyjnej (ng build –prod) wstrzykiwany będzie obiekt innego typu, niż podczas budowania wersji testowej.
providers: [ { provide: UserService, useFactory: userServiceFactory, deps: [IS_PROD_ENVIRONMENT] } ]
useFactory – wskazuje na funkcję fabryki klas
deps – tablica zależności, które chcemy wstrzyknąć do funkcji userServiceFactory. Podajemy tutaj tokeny.
Tak wygląda przykładowa implementacja fabryki:
export function userServiceFactory(isProd: boolean) { if(isProd) { return new UserService(); } return new MockUserService(); }
Funkcja ta przyjmuje argument isProd, na podstawie którego podejmowana jest decyzja o rodzaju tworzonej usługi. Argumenty funkcji podajemy w tej samej kolejności, w której wpisaliśmy je do tablicy deps.
d) z aliasem – useExisting
Zdarza się tak, że korzystamy z jakiejś zewnętrznej biblioteki np. do generowania wykresów i nie spodoba nam się drobny szczegół, którego nie możemy zmienić. Co wtedy robi większość programistów? Szuka nowej biblioteki, która spełni ich oczekiwania albo zakasuje rękawy i bierze sprawy w swoje ręce. Hola hola! Dostawcy mogą korzystać z opcji useExisting, która pozwala nam nadpisywać implementację zależności. Od dziś już nie musisz edytować plików znajdujących się w katalogu node_modules (widziałem takie kwiatki i odradzam tego typu działania), ani też robić sobie pod górkę.
providers :[ { provide: PieChartGeneratorService, useExisting: NewPieChartGeneratorService }, { provide: NewPieChartGeneratorService, useClass: NewPieChartGeneratorService } ]
Sposoby na zarejestrowanie dostawcy:
a) w komponencie
@Component({ selector: 'app-users-list', templateUrl: './list.component.html', styleUrls: ['./list.component.css'], providers: [ UserService ] }) export class UsersListComponent {}
Zaletą tego rozwiązania jest z pewnością to, że nowa instancja naszej zależności jest tworzona za każdym razem, gdy tworzony jest nasz komponent. Jest to jedyna możliwość na stworzenie w Angularze zależności, która nie jest singletonem (istnieje więcej instancji niż jedna, którą współdzielą wszystkie komponenty).
b) w module funkcyjnym
@NgModule({ imports: [ RouterModule.forChild(UsersRoutes) ], declarations: [ UsersListComponent ], providers: [ UserService ] }) export class UsersModule {}
Doskonały wybór, kiedy chcemy, aby do naszej usługi zapewniony był dostęp jedynie z poziomu komponentów, które należą do tego modułu. Ma to miejsce wyłącznie, kiedy moduł jest ładowany leniwie (lazy loading). O tym w dalszej części poradnika.
c) w głównym module aplikacji
@NgModule({ imports: [ RouterModule, AppRoutingModule, UsersModule ], providers: [ UserService ], bootstrap: [AppComponent] }) export class AppModule {}
Jeśli nasza zależność ma być dostępna z każdego miejsca w aplikacji, to odpowiednim miejscem do jej umieszczenia jest app.module. Oczywiście nie jest to jedyne rozwiązanie. Możemy bowiem korzystać z modułów funkcyjnych ładowanych chętnie (eager loading). O tym za chwilę.
Hierarchia dostawców
Potrzebujemy w naszym komponencie zależności o tokenie UserService. Skąd Angular wie gdzie jej szukać? W pierwszej kolejności skieruje się do naszego komponentu w poszukiwaniu dostawcy odpowiedzialnego za dostarczenie zależności o podanym tokenie. Jeśli go tam nie znajdzie, to następnie zajrzy do modułu funkcyjnego, do którego należy nasz komponent. Dalszym krokiem jest szukanie w modułach nadrzędnych, a na samym końcu w głównym module aplikacji. Jeśli nie znajdzie dostawcy w żadnym z podanych miejsc – wtedy zgłosi błąd, który mieliśmy przyjemność już poznać.
Czy to problem jeśli zarejestrujemy dostawcę danej zależności zarówno w module funkcyjnym jak i w module nadrzędnym? Czy Angular zgłosi błąd? Oczywiście nie. Angular pozwala na nadpisywanie dostawców. Dostawcę zarejestrowanego wyżej w hierarchii dostawców można nadpisać na jej niższych poziomach. Możemy np. zarejestrować dostawcę UserService z implementacją UserService dla całej aplikacji, a dla wybranego przez nas modułu skorzystać z innej implementacji np. UserService2.
Wstrzykiwacze (injectors)
W Angularze zarządców wstrzykiwania zależności może być wielu. Nazywamy ich wstrzykiwaczami. Przechowują oni instancje klas, które chcemy wstrzykiwać. Jest bowiem tak, że po utworzeniu jednej instancji UsersService może nie być potrzeby tworzenia kolejnej. Jeśli w przyszłości aplikacja poprosi o dostarczenie tej samej zależności w innym miejscu, to wstrzykiwacz użyje tej instancji, która już wcześniej została utworzona – chyba, że dostawca wskaże inaczej. Zawsze istnieje co najmniej jeden – globalny wstrzykiwacz (global injector), który jest powiązany z głównym modułem aplikacji. Skąd biorą się inni wstrzykiwacze i czemu są potrzebni?
Dobrze wiemy, że Angular ma budowę modułową. Każdy moduł ma przypisany wstrzykiwacz.
Moduł funkcyjny może być ładowany leniwie, czyli w momencie, w którym jest nam potrzebny (tzw. lazy loading) albo chętnie, czyli przy starcie aplikacji (eager loading). W przypadku modułów ładowanych leniwie, framework tworzy dla nich odseparowane wstrzykiwacze (nie uda nam się wykorzystać zarejestrowanych w nich dostawców poza naszym modułem). Jest to logiczne. Jeśli nasza zależność byłaby wykorzystywana w innym miejscu aplikacji, to przecież ten moduł musiałby być od razu dostępny, a zależy nam na leniwym ładowaniu, aby nasza aplikacja była szybciej pobierana przez przeglądarkę użytkownika. Moduły ładowane w trybie eager korzystają ze wstrzykiwacza globalnego.
Sposoby wstrzykiwania zależności
Mamy wiele dekoratorów (adnotacji), którymi możemy oznaczyć naszą zależność, aby poinformować wstrzykiwacz jak ma przebiegać wstrzykiwanie, gdzie ma szukać dostawcy. Będziemy pracować na przykładowym komponencie (@Component), ale wstrzykiwanie zależności działa także w usługach (@Injectable), dyrektywach (@Directive), potokach(@Pipe), itd.
Wstrzykiwanie niejawne
Kiedy tokenem naszej zależności jest klasa, to jest ona wstrzykiwana bez konieczności stosowania dodatkowego dekoratora.
@Component() export class UsersListComponent { constructor(private service: UserService) {} }
@Inject()
Dekorator obowiązkowy, kiedy planujemy wstrzyknąć zależność, dla której stworzyliśmy niestandardowego dostawcę.
@Component() export class UsersListComponent { constructor(@Inject(MY_DEPENDENCY_TOKEN) private myDependency: string) {} }
@Optional()
Sprawia, że zależność nie jest wymagana. W przypadku braku dostawcy zostanie wstrzyknięta wartość null.
@Component() export class UsersListComponent { constructor(@Optional() private service: UserService) {} }
@Host()
Sprawia, że dostawca zależności jest szukany w komponencie, który zawiera nasz komponent. Jeśli mamy komponent nadrzędny (parent), który zawiera elementy podrzędne (child), to po zastosowaniu adnotacji @Host w konstruktorze komponentu podrzędnego, dostawca będzie szukany w rodzicu.
Rodzic:
@Component({ selector: 'app-users-list', template: ' <app-user>1</app-user> <app-user>2</app-user> <app-user>3</app-user> ', providers: [UserService] }) export class UsersListComponent { constructor() {} }
Dziecko:
@Component({ selector: 'app-user', template: '' }) export class UserComponent { constructor(@Host() private userService: UserService) {} }
@Self()
Ogranicza szukanie dostawcy do komponentu, który potrzebuje zależności.
Zadziała:
@Component({ selector: 'app-user', template: ' ', providers: [UserService] }) export class UserComponent { constructor(@Self() private userService: UserService) {} }
Zwróci błąd:
@Component({ selector: 'app-user', template: ' ', providers: [] }) export class UserComponent { constructor(@Self() private userService: UserService) {} }
@SkipSelf()
Szuka dostawcy w całej hierarchii DI, nie uwzględniając dostawców zarejestrowanych w naszym komponencie.
Dekoratory można ze sobą łączyć!
@Component() export class UserComponent { constructor( @Self() @Optional() private userService: UserService ) {} }
Często pojawiające się pytania
Czy Angular DI tworzy tylko singletony?
Nie jest to prawdą. Rejestrując dostawcę w komponencie mamy pewność, że z każdym stworzeniem tego komponentu powstanie nowa instancja naszej zależności. Stwórz UserService posiadający właściwość user zainicjowaną w konstruktorze, z atrybutem loginTime generowanym losowo, oraz metodę getUser() zwracającą wartość tej właściwości, czyli obiekt użytkownika. Następnie stwórz komponent UserDetailsComponent, który będzie wielokrotnie wykorzystywany w komponencie nadrzędnym UserListComponent. Jego zadaniem będzie wyświetlanie godziny ostatniego logowania użytkownika. Wiesz co się stanie? Na liście zobaczymy użytkowników z różnymi czasami logowania, ponieważ UserService będzie tworzony za każdym razem, gdy nowy komponent UserDetailsComponent będzie go potrzebował. Przenieś definicję dostawcy do modułu i zobacz co wtedy się stanie. Każdy użytkownik będzie miał ten sam czas logowania, ponieważ powstanie jedna instancja, wstrzykiwana do każdego z komponentów UserDetailsComponent.
Czy aby wstrzykiwać usługę trzeba ją oznaczyć adnotacją @Injectable?
Mit. Możemy zdefiniować usługę jako zwykłą funkcję i taką funkcję przekazać do useClass. Jedyne ograniczenie jakie z tego wynika, to brak możliwości wstrzykiwania zależności do naszej usługi.
Dostawcy zarejestrowani w module funkcyjnym mogą być wstrzykiwani tylko do komponentów i usług, które wchodzą w skład tego modułu?
W przypadku modułów funkcyjnych ładowanych chętnie (eager loading) ich dostawcy są widoczni z poziomu całej aplikacji, a zależności, na które wskazują, są wstrzykiwane przez wstrzykiwacz globalny. Jeśli zależy Ci na izolacji, to skorzystaj z modułu ładowanego leniwie.
Podsumowanie
Mechanizm DI w Angular nie jest taki prosty na jaki może z pozoru wyglądać. Mam nadzieję, że od dziś nie będziesz umieszczać dostawców gdzie popadnie, ale zrobisz to z głową. To istotne czy powstanie jedna instancja usługi, do której dostęp będzie miało wiele komponentów, czy może każdy z nich będzie miał swoją własną instancję tej zależności. Miejmy też na uwadze jedną z podstaw programowania obiektowego – enkapsulację. Jeśli nasza zależność nie będzie używana poza modułem funkcyjnym, to postarajmy się, aby był on modułem ładowanym leniwie.
Uwagi od naszych czytelników
- Dekorator @Injectable() posiada opcjonalny atrybut provideIn, który pozwala ustawić dostępność naszej usługi (https://angular.io/api/core/Injectable#providedin) – pan_cziken
One reply on “Dependency Injection w Angular – DI”
[…] działaniu mechanizmu DI w Angular poczytasz w naszym obszernym artykule na ten […]