Dependency Injection в Angular: поради

15 хв. читання

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);
  }

}

... виведе рядок:

Dependency Injection в Angular: поради

Тож ми «випадково» змінили змінну середовища. Виправити таку проблему досить просто — зробити поля нашого класу 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 помилок тут */);
      }
    }
  }
}

Все достатньо зрозуміло: отримуємо повідомлення помилки, перевіряємо середовище, виконуємо відповідні дії. Однак таке рішення має певні недоліки:

  1. Ми робимо перевірки кожного разу під час виклику методу logError. По суті, це позбавлено сенсу, адже після того, як застосунок збілдився, значення enviroment.name ніколи не змінюється. switch-вираз завжди відпрацьовуватиме однаково — не важливо, скільки разів ми викликали метод.
  2. Реалізація самого методу досить незграбна: на перший погляд не дуже зрозуміло, що відбувається.
  3. А якщо нам треба логувати більше різної інформації? Для цього потрібно робити перевірки в кожному методі?

Які є альтернативи у нашої реалізації? Ми могли б написати окремі 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

  1. Завжди інжектіть значення у ваш компонент — ніколи не покладайтеся на глобальні змінні, змінні з інших файлів тощо. Варто пам'ятати, якщо метод вашого класу посилається на властивості, що не належать цьому класу, таке значення, найімовірніше, інжектиться як залежність (як ми робили зі змінними середовища).
  2. Ніколи не використовуйте рядкові токени для DI. В Angular є можливість передати рядок у декоратор Inject для пошуку залежностей. Однак ви завжди можете припуститися помилки, навіть якщо у вас є IntelliSense. Краще використовувати InjectionToken.
  3. Пам'ятайте, що екземпляри сервісів поширюються між компонентами принаймні на рівні модуля. Якщо будь-які властивості такого сервісу не будуть змінюватись зовні, їх можна позначити як readonly.
  4. Якщо ви використовує клас, який замінятиме інший клас, переконайтеся, що ви реалізували їх подібно до нашого другого прикладу. Тобто якщо інтерфейс залежностей змінюється, треба буде змінити і сам клас.
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація