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:
1 2 3 4 |
@Component({ … }) class UserComponent { constructor(private userService: UserService) {} } |
lub użycie funkcji inject:
1 2 3 4 |
@Component({ … }) class UserComponent { private userService = inject(UserService); } |
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
1 2 3 4 5 |
const getPageParam = (): Observable<string> => inject(ActivatedRoute).queryParams.pipe( map(params => params[‘page’]), filter(pageParam => pageParam !== null) ) |
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:
1 2 3 4 5 |
class SomeService { doSomething() { console.log('do something'); } } |
Oraz klasę reprezentującą komponent, który z niego korzysta:
1 2 3 |
class Component { constructor(public service: SomeService) {} } |
Injector odpowiada za przechowywanie i zwracanie instancji zależności:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Injector { private container = new Map(); constructor(private providers: any[] = []) { this.providers.forEach(service => this.container.set(service, new service())); } get(service: any) { const serviceInstance = this.container.get(service); if (!serviceInstance) throw new Error('Service not provided'); return serviceInstance; } } |
Podczas kompilacji Angular tworzy Injector i rejestruje zależności, które następnie są przekazywane do komponentów:
1 2 3 |
const injector = new Injector([SomeService]); const component = new Component(injector.get(SomeService)); component.service.doSomething(); |
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.
1 2 3 4 5 |
@Component({ ... providers: [UserService] }) export class UserComponent {} |
- 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:
1 2 3 |
const routes: Routes = [ { path: ‘user’, component: UserComponent, providers: [ UserService ] } ] |
- Enviroment Root Injector – zawiera globalnie dostępne zależności, które oznaczone są dekoratorem @Injectable z polem providedIn ustawionym na “root” lub “platform”:
1 2 3 4 |
@Injectable({providedIn: 'root'}) export class UserService { name = 'John' } |
lub umieszczone w tablicy providers w ApplicationConfig:
1 |
bootstrapApplication(AppComponent, { providers: [UserService] }); |
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:
1 2 3 4 5 6 |
<app-my-component> <div appParentDirective> … <div appChildDirective> … </div> </div> </app-my-component> |
- 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:
1 |
userService = inject(UserService, { optional: true, skipSelf: true }); |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@Injetable() export class Logger { log(message: string) { console.log(message); } } @Injectable() export class TimeLogger extends Logger { override log(message: string) { super.log(`${(new Date()).toLocaleTimeString()}: ${message}} } } @Component({ ..., providers: [ {provide: Logger, useClass: TimeLogger} ] }) export class MyComponent { constructor(private readonly logger: Logger) { logger.log(‘Hello World’); //5:17:35 PM: Hello World } } |
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ą.
1 2 3 4 5 6 7 8 9 |
@Component({ ..., providers: [ TimeLogger, {provide: Logger, useExisting: TimeLogger} ] }) export class MyComponent { constructor(private readonly logger: Logger) { logger.log(‘Hello World’); //5:17:35 PM: Hello World } } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
@Injectable() export class SecretMessageService { constructor( private readonly logger: Logger, private readonly isAuthorized: boolean ) {} private secretMessage = ‘My secret message’; getSecretMessage(): string | null { if (!this.isAuthorized) { this.logger.log(‘Authorize to get secret message!’); return null; } return this.secretMessage; } } @Component({ ..., providers: [ { provide: SecretMessageService, useFactory: (logger: Logger, authService: AuthService) => new SecretMessageService(logger, authService.isAuthorized), deps: [Logger, AuthService] } ] }) export class MyComponent { constructor(private readonly secretMessageService: SecretMessageService) { const secretMessage = this.secretMessageService.getSecretMessage() } } |
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:
1 2 3 4 5 6 7 8 9 |
{ provide: SecretMessageService, useFactory: (injector: Injector) => { const logger = injector.get(Logger); const authService = injector.get(AuthService); return new SecretMessageService(logger, authService.isAuthorized) }, deps: [Injector] } |
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:
1 2 3 4 5 6 |
{ provide: ThirdPartyService, useFactory: (appConfig: AppConfig, http: HttpClient) => appConfig.testEnv ? new ThridPartyMockService() : new ThridPartyService(http), deps: [APP_CONFIG, HttpClient] } |
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.
1 2 3 4 5 6 7 8 9 |
@Component({ ..., providers: [ {provide: APP_CONFIG, useValue: {testEnv: !enviroment.production}} ] }) export class MyComponent { readonly showTestEnvBanner = this.appConfig.testEnv; constructor(@Inject(APP_CONFIG) private readonly appConfig: AppConfig) {} } |
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:
1 |
{ provide: ‘APP_CONFIG’, useValue: {testEnv: !enviroment.production} } |
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:
1 2 3 4 5 |
interface AppConfig { testEnv: boolean; } export const APP_CONFIG = new InjectionToken<AppConfig>(‘app config’); |
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:
1 2 3 4 |
export const APP_CONFIG = new InjectionToken<AppConfig>( ‘app config’, { providedIn: ‘root’, factory: () => ({ testEnv: !enviroment.production }) } ); |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
export const LOCALE = new InjectionToken<string>(‘locale’); @Component({ ..., providers: [ { provide: LOCALE, useValue: ‘en’ }, { provide: LOCALE, useValue: ‘pl’ } ] }) export class WithoutMultiComponent { constructor() { console.log(inject(LOCALE)); // [‘pl’] } } @Component({ …, providers: [ { provide: LOCALE, useValue: ‘en’, multi: true }, { provide: LOCALE, useValue: ‘pl’, multi: true } ] }) export class WithMultiComponent { constructor() { console.log(inject(LOCALE)); // [‘en’, ‘pl’] } } |
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:
1 2 3 4 5 6 7 8 9 10 11 |
@Compnent({ ..., providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: forwardRef(() => CustomInputComponent) } ] )} export class CustomInputComponent { ... } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class MyServiceMock { getData() { return of(...) } } describe(MyComponent, () => { beforeEach(() => { TestBed.configureTestingModule({ provide: [{ provide: MyService, useClass: MyServiceMock }] }) } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
abstract class NotificationPort { abstract notify(message: string): void; } @Injectable() class SnackbarNotificationAdapter extends NotificationPort { private readonly snackbarService = inject(SnackbarService); notify(message: string): void { this.snackbarService.open(message); } } @Injectable() class ToastNotificationAdapter extends NotificationPort { private readonly toastNotificationService = inject(ToastNotificationService); notify(message: string): void { this.toastNotificationService.push(message, Theme.INFO) } } { provide: NotificationPort, useClass: SnackbarNotificationAdapter } |
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.
Leave a Reply