Dependency Injection в Angular: советы

  • 30 сентября, 15:54
  • 3737
  • 0

Dependency Injection (DI) - одна из важнейших концепций в Angular. Это шаблон проектирования, который упрощает создание веб-приложений и ограничивает тесную связь.

Dependency Injection в Angular: советы

Что именно предусматривает DI:

  1. обмен функциональными возможностями между различными компонентами приложения;
  2. упрощение юнит-тестов;
  3. уменьшение потребности создавать экземпляры класса;
  4. облегчения понимания зависимостей определенного класса.

Кроме получения данных, механизм 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;
  // some other fields maybe
}
В этом классе мы описали нашу среду. Однако мы также можем использовать класс 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;
  // some other fields maybe
}

Так мы получили защищен механизм 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(/*handle http errors here  */);
        break;
      }

      case 'production': {
        this.http.post(this.environment.baseUrl + '/api/logs/errors', {text})
                 .subscribe(/* handle http errors here  */);
      }
    }
  }
}

Все достаточно понятно: получаем сообщения об ошибках, проверяем среду, выполняем соответствующие действия. Однако такое решение имеет определенные недостатки:

  • Мы делаем проверки каждый раз при вызове метода logError. По сути, это лишено смысла, ведь после того, как приложение збилдилось, значение enviroment.name никогда не меняется. switch-выражение всегда будет работать одинаково - не важно, сколько раз мы вызвали метод.
  • Реализация самого метода достаточно неуклюжая: на первый взгляд не очень понятно, что происходит.
  • А если нам надо логировать больше различной информации? Для этого нужно делать проверки в каждом методе?

Какие есть альтернативы в нашей реализации? Мы могли бы написать отдельные LoggerServices для каждого сценария, а инжектить только один - в зависимости от условия с помощью factory:

export class LoggerService {
  logError(text: string): void { }
  // maybe other methods like logWarning, or 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})
  }
}

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

Пройдемся тем, что мы реализовали:

  • Мы оставляем от 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], // we tell Angular to provide this dependencies to the factory as arguments
    },
     {provide: Environment, useValue: environment}
   ],
   // other metadata
})
export class AppModule { }

С таким решением не нужно что-то менять в нашем приложении: все компоненты, которые использовали LoggerService, продолжат это делать, как и с предыдущей реализацией:

export class SomeComponent implements OnInit {

  constructor(
    private logger: LoggerService,
  ) { }

  ngOnInit() {
    try {
      // do something that may throw an error
    } catch (error) {
      this.logger.logError(error.message); // no need to change anything - works the same wy as previously
    }
  }

}

Создание глобальных одиночек (singletons)

Angular убеждается, что внутри заданного модуля все компоненты получают одинаковые экземпляры зависимостей. Например, если мы используем сервис в AppModule, затем объявляем SomeCompoоnent в модуле, а затем вводим туда SomeService, а также в AnotherComponent, объявленный в том же модуле, то SomeComponent и OtherComponent получат тот же экземпляр SomeService.

Однако в различных модулях все по-разному: каждый модуль имеет собственный инжектор зависимостей и будет передавать различные экземпляры того же сервиса для различных модулей.

А если мы хотим тот же экземпляр для каждого компонента, сервиса или чего-либо, что использует наши зависимости? Тогда нам точно нужен singleton.

Мы можем реализовать singleton, использовав useFactory. Сначала нам надо будет реализовать статический метод getInstance для нашего класса, а затем вызвать его с factory.

Допустим, нам надо реализовать простое хранилище данных во время выполнения программы, вроде базового redux. Код реализации метода getInstance:

export class StoreService {

  // maybe store methods like dispatch, subscribe or others

  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}
   ],
   // other metadata
})
export class AppModule { }

На этом все. Теперь мы можем просто инжектить StoreService в любой компонент и быть уверены, что имеем дело с тем же экземпляром. Мы можем передавать зависимости в factory, как обычно.

Общие советы по DI

  1. Всегда инжектите значение в ваш компонент - никогда не полагайтесь на глобальные переменные, переменные из других файлов и тому подобное. Стоит помнить, если метод вашего класса ссылается на свойства, которые не принадлежат этому классу, такое значение, скорее всего, инжектиться как зависимость (как мы делали с переменными среды).
  2. Никогда не используйте строчные токены для DI. В Angular есть возможность передать строку в декоратор Inject для поиска зависимостей. Однако вы всегда можете допустить ошибку, даже если у вас есть IntelliSense. Лучше использовать InjectionToken.
  3. Помните, что экземпляры сервисов распространяются между компонентами на уровне модуля. Если какие-либо свойства такого сервиса не будут меняться внешне, их можно обозначить как readonly.

Теги: angular
0 комментариев
Сортировка:
Добавить комментарий