Об одной из важнейшей функциональностей фреймворка Angular «Внедрение зависимостей» расскажу я, Александр Желнин, full-stack разработчик, архитектор Департамента информационных технологий Россельхозбанка и автор YouTube-канала.

Мне захотелось представить на конкретных примерах все возможные варианты регистрации зависимостей и их подключения в классах. А также поделиться трудностями, с которыми можно столкнуться, и рассказать об опыте применения «Внедрения зависимостей».

На просторах рунета о «Внедрения зависимостей» написано преступно мало, в основном затрагивается, только «Внедрение сервисов». A «Внедрение зависимостей» — это не только сервисы, но и другие сущности, о которых по какой-то причине не говорят. С помощью «Внедрение зависимостей» вы можете передавать настройки, например, URL к back-end сервису и многое другое. Так же в зависимости от типа сборки можно подменять зависимости с учётом особенностей, например, для тестирования или «параноидального» логирования.

Немного теории по теме

Начнём с такого набора принципов, который называется SOLID. Каждая буква из этого названия — это первая буква соответствующего принципа. Обратим внимание на последний принцип Dependency Inversion «Инверсия зависимостей», который говорит о том, что объект не должен создавать зависимости внутри себя, а должен получать эти зависимости, например, в конструкторе. Ниже приведу примеры без инверсии зависимостей и с инверсией зависимостей. Начнем с примера без инверсии:

class CarOptions
{
  public static read(id: number): CarOptions
  {
    return new CarOptions();
  }
}

class CarComponent
{
  public id: number = 1;
  public options?: CarOptions;

  constructor()
  {
    // Тут зависимость создаём в самом классе.
    // Более того сама зависимость может создавать зависимости и т.д.
    this.options = new CarOptions();

    // или

    this.options = CarOptions.read(this.id);
  }
}

В этом примере в классе машина создаёт зависимость на опции машины. У классического подхода без «Инверсии зависимостей» есть недостатки, например, повышается сложность такого класса, потому что класс должен знать, как и откуда получать зависимости.

Рассмотрим пример с «Инверсией зависимостей»:

class Car
{
  constructor(public carOptions: CarOptions)
  {
  }
}

 Принцип «Инверсия зависимостей» даёт ряд неоспоримых преимуществ:

  • упрощает взаимосвязи класса: класс получает зависимости в конструкторе

  • юнит-тестирование класса упрощается: тестирование происходит только класса, а не его зависимостей

К минусам при «голом» использовании принципа «Инверсия зависимостей» можно отнести то, что объекты класса могут создаваться множество раз, и писать код для передачи зависимостей — трудозатратно.

new Car(new CarOptions(/* тут аргументы*/) /*, а тут ещё зависимости, которых может быть много!!! */)

Механизм, позволивший автоматизировать процесс создания объектов с учётом зависимостей, называется Dependency Injection «Внедрения зависимостей».

Для понимания: принцип «Инверсии зависимостей» и механизм «Внедрения зависимостей» — это разные понятия и в первом случае — это паттерн, а во втором — реализация, позволяющая нам использовать этот принцип на практике.

В Angular механизм «Внедрение зависимостей» работает по принципу:

  • Во-первых, создаётся механизм регистрации зависимостей, который называется DI Container

  • Во-вторых, создание объекта переносится на фабрику классов, называемую Injector

В итоге фабрика создаёт объекты, передавая зависимости, которые получает из контейнера зависимостей.

injector.get('ключ, например тип')

В качестве зависимостей можем использовать такие типы как:

  • Сервисы (выделил в отдельный пункт, потому что сервисы часто получают другие сервисы, в качестве зависимостей и т.д.)

  • Значения

    • Скалярные

    • Объекты

    • Функции

Такое разделение условное, и служит лишь примером.

Как же регистрируется зависимости?

Для ответа на этот вопрос необходимо понять, что существуют несколько уровней иерархии для регистрации зависимостей:

Зарегистрировать зависимость можно на любом из уровней, представленном на схеме. Для понимания, каждый элемент схемы — это уровень. В итоге получаем иерархию регистраций зависимостей. При получении зависимости фабрика если не находит зависимость на текущем уровне, переходит вверх по иерархии, пока не найдёт необходимую зависимость.

 Пояснения по схеме:

Первый уровень — это уровень платформы. В своей практике я не регистрирую зависимости на этом уровне, в этом нет необходимости, но такая возможность присутствует.

Большинство зависимостей регистрируются в модулях. Общие зависимости регистрируются в корневом модуле, и если его не переименовывали, то он называется AppModule.

Перейдём непосредственно к регистрации зависимостей.

В Angular зависимости можно зарегистрировать 2-мя возможными способами: 

для модулей, компонентов и директив, регистрация осуществляется в соответствующем декораторе, в разделе providers
для модулей, компонентов и директив, регистрация осуществляется в соответствующем декораторе, в разделе providers
для @Injectable и InjectionToken есть свойство providedIn. В этом случае зависимость регистрирует себя сама.
для @Injectable и InjectionToken есть свойство providedIn. В этом случае зависимость регистрирует себя сама.

Давайте подробно рассмотрим оба способа.

Регистрация в providers

В простейшем случае регистрация зависимости, выглядит так:

providers: [
  OptionsService,
  /* Другие провайдеры */
]

В более сложном случае необходимо указывать ключ и реализацию экземпляра зависимости:

Для начала разберём какие варианты «ключей» бывают:

  • Строка

 providers: [
  // регистрация
  { 
    provide: 'API_URL',
    /* тут будет вариант подстановки зависимости */
  },
  /* Другие провайдеры */
]

Этот вариант плох тем, что строковый ключ легко могут использовать в подключаемой библиотеке, в результате чего будет конфликт. Настоятельно не рекомендую применять этот вариант на практике, чтобы потом не тратить время на поиск ошибки.

  • Класс

providers: [
  // регистрация
  {
    provide: OptionsService, 
    /* тут будет вариант подстановки зависимости */
  },
  /* Другие провайдеры */
]

Если хотим делать несколько реализаций, то в качестве ключа можем использовать класс. Но при одной реализации лучше применять упрощённую регистрацию, которая описана ниже в примере UseClass.

  • Токен  

/** Объявление токена */
export const API_URL_TOKEN = new InjectionToken<string>('API_URL'); 

// регистрация
providers: [
  {
    provide: API_URL_TOKEN, 
    /* тут будет вариант подстановки зависимости */
  },
  /* Другие провайдеры */
]

Использование токена в качестве ключа наиболее предпочтительно. Строковый ключ рекомендую всегда заменять на токен.

 

С вариантами ключей разобрались, перейдём к вариантам подстановки зависимости. Angular поддерживает такие варианты как useClass, useValue, useFactory, useExisting.

Подробно разберём каждый из этих вариантов:

  • useClass

Это самый простой вариант, который заключается в том, что для реализации указывается класс.

providers: [
  { provide: OptionsService, useClass: OptionsService },
  /* Другие провайдеры */
]

Пример ниже это упрощённая запись примера выше. И если хотите зарегистрировать сервис без вариантов, то сокращённая запись предпочтительнее.

providers: [
  OptionsService,
  /* Другие провайдеры */
]
  • useValue

В этом варианте подставляем конкретный экземпляр значения, которое может быть любым типом данных.

providers: [
  // число
  { provide: 'VALUE_NUMBER', useValue: 1  },
  // текст
  { provide: 'VALUE_STRING', useValue: 'Текстовое значение' },
  // функция
  { provide: 'VALUE2_FUNCTION', useValue: () => { return 'что-то' } },
  // объект
  { provide: 'VALUE2_OBJECT', useValue: { id: 1, name: 'имя' } },
  // массив
  { provide: 'VALUE2_ARRAY', useValue: [1, 2, 3] } },
  // и т.д.
  /* Другие провайдеры */
]

С помощь регистрации значений часто регистрируют конфигурационные значения. Например, Angular содержит файлы environment, в которых хранятся конфигурационные значения в зависимости от типа сборки, но к этим файлам нет доступа из подключаемых библиотек. В своей практике часто беру значение из environment и регистрирую это значение в контейнере, после чего к конфигурации получает доступ библиотека. В примере ключ сделал строкой исключительно в демонстрационных целях, в реальных проекта используйте токены. 

  • useFactory

Это вариант, в котором функция регистрируется как результат. Функция выполняет роль фабрики, возвращающей значение зависимости.

providers: [
    { provide: 'VALUE', useFactory: () => { return 'что-то' } }, 
    /* Другие провайдеры */
  ]

Вариант useFactory отличается от варианта useValue c функцией тем, что когда возвращается функция в useValue, потом с этой функцией необходимо работать как с функцией, а с фабрикой получаем значение, с которым и работаем, и нет повторных вызовов функции.

Для работы фабрики часто необходимо получать зависимости, поэтому предусмотрен механизм передачи зависимостей в функцию фабрики.

Хочу привести «реальный» пример, которой заключается в том, что необходимо получать настройки, например, с back-end, а потом зарегистрировать эти настройки в качестве зависимости.

/** Интерфейс конфигурации */
export interface ISettings
{
  /** URL к API для некоторого сервиса My */
  apiUrlMy: string;
} 

/** Токен конфигурации */
export const SETTINGS_TOKEN = new InjectionToken<Observable<ISettings>>('SETTINGS_TOKEN');
/** Токен для получения URL API */
export const API_URL_MY_TOKEN = new InjectionToken<Observable<string>>('API_URL_MY_TOKEN'); 

providers: [
  {
    provide: SETTINGS_TOKEN,

    useFactory: (http: HttpClient): Observable<ISettings> =>
      http
        .get<ISettings>('/assets/settings.json')
        .pipe(shareReplay()),
    deps: [HttpClient]
  },
  {
    provide: API_URL_MY_TOKEN,
    useFactory:
      (injector: Injector) =>
        injector.get(SETTINGS_TOKEN).pipe(map(s => s.apiUrlMy)),
    deps: [Injector]
  }, 
  /* Другие провайдеры */
]

В представленном примере хотелось бы обратить внимание на свойство deps, которое осуществляет передачу зависимостей в фабрику.

  • useExisting

Этот вариант наиболее непонятный для новичка. Суть useExisting заключается в том, что выбирается уже существующая зависимость.

providers: [
  { provide: 'CarService1', useClass: CarService},
  { provide: 'CarService2', useExisting: 'CarService1' }, 
  /* Другие провайдеры */
]

Сразу отвечу на первый же вопрос – почему мы не должны написать код так:

providers: [
  { provide: 'CarService1', useClass: CarService },
  { provide: 'CarService2', useClass: CarService },
  /* Другие провайдеры */
]

Этот вариант регистрация зависимости создаст нам два экземпляра CarService. Что может доставить много не удобств при отладке, т.к. сервис часто хранит состояние, в результате чего произойдёт так называемый сайд-эффект.

Расскажу почему ещё так важен useExisting

 При работе с компонентом экземпляр компонента регистрируется в «Контейнере зависимостей», таким образом легко получить доступ для родительского компонента.

/** Родительский компонент "Машина" */
@Component({
  selector: 'car-di',
  template: `
  <p>car-di works!</p>
  <wheels-di ad-car></wheels-di>`
})
export class CarComponent
{
  constructor() { }
}

/** Дочерний компонент "Колёса машины" демонстрирует через DI получаем доступ к родительскому компоненту */
@Component({
  selector: 'wheels-di',
  template: `<p>wheels works!</p>`
})
export class WheelsComponent
{
  /**
   * Конструктор, в котором получаем DI аргументы
   * @param car Родительский компонент "Машина"
   */
  constructor(public car: CarComponent) { }
} 

/** Директива демонстрирует доступ к родительским компонентам по средствам DI */
@Directive({ selector: '[ad-car]' })
export class CarDirective
{
  /**
   * Конструктор, в котором получаем DI аргументы
   * @param wheels Родительский компонент "Колёса"
   * @param car Родительский -> Родительский компонент "Колёса"
   */
  constructor(wheels: WheelsComponent, car: CarComponent)
  {
  }
}

Разработчики часто пишут универсальные директивы, которые можно использовать в разных компонентах. Поэтому необходимо получить доступ к компоненту по универсальному ключу, а дальше работать с базовым интерфейсом компонента. Для этого нужно использовать регистрацию зависимости useExisting c реализацией forwardRef

/** Общий интерфейс */
export interface IUniversal
{
  /** Марка */
  name: string;
  /** Масса */
  weight: number;
} 

/** Токен для роботы в DI */
export const UNIVERSAL_TOKEN = new InjectionToken<IUniversal>('UNIVERSAL_TOKEN'); 

/** Дочерний компонент "Колёса машины" демонстрирует регистрацию зависимости для специального токена, в качестве зависимости выступает сам компонент */
@Component({
  selector: 'wheels-universal-di',
  template: `<p>wheels works!</p>`,
  providers:[
    { provide: UNIVERSAL_TOKEN, useExisting: forwardRef(() => WheelsComponent), multi: true }
  ]
})
export class WheelsComponent implements IUniversal
{
  /** Марка, от интерфейса IUniversal */
  public name = 'no-name';
  /** Масса, от интерфейса IUniversal */
  public weight = 10; 

  /**
   * Конструктор, в котором получаем DI аргументы
   * @param car Родительский компонент "Машина"
   */
  constructor(public car: CarComponent) { }
} 

/** Получаем доступ к родительскому компоненту используя базовый интерфейс. Соответственно эта директива может работать со всеми компонентами, реализующих UNIVERSAL_TOKEN */
@Directive({ selector: '[ad-universal]' })
export class UniversalDirective
{
  /**
   * Конструктор
   * @param universal объект с базовой реализацией интер
   */
  constructor(@Inject(UNIVERSAL_TOKEN) universal: IUniversal) { }
}

forwardRef позволяет обратиться к ещё не зарегистрированной зависимости. Из примера понятно, что в коде декоратора сам компонент ещё не был зарегистрирован в контейнере зависимостей. 

Такая же реализация помогает решить задачу получения события загрузки компонента. Ссылка на видео: https://youtu.be/097plGqjP0U

Кроме того, самая часто встречающая реализация — это ngModel. Это тема для отдельной статьи.

Регистрация нескольких зависимостей с одинаковым ключом

 Так же хотелось бы обратить внимание на дополнительное свойство multi. Приведу пример, который часто может быть необходим:

{ provide: HTTP_INTERCEPTORS, useClass: HttpInterceptorService1, multi: true },

{ provide: HTTP_INTERCEPTORS, useClass: HttpInterceptorService2, multi: true }

Если не указывать свойство multi, то в результате бы работал только HttpInterceptorService2. Свойство multi даёт нам возможность, чтобы одна зависимость не переписывала другу, если ключ совпадает, а накапливала зависимости в массиве.

И если получить зависимость по ключу, то в результате будет массив зависимостей.

const interceptors = injector.get(HTTP_INTERCEPTORS);

// interceptors = [экземпляр HttpInterceptorService1, экземпляр HttpInterceptorService2]

Регистрация providedIn

Такой способ даёт возможность зависимости зарегистрировать саму себя. Зависимость может зарегистрировать себя либо в виде сервиса, помеченного декоратором @Injectable,либо при определении токена InjectionToken.

Таким способом можно зарегистрировать зависимость на уровнях:

  • 'platform'

  • 'root'

  • 'any'

  • или указать конкретный тип для регистрации, например, компонент или модуль

@Injectable({providedIn: 'root'})
export class OptionsService { } 

@Injectable({ providedIn: AppModule })
export class OptionsService { } 

@Injectable({ providedIn: 'any' })
export class OptionsService { }

'any' является особым уровнем регистрации. Этот уровень позволяет создавать отдельный экземпляр зависимости для каждого «лениво загружаемого модуля» (lazy-loaded module)

 Обязательно при регистрации токена необходимо указать фабрику

export const API_URL_MY_TOKEN = new InjectionToken<string>('API_URL_MY_TOKEN',
  {
    providedIn: 'root',
    factory: () => 'http://localhost/test:5000'
  });

Управление доступом «Внедрения зависимостей» занимаются специальные декораторы 

  • @Self()

 Этот декоратор будет брать зависимость только этого же компонента/директивы/модуля, в котором требуется получить зависимость. 

@Component({
        selector: 'car-di',
        template: ``
  // На уровне компонента
  providers: [OptionsService]
})
export class CarComponent
{
  constructor(@Self() public options: OptionsService){}
}

Если зависимость не будет зарегистрирована в этом же компоненте, то получим ошибку.

  • @Optional()

export class CarComponent
{
  constructor(@Optional() public options: OptionsService)
  {
  }
}

Если зависимость OptionsService не найдена, то options === null никаких ошибок сгенерировано не будет. Так же этот декоратор можно применять с любыми другими декораторами уровня доступа.

  • @SkipSelf()

Этот декоратор пропускает зарегистрированную зависимость у самого компонента и ищет зависимость выше по иерархии.

export class CarComponent
{
  constructor(@SkipSelf() public options: OptionsService)
  {
  }
}
  • @Host()

@Directive({ selector: '[ad-car]' })
export class CarDirective
{
  constructor(@Host() public options: OptionsService)
  {
  }
}

Специально пример с декоратором, чтобы пояснить чем отличается от @Self().

@Host() указывает, что искать нужно у родительского компонента

Хотелось бы добавить из своего опыта то, что специализированные декораторы применяются редко, сам чаще всего применяю декоратор @Optional. Но возможность иметь полный контроль добавляет плюсов в копилку Angular.

В качестве постскриптума. Механизм «Внедрения зависимостей» в Angular достаточно объёмный, но если разобраться ничего сложного в нём нет. В этой статье показаны все варианты регистрации и внедрения зависимостей, приведены примеры кода, которые надеюсь раскроют принципы «Внедрения зависимостей». Если возникли вопросы задавайте в комментариях.

Комментарии (6)


  1. Faab83
    02.11.2021 21:35
    +1

    Этот вариант регистрация зависимости создаст нам два экземпляра CarService. 

    Даже если сервис подключен как синглитон (добавлен в AapModule, или providedIn as root) ?

    Несмотря что вся инфа есть в доке, кое-какие мелочи не знал. А мелочи это важно) читается легко - спасибо за статью.


    1. Kulibin_s Автор
      02.11.2021 21:38

      Способ регистрации providedIn это отдельный пункт, он рассмотрен после регистрации в разделе провайдеров. Да тут еще есть много тонкостей которые хотелось бы показать. Буду это в роликах делать.


  1. shestakovicha
    03.11.2021 13:07

    Что-то в вашей формулировкой не так

    Dependency Inversion «Инверсия зависимостей», который говорит о том, что объект не должен создавать зависимости внутри себя, а должен получать эти зависимости, например, в конструкторе

    Википедия:

    Формулировка:

    Модули верхних уровней не должны импортировать сущности из модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.

    Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.


    1. Kulibin_s Автор
      03.11.2021 13:09

      Согласен с вами. Я не перепечатывал определение из вики. Попытался выразить своими словами, больше на практику опираясь. Часто новичку тяжело понять заумные определения, хотелось бы чтобы и ему было понятно.


      1. dopusteam
        04.11.2021 23:25
        +1

        Просто Вы перепутали инверсию зависимостей с инъекцией зависимостей, дело не в словах


        1. Kulibin_s Автор
          06.11.2021 11:04
          +1

          В статье очень подробно рассказываю. Что "механизм" внедрения зависимостей и Паттерн интверсии зависимостей это разные понятия.

          Все показал на примерах. Потому что новичкам тяжело понять, те определения из википедии, на которые ссылаетесь. Даже подготовленному специалисту трудно с первого раза вникнуть в такие "заумные" определения. Для того что бы объяснить начинающему почему Angular это классный framework, старался рассказыть понятно.