Angular: полное руководство для «Внедрения зависимостей»

от автора

Об одной из важнейшей функциональностей фреймворка 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 достаточно объёмный, но если разобраться ничего сложного в нём нет. В этой статье показаны все варианты регистрации и внедрения зависимостей, приведены примеры кода, которые надеюсь раскроют принципы «Внедрения зависимостей». Если возникли вопросы задавайте в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/586874/