Dependency Injection (DI) — одна з найважливіших концепцій в Angular. Це шаблон проєктування, який спрощує створення веб-застосунків та обмежує сильне зв'язування.
Що саме передбачає DI:
- обмін функціональними можливостями між різними компонентами застосунку;
- спрощення юніт-тестів, оскільки деякі частини програми замокані;
- зменшення потреби створювати екземпляри класу;
- полегшення розуміння залежностей певного класу.
Окрім отримання даних, механізм Dependency Injection в Angular дозволяє зменшити зв'язаність компонентів у застосунку. У цьому матеріалі ми розберемося, як саме він це робить.
Якщо ви новачок в Angular або не знаєте про концепцію Dependency Injection, ознайомтеся з офіційною документацією.
Створення змінних середовища
Якщо ви вже мали справу з Angular, ви, імовірно, знайомі з файлами environment.ts
. З них можна отримати інформацію про середовище, в якому запущено застосунок.
Якщо застосунок запущено в середовищі розробки, ми хочемо, щоб сервіси, які відповідають за підвантаження даних у застосунку, надсилали запити на http://localhost:3000/api
. Якщо ж застосунок запущено на тимчасовому сервері для тестувальників, ми направляємо запити на https://qa.local.com/api
. Все це керується Angular під час процесу збірки з використанням різних файлів середовища.
Наприклад, у нас можуть бути два файли environment.ts
та environment.qa.ts
у теці environments
, а коли ми запускаємо команду ng build --config qa
, Angular CLI замінить файл environment.ts
на environment.qa.ts
, і застосунок буде відповідно запущено в режимі для тестувальників.
Але до чого тут Dependency Injection?
Погляньте на такий компонент:
import { Component, OnInit } from '@angular/core';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-some',
templateUrl: './some.component.html',
styleUrls: ['./some.component.css']
})
export class SomeComponent implements OnInit {
constructor() { }
ngOnInit() {
if (!environment.production) {
console.log('In development environment') {}
}
}
}
Тут ми лише імпортували файл та використали його вміст напряму.
А якщо ми хочемо заінжектити деякі інші значення для змінних середовища тестування? До того ж у нас є сервіс, який також використовує змінні середовища:
import { environment } from 'src/environments/environment';
@Injectable()
export class SomeService {
constructor(
private http: HttpClient,
) { }
getData(): Observable<any> {
return this.http.get(environment.baseUrl + '/api/method');
}
}
На перший погляд все нормально, але насправді це не найкращий спосіб використовувати змінні середовища.
Уявіть ситуацію, що наш сервіс є частиною застосунку, який складається з декількох Angular-проектів. Тож у нас є тека projects
, яка містить різні застосунки (веб-компоненти, наприклад). Чи можемо ми перевикористати цей сервіс в іншому проєкті?
Теоретично, можемо: просто імпортуємо сервіс в інший модуль Angular, використовуючи масив providers
. Однак отримуємо деякі проблеми: різні проєкти можуть виконуватись в різних середовищах. Ми не можемо просто імпортувати одне з них, але нам треба переконатися, що сервіс може бути перевикористаний якомога більшою кількістю компонентів. Тут нам на допомогу приходить DI.
Існує багато способів реалізувати потрібний нам функціонал, ми почнемо з найпростішого — Injection Tokens
.
Injection Tokens
— концепція Angular, яка дозволяє оголошувати незалежні унікальні токени, щоб інжектити значення в інші класи з декоратором Inject
. Більш детально за посиланням.
Нам потрібно просто передбачити значення для нашого середовища в модулі:
export const ENV = new InjectionToken('ENV');
@NgModule({
declarations: [
AppComponent,
SomeComponent
],
imports: [
BrowserModule
],
providers: [
{provide: ENV, useValue: environment}
],
bootstrap: [
AppComponent
]
})
export class AppModule { }
Як бачите, ми визначили значення для нашої змінної середовища за допомогою Injection Token
. Тож тепер використаймо її в нашому сервісі:
import { Injectable, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ENV } from '../app.module';
@Injectable()
export class SomeService {
constructor(
private http: HttpClient,
@Inject(ENV) private environment,
) { }
getData(): Observable<any> {
return this.http.get(this.environment.baseUrl + '');
}
}
Зверніть увагу, що ми більше не імпортуємо файл environment.ts
. Натомість ми беремо токен ENV
та дозволяємо застосунку самостійно вирішити, яке значення передати в сервіс, залежно від проєкту, де він використовується.
Але ми все ще не знайшли найкраще рішення. Ми не передбачили тип для приватного поля environment
.
Варто все ж дбати про типи, аби наш код був менш вразливим до несподіваних помилок. Angular може використовувати типізацію, щоб покращити DI. Фактично, ми можемо повністю позбавитись декоратора Inject
та InjectionToken
. Для цього ми напишемо клас-оболонку, щоб описати інтерфейс нашої змінної середовища, а вже потім використаємо її у коді. Приклад такого класу:
export class Environment {
production: boolean;
baseUrl: string;
// можливо інші поля
}
У цьому класі ми описали наше середовище. Однак ми також можемо використати клас InjectionToken
для введення змінної середовища. Просто використаємо useValue
як провайдер:
@NgModule({
declarations: [
AppComponent,
SomeComponent
],
imports: [
BrowserModule
],
providers: [
{provide: Environment, useValue: environment}
],
bootstrap: [
AppComponent
]
})
export class AppModule { }
І в нашому сервісі:
@Injectable()
export class SomeService {
constructor(
private http: HttpClient,
private environment: Environment,
) { }
getData(): Observable<any> {
return this.http.get(this.environment.baseUrl + '');
}
}
Таке рішення дає нам не просто більш чистий та звичний механізм DI, але й статичну типізацію.
Проте досі є невелика проблема. Розглянемо такий фрагмент:
@Injectable()
export class SomeService {
constructor(
private environment: Environment,
) { }
someMethod() {
this.environment.baseUrl = 'something else';
}
}
Тут ми змінюємо значення однієї зі змінних середовища. Оскільки модулі Angular переконуються, що всередині кожен компонент отримує той самий екземпляр залежності, то такий код:
export class SomeComponent implements OnInit {
constructor(
private someService: SomeService,
private environment: Environment,
) { }
ngOnInit() {
this.someService.someMethod();
console.log(this.environment.baseUrl);
}
}
... виведе рядок:

Тож ми «випадково» змінили змінну середовища. Виправити таку проблему досить просто — зробити поля нашого класу readonly
:
export class Environment {
readonly production: boolean;
readonly baseUrl: string;
// можливо інші поля
}
Так ми отримали захищений механізм DI.
Використовуємо різні сервіси залежно від середовища
Ми вже знаємо як використовувати змінні середовища через DI, але як щодо перемикання між сервісами в різних середовищах?
Уявімо ситуацію: нам необхідна деяка статистика про збої/використання нашого застосунку. Однак механізм логування відрізняється залежно від використовуваного середовища: якщо це середовище розробки, нам треба логувати лише помилку чи попередження в консоль; у випадку середовища тестування, нам необхідно викликати API, яке згрупує наші помилки в Excel-файл; в продакшені ми хотіли б мати окремий файл з логами на бекенді, тому ми викликаємо інший API. Найкращим рішенням буде реалізувати сервіс Logger
, який оброблятиме такий функціонал. Який вигляд наш сервіс матиме в коді:
@Injectable()
export class LoggerService {
constructor(
private environment: Environment,
private http: HttpClient,
) { }
logError(text: string): void {
switch (this.environment.name) {
case 'development': {
console.error(text);
break;
}
case 'qa': {
this.http.post(this.environment.baseUrl + '/api/reports', {text})
.subscribe(/* обробка http помилок тут */);
break;
}
case 'production': {
this.http.post(this.environment.baseUrl + '/api/logs/errors', {text})
.subscribe(/* обробка http помилок тут */);
}
}
}
}
Все достатньо зрозуміло: отримуємо повідомлення помилки, перевіряємо середовище, виконуємо відповідні дії. Однак таке рішення має певні недоліки:
- Ми робимо перевірки кожного разу під час виклику методу
logError
. По суті, це позбавлено сенсу, адже після того, як застосунок збілдився, значенняenviroment.name
ніколи не змінюється.switch
-вираз завжди відпрацьовуватиме однаково — не важливо, скільки разів ми викликали метод. - Реалізація самого методу досить незграбна: на перший погляд не дуже зрозуміло, що відбувається.
- А якщо нам треба логувати більше різної інформації? Для цього потрібно робити перевірки в кожному методі?
Які є альтернативи у нашої реалізації? Ми могли б написати окремі LoggerServices
для кожного сценарію, а інжектити лише один — залежно від умови за допомогою factory
:
export class LoggerService {
logError(text: string): void { }
// можливо інші методи, на зразок logWarning або info
}
@Injectable()
class DevelopLoggerService implements LoggerService {
logError(text: string) {
console.error(text);
}
}
@Injectable()
class QALoggerService implements LoggerService {
constructor(
private http: HttpClient,
private environment: Environment,
) {}
logError(text: string) {
this.http.post(this.environment.baseUrl + '/api/reports', {text})
.subscribe(/* обробка http помилок тут */);
}
}
@Injectable()
class ProdLoggerService implements LoggerService {
constructor(
private http: HttpClient,
private environment: Environment,
) {}
logError(text: string) {
this.http.post(this.environment.baseUrl + '/api/logs/errors', {text})
.subscribe(/* обробка http помилок тут */);
}
}
Пройдемося тим, що ми реалізували:
- Ми залишаємо від
LoggerService
лише оголошення його API, без реалізації. Використовуватимемо його як injection token, а також, щоб вказати інтерфейс для TS. - Створюємо окремі класи для кожного середовища, переконуємось, що реалізували
LoggerService
для кожного, щоб у нас був однаковий API. - Кожен клас має ті самі методи, однак з різною логікою. Немає потреби перевіряти середовище.
Як тепер повідомити нашим компонентам, яку версію LoggerService
ми використовуємо? Тут на допомогу приходять factories
.
factory
— чиста функція, яка отримує залежності як аргументи та повертає значення для токена. Оглянемо, як використовувати наш LoggerService
:
export function loggerFactory(environment: Environment, http: HttpClient): LoggerService {
switch (environment.name) {
case 'develop': {
return new DevelopLoggerService();
}
case 'qa': {
return new QALoggerService(http, environment);
}
case 'prod': {
return new ProdLoggerService(http, environment);
}
}
}
Така функція викликатиме один з сервісів під час виконання програми, залежно від змінної середовища. Тож наш код виконається одноразово.
Звичайно, на цьому наша реалізація не закінчується: нам досі потрібно повідомити Angular про використання factory
та передати усі необхідні залежності через масив deps
.
@NgModule({
providers: [
{
provide: LoggerService,
useFactory: loggerFactory,
deps: [HttpClient, Environment], // кажемо Angular передати залежності в factory як аргументи
},
{provide: Environment, useValue: environment}
],
// інші метадані
})
export class AppModule { }
З таким рішенням не потрібно ще щось змінювати в нашому застосунку: всі компоненти, які використовували LoggerService
, продовжать це робити, як і з попередньою реалізацією:
export class SomeComponent implements OnInit {
constructor(
private logger: LoggerService,
) { }
ngOnInit() {
try {
// робимо щось, що викликає помилку
} catch (error) {
this.logger.logError(error.message); // немає потреби щось змінювати - все працює як і раніше
}
}
}
Створення глобальних одинаків (singletons)
Angular переконується, що всередині заданого модуля всі компоненти отримують однакові екземпляри залежностей. Наприклад, якщо ми використовуємо сервіс в AppModule
, потім оголошуємо SomeCompoоnent
в модулі, а потім вводимо туди SomeService
, а також в AnotherComponent
, оголошений в тому самому модулі, то SomeComponent
та OtherComponent
отримають той самий екземпляр SomeService
.
Однак в різних модулях все по-різному: кожен модуль має власний інжектор залежностей і буде передавати різні екземпляри того самого сервісу для різних модулів.
А якщо ми хочемо той самий екземпляр для кожного компонента, сервісу або будь-чого, що використовує наші залежності? Тоді нам точно потрібен singleton.
Ми можемо реалізувати singleton, використавши useFactory
. Спершу нам треба буде реалізувати статичний метод getInstance
для нашого класу, а потім викликати його з factory
.
Припустимо, нам треба реалізувати просте сховище даних під час виконання програми, на зразок базового redux. Код реалізації методу getInstance
:
export class StoreService {
// можна зберігати методи, на зразок dispatch, subscribe або інші
private static instance: StoreService = null;
static getInstance(): StoreService {
if (!StoreService.instance) {
StoreService.instance = new StoreService();
}
return StoreService.instance;
}
}
Тепер ми повинні повідомити Angular про наш метод getInstance
export function storeFactory(): StoreService {
return StoreService.getInstance();
}
@NgModule({
providers: [
{provide: StoreService, useFactory: storeFactory}
],
// інші метадані
})
export class AppModule { }
На цьому все. Тепер ми можемо просто інжектити StoreService
в будь-який компонент та бути певними, що маємо справу з тим самим екземпляром. Ми також можемо передавати залежності в factory
, як зазвичай.
Загальні поради щодо DI
- Завжди інжектіть значення у ваш компонент — ніколи не покладайтеся на глобальні змінні, змінні з інших файлів тощо. Варто пам'ятати, якщо метод вашого класу посилається на властивості, що не належать цьому класу, таке значення, найімовірніше, інжектиться як залежність (як ми робили зі змінними середовища).
- Ніколи не використовуйте рядкові токени для DI. В Angular є можливість передати рядок у декоратор
Inject
для пошуку залежностей. Однак ви завжди можете припуститися помилки, навіть якщо у вас є IntelliSense. Краще використовуватиInjectionToken
. - Пам'ятайте, що екземпляри сервісів поширюються між компонентами принаймні на рівні модуля. Якщо будь-які властивості такого сервісу не будуть змінюватись зовні, їх можна позначити як
readonly
. - Якщо ви використовує клас, який замінятиме інший клас, переконайтеся, що ви реалізували їх подібно до нашого другого прикладу. Тобто якщо інтерфейс залежностей змінюється, треба буде змінити і сам клас.
Ще немає коментарів