Об одной из важнейшей функциональностей фреймворка 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: [
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)
shestakovicha
03.11.2021 13:07Что-то в вашей формулировкой не так
Dependency Inversion «Инверсия зависимостей», который говорит о том, что объект не должен создавать зависимости внутри себя, а должен получать эти зависимости, например, в конструкторе
Формулировка:
Модули верхних уровней не должны импортировать сущности из модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Kulibin_s Автор
03.11.2021 13:09Согласен с вами. Я не перепечатывал определение из вики. Попытался выразить своими словами, больше на практику опираясь. Часто новичку тяжело понять заумные определения, хотелось бы чтобы и ему было понятно.
dopusteam
04.11.2021 23:25+1Просто Вы перепутали инверсию зависимостей с инъекцией зависимостей, дело не в словах
Kulibin_s Автор
06.11.2021 11:04+1В статье очень подробно рассказываю. Что "механизм" внедрения зависимостей и Паттерн интверсии зависимостей это разные понятия.
Все показал на примерах. Потому что новичкам тяжело понять, те определения из википедии, на которые ссылаетесь. Даже подготовленному специалисту трудно с первого раза вникнуть в такие "заумные" определения. Для того что бы объяснить начинающему почему Angular это классный framework, старался рассказыть понятно.
Faab83
Даже если сервис подключен как синглитон (добавлен в AapModule, или providedIn as root) ?
Несмотря что вся инфа есть в доке, кое-какие мелочи не знал. А мелочи это важно) читается легко - спасибо за статью.
Kulibin_s Автор
Способ регистрации providedIn это отдельный пункт, он рассмотрен после регистрации в разделе провайдеров. Да тут еще есть много тонкостей которые хотелось бы показать. Буду это в роликах делать.