Dependency Injection
Підготовка
В наступних прикладах даного розділу припускається, що ви клонували репозиторій ditsmod/rest-starter. Це дасть вам змогу отримати базову конфігурацію для застосунку та експериментувати у теці src/app даного репозиторію.
Окрім цього, якщо ви ще не знаєте, що саме робить рефлектор і що таке "вирішення залежностей", рекомендуємо вам спочатку прочитати попередній розділ Декоратори та рефлектор.
Інжектор, токени та провайдери
В контексті Dependency Injection (DI) часто говорять про інжектори (їх також називають контейнерами), токени, сервіси та провайдери. Якщо для вас ці терміни є новими, вам мають допомогти наступні асоціації з реального життя:
- Токен — Хоча це слово у побуті часто означає жетон для метро або фішку в грі, у DI воно працює як квиток у камеру схову. Тобто в DI токен - це ідентифікатор, за яким можна знайти потрібне значення.
- Провайдер - В побутовому житті цей термін означає компанію, яка надає певний сервіс або товар. В DI же цей термін означає інструкцію (мапінг), що містить токен та відповідний сервіс чи значення. В технічному плані, найпростіше уявляти провайдер, наприклад, у вигляді такого об'єкту:
В даному разі це інструкція, яка говорить: "Коли запитують{ token: 'some-token', useValue: 'some-value' }
some-token- потрібно видатиsome-value". - Інжектор - цей термін у побуті використовується рідко, але сама його концепція у побуті досить поширена і трохи нагадує "Маркетплейс" із сервісом доставки (як Amazon у США). Спочатку провайдери передають свої товари чи сервіси у Маркетплейс, а потім споживачі звертаються до цього Маркетплейсу з токенами, щоб отримати відповідний товар чи сервіс. В технічному плані, спочатку розробники реєструють провайдери в інжекторах, а потім використовуючи токени отримують відповідні значення певного провайдера.
Давайте тепер завершимо спрощену аналогію з побутового життя, і повернемось до технічних деталей. Далі всі терміни використовуються вже у технічному сенсі.
У попередньому розділі ми побачили як в конструкторі можна вказувати залежність одного класу від іншого класу, а також як можна автоматично визначити ланцюжок залежностей за допомогою рефлектора. В контексті Dependency Injection такі класи часто називають сервіса ми. Тепер давайте познайомимось ближче з інжектором - механізмом, який дозволяє отримувати інстанси сервісів, з врахуванням їхніх залежностей. Інжектор працює дуже просто: приймає токен і повертає значення, пов'язане з цим токеном. Очевидно, що для такої функціональності потрібні інструкції між тим, що запитують в інжектора, і тим що він видає. Такі інструкції забезпечуються так званими провайдерами.
Давайте розглянемо наступний приклад, який трохи розширює останній приклад з розділу Декоратори та рефлектор:
import { Injector, injectable } from '@ditsmod/core';
class Service1 {}
@injectable()
class Service2 {
constructor(service1: Service1) {}
}
@injectable()
class Service3 {
constructor(service2: Service2) {}
}
const injector = Injector.resolveAndCreate([
{ token: Service1, useClass: Service1 },
{ token: Service2, useClass: Service2 },
{ token: Service3, useClass: Service3 }
]);
const service3 = injector.get(Service3); // інстанс Service3
service3 === injector.get(Service3); // true
Як бачите, метод Injector.resolveAndCreate() на вході приймає масив провайдерів, а на виході видає інжектор, який вміє створювати інстанс кожного переданого класу за допомогою методу injector.get(), з урахуванням усього ланцюжка залежностей (Service3 -> Service2 -> Service1).
Отже, які задачі стоять перед інжектором, і що робить його метод injector.get():
- Під час створення інжектора, йому передається масив провайдерів - тобто масив інструкцій між тим, що у нього запитують (токеном), та тим, що він повинен видавати (значенням). Цей етап є дуже важливим для подальшого функціонування інжектора. Якщо ви не передасте усіх необхідних провайдерів, інжектор не матиме відповідних інструкцій, коли ви будете запитувати певний токен.
- Після створення інжектора, коли у нього запитують токен
Service3, він проглядає масив провайдерів і бачить там інструкцію{ token: Service3, useClass: Service3 }, тому він "розуміє", що для токенаService3треба видавати інстанс класуService3. - Потім він проглядає конструктор класу
Service3, бачить залежність відService2. - Далі інжектор проглядає свій масив провайдерів і бачить там інструкцію
{ token: Service2, useClass: Service2 }, тому він "розуміє", що для токенаService2треба видавати інстанс класуService2. - Потім проглядає конструктор у
Service2, бачить залежність відService1. - Далі інжектор проглядає масив провайдерів і бачить там інструкцію
{ token: Service1, useClass: Service1 }, тому він "розуміє", що для токенаService1треба видавати інстанс класуService1. - Потім проглядає конструктор у
Service1, не знаходить там залежності, і тому першим створює інстансService1. - Потім створює інстанс
Service2використовуючи інстансService1. - І останнім створює інстанс
Service3використовуючи інстансService2. - Якщо пізніше будуть запитувати повторно інстанс
Service3, методinjector.get()буде повертати раніше створений інстансService3з кешу даного інжектора.
У підсумку ми можемо констатувати, що injector.get() дійсно працює дуже просто: приймає токен Service3, і видає його значення - інстанс класу Service3. Але щоб так діяти, інжектор, по-перше, бере до уваги наданий йому масив провайдерів. По-друге, він враховує ланцюжок залежностей кожного із провайдерів.
До речі, два наступні інжектори отримують еквівалентні провайдери:
const injector1 = Injector.resolveAndCreate([
{ token: Service1, useClass: Service1 },
{ token: Service2, useClass: Service2 },
{ token: Service3, useClass: Service3 }
]);
const injector2 = Injector.resolveAndCreate([
Service1,
Service2,
Service3
]);
Давайте тепер порушимо пункт 1, і спробуємо під час створення інжектора передати йому пустий масив. В такому разі виклик injector.get() кидатиме помилку:
const injector = Injector.resolveAndCreate([]);
const service3 = injector.get(Service3); // Error: No provider for Service3!
Як і варто було очікувати, коли ми передаємо пустий масив замість масиву провайдерів, а потім запитуємо в інжектора токен Service3, інжектор кидає помилку вимагаючи провайдера для даного токена.
Щоб краще зрозуміти якими можуть бути провайдери, давайте передамо інжектору масив провайдерів в наступній формі:
import { Injector } from '@ditsmod/core';
class Service1 {}
class Service2 {}
class Service3 {}
class Service4 {}
const injector = Injector.resolveAndCreate([
{ token: Service1, useValue: 'value for Service1' },
{ token: Service2, useClass: Service2 },
{ token: Service3, useFactory: () => 'value for Service3' },
{ token: Service4, useToken: Service3 },
]);
injector.get(Service1); // value for Service1
injector.get(Service2); // інстанс Service2
injector.get(Service3); // value for Service3
injector.get(Service4); // value for Service3
Зверніть увагу, що у цьому прикладі не використовується декоратор injectable, оскільки кожен представлений тут клас не має конструктора, де б можна було вказати залежності.
Як бачите, тепер під час створення інжектора ми передали масив провайдерів чотирьох типів. Пізніше кожен із цих типів буде формально описано, але і без цього можна здогадатись, які інструкції несуть ці провайдери до інжектора:
- Якщо запитується токен
Service1, потрібно видавати текстvalue for Service1. - Якщо запитується токен
Service2, треба спочатку створити інстансService2, а потім видати його. - Якщо запитується токен
Service3, треба запустити надану функцію, яка видає текстvalue for Service3. - Якщо запитується токен
Service4, треба видати значення для токенуService3, тобто треба видати текстvalue for Service3.
Коротка та довга форми декларації залежностей у методах класу
Якщо у якості типу параметра конструктора використовується клас, його одночасно можна використовувати у якості токена:
import { injectable } from '@ditsmod/core';
class Service1 {}
@injectable()
class Service2 {
constructor(service1: Service1) {} // Коротка форма декларування залежності
}
Дуже важливо розуміти, що сам механізм використання токенів потрібний для JavaScript-runtime, тому у якості токенів не можна використовувати такі типи, які у TypeScript-коді ви оголошуєте з ключовими словами interface, type, enum, declare і т.п., бо їх не існує у JavaScript-коді. Окрім цього, токени не можна імпортувати з ключовим словом type, оскільки у JavaScript-коді такого імпорту не буде.
На відміну від класу, масив не може одночасно використовуватись і у якості TypeScript-типу, і у якості токену. З іншого боку, токен може мати зовсім нерелевантний тип даних відносно залежності, з якою він асоціюється, тому, наприклад, рядковий тип токена може асоціюватись із залежністю, що має будь-який TypeScript-тип, включаючи масиви, інтерфейси, enum і т.д.
Декларувати зележність можна у короткій або довгій формі. В останньому прикладі використовується коротка форма декларування залежності, вона має суттєві обмеження, бо таким чином можна вказати залежність лише від певного класу.
А ще існує довга форма декларування залежності за допомогою декоратора inject, вона дозволяє використовувати альтернативний токен:
import { injectable, inject } from '@ditsmod/core';
interface InterfaceOfItem {
one: string;
two: number;
}
@injectable()
export class Service1 {
constructor(@inject('some-string') private someArray: InterfaceOfItem[]) {} // Довга форма декларування залежності
// ...
}
Коли використовується inject, DI бере до уваги лише переданий в нього токен. В даному разі DI ігнорує тип змінної - InterfaceOfItem[], використовуючи в якості токена текстовий рядок some-string. Іншими словами, DI використовує some-string як ключ для пошуку відповідного значення для залежності, і для DI взагалі ніякого значення не має тип для цього параметра - тобто InterfaceOfItem[]. Таким чином, DI дає можливість розділяти токен та тип змінної, тому в конструкторі можна отримат и будь-який тип залежності, включаючи різні типи масивів чи enum.
Токеном може бути референс на клас, об'єкт чи функцію, також у якості токену можна використовувати текстові, числові значення, та символи. Для довгої форми вказання залежностей, у якості токена рекомендуємо використовувати інстанс класу InjectionToken<T>, оскільки клас InjectionToken<T> має параметризований тип T, за допомогою якого можна вказати тип даних, який асоціюється з даним токеном:
// tokens.ts
import { InjectionToken } from '@ditsmod/core';
import { InterfaceOfItem } from './types.js';
const SOME_TOKEN = new InjectionToken<InterfaceOfItem[]>('SOME_TOKEN');
// service1.ts
import { injectable, inject } from '@ditsmod/core';
import { InterfaceOfItem } from './types.js';
import { SOME_TOKEN } from './tokens.js';
@injectable()
export class Service1 {
constructor(@inject(SOME_TOKEN) private someArray: InterfaceOfItem[]) {}
// ...
}
Провайдер
Формально, тип провайдера представляє собою таку декларацію:
import { Class } from '@ditsmod/core';
type Provider = Class<any> |
{ token: any, useValue?: any, multi?: boolean } |
{ token: any, useClass: Class<any>, multi?: boolean } |
{ token?: any, useFactory: [Class<any>, Class<any>.prototype.methodName], multi?: boolean } |
{ token?: any, useFactory: (...args: any[]) => any, deps: any[], multi?: boolean } |
{ token: any, useToken: any, multi?: boolean }
*зверніть увагу, що токен для провайдера з властивістю useFactory є опціональним, оскільки DI може використати функцію чи метод вказаного класу у якості токена.
Типи провайдерів можна імпортувати з @ditsmod/core:
import { ValueProvider, ClassProvider, FactoryProvider, TokenProvider } from '@ditsmod/core';
Більш детально про кожен із цих типів:
-
ValueProvider - цей тип провайдера має властивість
useValue, в яку передається будь-яке значення, окрімundefined, і значення якого буде використано як значення даного провайдера. Приклад такого провайдера:{ token: 'token1', useValue: 'some value' } -
ClassProvider - цей тип провайдера має властивість
useClass, в яку передається клас, чий інстанс буде використано як значення цього провайдера. Приклад такого провайдера:{ token: 'token2', useClass: SomeService } -
FactoryProvider - цей тип провайдера має властивість
useFactory, і він має два підтипи:-
ClassFactoryProvider (рекомендовано, через свою кращу інкапсуляцію) передбачає, що до
useFactoryпередається tuple, де на першому місці повинен бути клас, а на другому місці - метод цього класу, який повинен повернути якесь значення для вказаного токена. Наприклад, якщо клас буде таким:import { factoryMethod } from '@ditsmod/core';export class ClassWithFactory {@factoryMethod()method1(dependecy1: Dependecy1, dependecy2: Dependecy2) {// ...return '...';}}В такому разі, провайдер потрібно передавати в наступному форматі:
{ token: 'token3', useFactory: [ClassWithFactory, ClassWithFactory.prototype.method1] }Спочатку DI створить інстанс цього класу, потім викличе його метод та отримає результат, який вже і буде значенням даного провайдера. Метод указаного класу може повертати будь-яке значення, окрім
undefined. -
FunctionFactoryProvider передбачає, що до
useFactoryможна передавати функцію, яка може мати параметри - тобто мо же мати залежність. Цю залежність необхідно додатково вручну вказувати у властивостіdepsу вигляді масиву токенів, причому порядок передачі токенів важливий:function fn(dependecy1: Dependecy1, dependecy2: Dependecy2) {// ...return 'some value';}{ token: 'token3', deps: [Dependecy1, Dependecy2], useFactory: fn }Зверніть увагу, що у властивість
depsпередаються саме токени провайдерів, і DI їх сприймає саме як токени, а не як провайдери. Тобто для цих токенів, в масив провайдерів ще треба буде передати відповідні провайдери. Також зверніть увагу, що уdepsне передаються декоратори для параметрів (наприкладoptional,skipSelfі т.д.). Якщо для вашої фабрики необхідні декоратори параметрів - вам потрібно скористатисьClassFactoryProvider.
-
-
TokenProvider - цей тип провайдера має властивість
useToken, в яку передається інший токен. Якщо ви записуєте таке:{ token: Service2, useToken: Service1 }Таким чином ви говорите DI: "Коли споживачі провайдерів запитують токен
Service2, потрібно використати значення для токенаService1". Іншими словами, ця директива робить аліасService2, який вказує наService1. Отже,TokenProviderне є самодостатнім, на відміну від інших типів провайдерів, і в кінцевому підсумку він завжди повинен вказувати на інші типи провайдерів - наTypeProvider,ValueProvider,ClassProviderчиFactoryProvider:import { Injector } from '@ditsmod/core';const injector = Injector.resolveAndCreate([{ token: 'token1', useValue: 'some value for token1' }, // <-- non TokenProvider{ token: 'token2', useToken: 'token1' },]);console.log(injector.get('token1')); // some value for token1console.log(injector.get('token2')); // some value for token1Тут під час створення інжектора передається
TokenProvider, який вказує наValueProvider, тому цей код працюватиме. Якщо ж ви цього не зробите, то DI кидатиме помилку:import { Injector } from '@ditsmod/core';const injector = Injector.resolveAndCreate([{ token: 'token1', useToken: 'token2' },]);injector.get('token1'); // Error! No provider for token2! (token1 -> token2)// ORinjector.get('token2'); // Error! No provider for token2!Це стається через те, що ви говорите DI: "Якщо у тебе будуть запитувати
token1, то використовуй значення дляtoken2", але ви не передаєте значення дляtoken2.З іншого боку, ваш
TokenProviderможе вказувати на той же тип -TokenProvider- у якості проміжного значення, але в кінцевому підсумкуTokenProviderзавжди повинен вказувати на провайдер іншого типу:import { Injector } from '@ditsmod/core';const injector = Injector.resolveAndCreate([{ token: 'token1', useValue: 'some value for token1' }, // <-- non TokenProvider{ token: 'token2', useToken: 'token1' },{ token: 'token3', useToken: 'token2' },{ token: 'token4', useToken: 'token3' },]);console.log(injector.get('token4')); // some value for token1Тобто, у провайдера з
token4є наступний ланцюжок залежностей:token4->token3->token2->token1. Саме тому, коли в інжектора запитуютьtoken4, в кінцевому підсумку він видає значення дляtoken1.
Тепер, коли ви вже ознайомились з поняттям провайдер, можна уточнити, що під залежністю розуміють залежність саме від значення провайдера. Таку залежність мають споживачі значень провайдерів або в конструкторах сервісів, або в конструкторах чи методах контролерів, або в методі get() інжекторів.
Ієрархія та інкапсуляція інжекторів
DI надає можливість створювати ще й ієрархію та інкапсуляцію інжекторів, в якій беруть участь батьківські та дочірні інжектори. Саме завдяки ієрархії та інкапсуляції - і будується структура та модульність застосунку. З іншого боку, якщо є інкапсуляція, існують правила, які треба вивчити, щоб орієнтуватись, коли один сервіс може отримати доступ до певного провайдера, а коли - ні.
Давайте розглянемо наступну ситуацію. Уявіть, що вам треба створити дефолтну конфігурацію для логера, яка буде використовуватись для усього застосунку. Але для певного модуля рівень виводу повідомлень треба підвищити, наприклад щоб робити дебаг цього конкретного модуля. Це означає, що на рівні даного модуля ви будете змінювати конфігурацію, і вам треба, щоб вона не впливала на дефолтне значення та інші модулі. Саме для цього і призначається ієрархія інжекторів. Самий вищій в ієрархії - інжектор на рівні застосунку, а від нього вже відгалуджуються інжектори для кожного модуля. Причому важливою особливістю є те, що вищій в ієрархії інжектор не має доступу до нижчих в ієрархії інжекторів. А ось нижчі в ієрархії інжектори можуть мати доступ до вищих в ієрархії інжекторів. Саме тому інжектори на рівні модуля можуть отримувати, наприклад конфігурацію логера від інжектора на рівні застосунку, якщо на рівня модуля вони не отримали змінену конфігурацію.
Давайте розглянемо наступний приклад. Для спрощення, тут взагалі не використовуються декоратори, оскільки жоден клас не має залежностей:
import { Injector } from '@ditsmod/core';
class Service1 {}
class Service2 {}
class Service3 {}
class Service4 {}
const parent = Injector.resolveAndCreate([Service1, Service2]); // Батьківський інжектор
const child = parent.resolveAndCreateChild([Service2, Service3]); // Дочірній інжектор
child.get(Service1); // інстанс Service1
parent.get(Service1); // інстанс Service1
parent.get(Service1) === child.get(Service1); // true
child.get(Service2); // інстанс Service2
parent.get(Service2); // інстанс Service2
parent.get(Service2) === child.get(Service2); // false
child.get(Service3); // інстанс Service3
parent.get(Service3); // Error: No provider for Service3!
child.get(Service4); // Error: No provider for [Service4 in injector2 >> injector1]!
parent.get(Service4); // Error: No provider for Service4!
Як бачите, при створенні дочірнього інжектора, йому не передали Service1, тому при запиті у нього значення для цього токена, він візьме його у батьківського інжектора. До речі, тут є один неочевидний, але дуже важливий момент: через метод get(), у разі потреби, дочірні інжектори можуть запитувати у батьківських інжекторів значення для певних токенів, а самостійно вони їх не створюють. Саме тому цей вираз повертає true:
parent.get(Service1) === child.get(Service1); // true
Оскільки це дуже важлива особливість в ієрархії інжекторів, давайте ще раз опишемо її: значення певного провайдера зберігається саме у тому інжекторі, в який передається відповідний провайдер. Тобто, якщо під час створення дочірнього інжектора йому не передавали провайдер з токеном Service1, то при запиті child.get(Service1) дочірній інжектор не буде створювати значення для токена Service1. Замість цього, дочірній інжектор звернеться до батьківського інжектора, куди передали провайдер з токеном Service1, тому батьківський інжектор вже зможе створити значення для цього токена. І вже після того, як інстанс Service1 створено у батьківському інжекторі, цей самий інстанс буде видаватись (з кешу) при повторному запиті або через child.get(Service1), або через parent.get(Service1).
Коли ж ми поглянемо на поведінку інжекторів при запиті у них Service2, то тут вони будуть поводитись по-іншому, оскільки під час їх створення їм обом передали провайдер Service2, тому кожен із них ст ворить свою локальну версію цього сервіса, і саме через це даний вираз повертає false:
parent.get(Service2) === child.get(Service2); // false
Коли ми запитуємо у батьківського інжектора Service3, він не може створити інстансу класу Service3 через те, що він не має зв'язку з дочірнім інжектором, в якому є Service3.
Ну і обидва інжектори не можуть видати інстансу Service4, бо їм не передали цього класу при їхньому створенні.
Ланцюжок залежностей на різних рівнях
Ланцюжок залежностей провайдерів може бути досить складним, а ієрархія інжекторів ще додає складності. Тут допоможе наступне правило: "Якщо певний провайдер залежить від іншого провайдера, то цей інший провайдер не можна передавати на нижні рівні ієрархії - у дочірні інжектори".
Давайте почнемо з простого прикладу:
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate([
{ token: Config, useValue: { one: 1, two: 2 } }
]);
const child = parent.resolveAndCreateChild([
Service,
]);
child.get(Config); // { one: 1, two: 2 }
child.get(Service); // instance of Service
parent.get(Config); // { one: 1, two: 2 }
parent.get(Service); // Error: No provider for Service!
Що ми тут бачимо:
- Провайдер з токеном
Serviceзалежить від провайдера з токеномConfig. - Є два рівні ієрархії утворені батьківським та дочірнім інжектором.
Configпередається у батьківський інжектор, аService- у дочірній. Тобто тут дотримано заборони передачі залежності (Config) на нижні рівні ієрархії. Якщо зробити навпаки - жоден інжектор не зможе створити інстансService(такий варіант показано в наступному прикладі).- Дочірній інжектор може повернути значення для обох провайдерів, оскільки він знаходиться на нижньому рівні ієрархії, і дотримано правила передачі провайдерів із залежностями на різних рівнях ієрархії (див. третій пункт цього списку).
- Батьківський інжектор може повернути значення лише для
Config, оскільки йому передали провайдер лише з цим токеном. Коли ж у нього запитують значення дляService, він кидає помилку, хоча у дочірньому інжекторі є провайдер з токеномService. Це відбувається через те, що батьківські інжектори ніколи не звертаються до дочірніх інжекторів щоб отримати від них необхідні значення провайдерів.
Тепер давайте порушимо правило, яке говорить, що залежність не можна передавати на нижні рівні ієрархії. Отже, Service у нас буде на вищому рівні (в батьківському інжекторі), а Config - на нижньому рівні (в дочірньому інжекторі):
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate([
Service,
]);
const child = parent.resolveAndCreateChild([
{ token: Config, useValue: { one: 11, two: 22 } }
]);
child.get(Config); // { one: 11, two: 22 }
child.get(Service);
// Error: No provider for [Config in injector1]!
// Resolution path: [Service in injector2 >> injector1] -> [Config in injector1]
parent.get(Service);
// Error: No provider for Config!
// Resolution path: Service -> Config
Як бачите, коли у дочірнього інжектора запитується токен Config, він видає відповідне значення, оскільки під час його створення йому передали провайдер з цим токеном.
Інша справа із Service, який залежить від Config. Під час створення дочірнього інжектора йому не передали провайдер з токеном Service, тому він не може створити інстансу Service, і тому він змушений звернутись до батьківського інжектора. В той же час, батьківський інжектор хоча і має провайдер з токеном Service, але не має доступу до дочірнього інжектора, де є Config, тому під час запиту child.get(Service) насправді кидатиме помилку саме батьківський інжектор.
Зверніть увагу на Resolution path у повідомленні помилки:
child.get(Service);
// Error: No provider for [Config in injector1]!
// Resolution path: [Service in injector2 >> injector1] -> [Config in injector1]
Resolution path починається з пошуку Service в injector2, а потім продовжується в injector1. Оскільки цю помилку спричинив вираз child.get(Service), можна догадатись, що injector2 - це автоматичне ім'я, яке Ditsmod надав дочірньому інжектору. Відповідно - injector1 - це батьківський інжектор. Пам'ятайте, що найвищій в ієрархії інжектор завжди матиме автоматичне ім'я injector1, і чим нижчий інжектор в ієрархії, тим більший номер буде в кінці його імені injectorN.
Але чи можна явно вказувати імена (чи рівні в ієрархії) інжекторів? - Так, можна, передаючи другий аргумент під час створення інжектора. Більше того, це навіть рекомендується робити завжди:
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate(
[Service],
'parentInjector'
);
const child = parent.resolveAndCreateChild(
[{ token: Config, useValue: { one: 11, two: 22 } }],
'childInjector'
);
child.get(Service);
// Error: No provider for [Config in parentInjector]!
// Resolution path: [Service in childInjector >> parentInjector] -> [Config in parentInjector]
В такому разі, Resolution path стає більш зрозумілим:
- спочатку
Serviceшукється вchildInjector, потім - уparentInjector; - і оскільки
Serviceзнайдено саме уparentInjector, його залежність -Config- теж буде шукатись уparentInjector.
Аналізуючи повідомлення помилки, можна здогадатись, що проблему можна вирішити двома способами:
- потрібно або
Serviceдодати доchildInjector, щоб він не піднімався доparentInjector; - або
Configдодати доparentInjector, щоб він міг вирішити залежність дляService.
Давайте скористаємось другим варіантом:
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate(
[
Service,
{ token: Config, useValue: { one: 1, two: 2 } }
],
'parentInjector'
);
const child = parent.resolveAndCreateChild(
[{ token: Config, useValue: { one: 11, two: 22 } }],
'childInjector'
);
Тут батьківський інжектор має обидва необхідних провайдери, щоб створити інстанс Service. А як щодо дочірнього інжектора? З якою саме версією Config буде створено інстанс Service в наступному виразі?
child.get(Service);
Корисно спочатку подумати над цим самостійно, щоб краще закріпити дану особливість в пам'яті. Подумали? Ок, логіка в дочірнього інжектора буде наступною:
- спочатку він прогляне масив своїх провайдерів і не знайде там провайдера з токеном
Service; - потім він звернеться до батьківського інжектора і отримає від нього вже готовий інстанс
Service, в якомуConfigматиме значення{ one: 1, two: 2 }.
Трохи неочікувано, правда ж? Мабуть дехто подумав, що дочірній інжектор для створення інстансу Service буде використовувати локальну версію Config (тобто { one: 11, two: 22 }). Здогадуєтесь, що можна зробити, щоб при запиті Service у дочірнього інжектора, DI вирішував його залежність з використанням локальної версії провайдера з токеном Config? - Так, при створенні дочірнього інжектора, в масиві провайдерів ми можемо передати йому також Service:
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate([
Service,
{ token: Config, useValue: { one: 1, two: 2 } }
]);
const child = parent.resolveAndCreateChild([
Service,
{ token: Config, useValue: { one: 11, two: 22 } }
]);
Все, тепер дочірній інжектор має як Service, так і Config, тому він не буде звертатись до батьківсього інжектора:
child.get(Service).config; // { one: 11, two: 22 }
Метод injector.pull()
Цей метод є сенс використовувати лише у дочірньому інжекторі, коли у нього бракує певного провайдера, який є у батьківському інжекторі, причому цей провайдер повинен залежати від іншого провайдера, який є у дочірньому інжекторі.
Наприклад, коли Service залежить від Config, причому Service є тільки у батьківському інжекторі, а Config є як у батьківському, так і у дочірньому інжекторі:
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate([
Service,
{ token: Config, useValue: { one: 1, two: 2 } }
]);
const child = parent.resolveAndCreateChild([
{ token: Config, useValue: { one: 11, two: 22 } }
]);
child.get(Service).config; // returns from parent injector: { one: 1, two: 2 }
child.pull(Service).config; // pulls Service in current injector: { one: 11, two: 22 }
Як бачите, якщо в даному випадку використовувати child.get(Service), то Service створиться з тим Config, який є у батьківському інжекторі. Якщо ж використовувати child.pull(Service), він спочатку зтягне потрібний провайдер у дочірній інжектор, а потім створить його значення в контексті дочірнього інжектора не додаючи його значення у кеш інжектора (тобто child.pull(Service) повертатиме кожен раз новий інстанс).
Але якщо запитуваний провайдер є у дочірньому інжекторі, то вираз child.pull(Service) буде працювати ідентично до виразу child.get(Service) (з додаванням значення провайдера у кеш інжектора):
import { injectable, Injector } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const parent = Injector.resolveAndCreate([]);
const child = parent.resolveAndCreateChild([
Service,
{ token: Config, useValue: { one: 11, two: 22 } }
]);
child.get(Service).config; // { one: 11, two: 22 }
Ієрархія інжекторів в застосунку Ditsmod
Пізніше в документації ви зустрічатимете наступні властивості об'єкта, які передаються через метадані модуля:
providersPerApp- провайдери на рівні застосунку;providersPerMod- провайдери на рівні модуля;providersPerRou- провайдери на рівні роута;providersPerReq- провайдери на рівні HTTP-запиту.
Використовуючи ці масиви, Ditsmod формує різні інжектори, що пов'язані між собою ієрархічним зв'язком. Таку ієрархію можна зімітувати наступним чином:
import { Injector, Provider } from '@ditsmod/core';
const providersPerApp: Provider[] = [];
const providersPerMod: Provider[] = [];
const providersPerRou: Provider[] = [];
const providersPerReq: Provider[] = [];
const injectorPerApp = Injector.resolveAndCreate(providersPerApp);
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
const injectorPerRou = injectorPerMod.resolveAndCreateChild(providersPerRou);
const injectorPerReq = injectorPerRou.resolveAndCreateChild(providersPerReq);
Під капотом, Ditsmod робить аналогічну процедуру багато разів для різних модулів, роутів та HTTP-запитів. Використовуючи цей приклад, давайте закріпимо знання про ланцюжок залежностей на різних рівнях ієрархії і нжекторів, і знову скористаємось знайомим класом Service, який залежить від Config:
import { injectable, Injector, Provider } from '@ditsmod/core';
class Config {
one: any;
two: any;
}
@injectable()
class Service {
constructor(public config: Config) {}
}
const providersPerApp: Provider[] = [];
const providersPerMod: Provider[] = [];
const providersPerRou: Provider[] = [];
const providersPerReq: Provider[] = [Service, Config];
const injectorPerApp = Injector.resolveAndCreate(providersPerApp, 'App');
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod, 'Mod');
const injectorPerRou = injectorPerMod.resolveAndCreateChild(providersPerRou, 'Rou');
const injectorPerReq = injectorPerRou.resolveAndCreateChild(providersPerReq, 'Req');
injectorPerReq.get(Service); // returns instance of Service
Цей приклад не кидає помилки, оскільки Service та Config передано в один і той же інжектор, причому саме у цього інжектора запитують Service. Якщо ми запитаємо Service чи Config з інжекторів на вищіх рівнях, то вони кидатимуть помилку, оскільки батьківські інжектори ніколи не звертаються до дочірніх інжекторів, щоб ті повертали їм будь-яке значення провайдерів. Наприклад, якщо замість injectorPerReq.get(Service) ми викличемо injectorPerRou.get(Service) або injectorPerMod.get(Service), або injectorPerApp.get(Service) - усі вони кидатимуть помилку.
Ситуація змінюється, якщо провайдер з токеном Config буде не на тому самому рівні, що і провайдер з токеном Service. Щоб не зробити помилки, згадаємо правило: "Якщо певний провайдер залежить від іншого провайдера, то цей інший провайдер не можна передавати на нижні рівні ієрархії - у дочірні інжектори". Оскільки ми маємо ланцюжок залежностей - Service -> Config - отже Config завжди повинен бути вище в ієрархії інжекторів, відносно Service. В такому разі, дочірній інжектор спочатку спробує створити Service, побачить залежність від Config, не знайде його в поточному інжекторі і звернеться в один із батьківських інжекторів.
Зверніть увагу, що у попередньому прикладі інжекторам надаються скорочені назви рівнів ієрархії:
App- рівень застосунку;Mod- рівень модуля;Rou- рівень роуту;Req- рівень запиту.
Це зроблено для кращої читабельності помилок:
injectorPerReq.get(Service);
// Error: No provider for [Config in Mod >> App]!
// Resolution path: [Service in Req >> Rou >> Mod] -> [Config in Mod >> App]
Хоча ми не бачимо усього коду, який спричинив цю помилку, але ми знаємо принаймні, що стек цієї помилки починається з виклику injectorPerReq.get(Service). Також ми бачимо, що Resolution path починається з пошуку Service на трьох рівнях ієрархії інжекторів - Req >> Rou >> Mod. Але чому пошук не продовжився на рівні App? - Можна здогадатись, що під час створення інжекторів, Service було передано саме на рівень Mod. І якщо б на цьому рівні були усі необхідні провайдери, саме на цьому рівні створювався б інстанс Service. Потім пошук переключився на Config, від якого залежить Service, і стартував цей пошук з того самого рівня, на якому було знайдено Service. Завершився пошук Config на рівні App, інжектор якого і кинув помилку про те, що не може знайти провайдера для Config.
Виходить що ця помилка була спричинена тим, що не було дотримано правила передачі залежностей на різних рівнях ієрархії інжекторів. Судячи з Resolution path, провайдер з токеном Service було передано на рівні Mod, а провайдер з токеном Config взагалі не передавався в інжектори, або передавався в дочірні інжектори на рівні Rou чи Req.
Поточний інжектор
Безпосередньо сам інжектор сервіса чи контролера вам рідко може знадобиться, але ви його можете отримати у конструкторі як і значення будь-якого іншого провайдера:
import { injectable, Injector } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private injector: Injector) {}
someMethod() {
const firstService = this.injector.get(FirstService); // Lazy loading of dependency
}
}
Майте на увазі, що ви таким чином отримуєте інжектор, що створив інстанс даного сервіса. Рівень ієрархії цього інжектора залежить тільки від того, в масив якого інжектора передали SecondService.
Мульти-провайдери
Цей вид провайдерів існує тільки у вигляді об'єкта, і він відрізняється від звичайних DI-провайдерів наявністю властивості multi: true. Такі провайдери доцільно використовувати, коли є потреба у передачі до DI зразу декількох провайдерів з однаковим токеном, щоб DI повернув таку саму кількість значень для цих провайдерів в одному масиві:
import { Injector } from '@ditsmod/core';
import { LOCAL } from './tokens.js';
const injector = Injector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);
const locals = injector.get(LOCAL); // ['uk', 'en']
По-суті, мульти-провайдери дозволяють створювати групи провайдерів, що мають спільний токен. Ця можливість зокрема використовується у @ditsmod/rest для створення групи HTTP_INTERCEPTORS.
Не допускається щоб в одному інжекторі однаковий токен мали і звичайні, і мульти-провайдери:
import { Injector } from '@ditsmod/core';
import { LOCAL } from './tokens.js';
const injector = Injector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk' },
{ token: LOCAL, useValue: 'en', multi: true },
]);
const locals = injector.get(LOCAL); // Error: Cannot mix multi providers and regular providers
Дочірні інжектори можуть повертати значення мульти-провайдерів батьківського інжектора, лише якщо при їх створенні їм не передавались провайдери з такими самими токенами:
import { InjectionToken, Injector } from '@ditsmod/core';
const LOCAL = new InjectionToken('LOCAL');
const parent = Injector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);
const child = parent.resolveAndCreateChild([]);
const locals = child.get(LOCAL); // ['uk', 'en']
Якщо ж і в дочірнього, і в батьківського інжектора є мульти-провайдери з однаковим токеном, дочірній інжектор повертатиме значення лише зі свого масиву:
import { Injector } from '@ditsmod/core';
import { LOCAL } from './tokens.js';
const parent = Injector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);
const child = parent.resolveAndCreateChild([
{ token: LOCAL, useValue: 'аа', multi: true }
]);
const locals = child.get(LOCAL); // ['аа']
Підміна мультипровайдерів
Щоб стала можливою підміна конкретного мультипровайдера, можна зробити так:
- передайте певний клас в об'єкт мультипровайдера використовуючи властивість
useToken; - потім даний клас передайте у якості
ClassProviderчиTypeProvider; - наступним в масив провайдерів потрібно передати провайдер для підміни даного класу.
import { Injector, HTTP_INTERCEPTORS } from '@ditsmod/core';
import { DefaultInterceptor } from './default.interceptor.js';
import { MyInterceptor } from './my.interceptor.js';
const injector = Injector.resolveAndCreate([
{ token: HTTP_INTERCEPTORS, useToken: DefaultInterceptor, multi: true },
DefaultInterceptor,
{ token: DefaultInterceptor, useClass: MyInterceptor }
]);
const locals = injector.get(HTTP_INTERCEPTORS); // [MyInterceptor]
Така конструкція має сенс, наприклад, якщо перші два пункти виконуються десь у зовнішньому модулі, до якого у вас немає доступу на редагування, а третій пункт виконує вже користувач поточного модуля.
Редагування значень в реєстрі DI
Під час створення інжектора, йому передається масив провайдерів, який потім перетворюється на так званий реєстр провайдерів. Схематично цей реєстр можна уявляти наступним чином:
token1 -> value15
token2 -> value100
...
Окрім цього, існує можливість редагування готових значень реєстра DI:
import { Injector } from '@ditsmod/core';
const injector = Injector.resolveAndCreate([{ token: 'token1', useValue: undefined }]);
injector.setByToken('token1', 'value1');
injector.get('token1'); // value1
Зверніть увагу, що в даному разі до реєстру спочатку передається провайдер з token1, який має значення undefined, і лише потім ми змінюємо значення для даного токена. Якщо ви спробуєте редагувати значення для токена, якого у реєстрі немає, DI кине приблизно таку помилку:
DiError: Setting value by token failed: cannot find token in register: "token1". Try adding a provider with the same token to the current injector via module or controller metadata.
У більшості випадків, редагування значень використовують інтерсептори або гарди, оскільки вони таким чином передають результат своєї роботи до реєстру:
У якості альтернативи для методу injector.setByToken(), можна використовувати еквівалентний вираз:
import { KeyRegistry } from '@ditsmod/core';
// ...
const { id } = KeyRegistry.get('token1');
injector.setById(id, 'value1');
// ...
Переваги використання методу injector.setById() в тому, що він швидший за метод injector.setByToken(), але лише при умові, якщо ви один раз отримуєте ID із KeyRegistry, а потім багато разів використовуєте injector.setById().
Декоратори optional, fromSelf та skipSelf
Ці дек оратори використовуються для управління поведінкою інжектора під час пошуку значень для певного токена.
optional
Інколи вам може знадобитись вказати опціональну (необов'язкову) залежність в конструкторі. Давайте розглянемо наступний приклад, де після властивості firstService поставлено знак питання, і таким чином вказано для TypeScript що ця властивість є опціональною:
import { injectable } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private firstService?: FirstService) {}
// ...
}
Але оскільки DI працює у JavaScript-коді, а не у TypeScript, він проігнорує цю опціональність і видасть помилку у разі відсутності провайдера з токеном FirstService. Щоб даний код працював, необхідно скористатись декоратором optional:
import { injectable, optional } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(@optional() private firstService?: FirstService) {}
// ...
}
Оскільки в JavaScript немає позначки "опціональна властивість", лише завдяки декораторам можна це вказати.
fromSelf
Декоратори fromSelf та skipSelf мають сенс у випадку, коли існує певна ієрархія інжекторів. Декоратор fromSelf використовується дуже рідко.
import { injectable, fromSelf, Injector } from '@ditsmod/core';
class Service1 {}
@injectable()
class Service2 {
constructor(@fromSelf() public service1: Service1) {}
}
const parent = Injector.resolveAndCreate([Service1, Service2]);
const child = parent.resolveAndCreateChild([Service2]);
const service2 = parent.get(Service2) as Service2;
service2.service1 instanceof Service1; // true
child.get(Service2);
// Error: No provider for Service1!
// Resolution path: Service2 -> Service1
Як бачите, Service2 залежить від Service1, причому декоратор fromSelf вказує DI: "При створенні інстансу Service1 використовувати тільки той самий інжектор, який створить інстанс Service2, а до батьківського інжектора не потрібно звертатись". Коли створюється батьківський інжектор, йому передають обидва необхідні сервіси, тому при запиті токену Service2 він успішно вирішить залежність та видасть інстанс цього класу.
А ось при створенні дочірнього інжектора, йому не передали Service1, тому при запиті токену Service2 він не зможе вирішити залежність цього сервісу. Якщо прибрати декоратор fromSelf з конструктора, то дочірній іжектор успішно вирішить залежність Service2.
skipSelf
Декоратор skipSelf використовується частіше, ніж fromSelf, але також рідко.
import { injectable, skipSelf, Injector } from '@ditsmod/core';
class Service1 {}
@injectable()
class Service2 {
constructor(@skipSelf() public service1: Service1) {}
}
const parent = Injector.resolveAndCreate([Service1, Service2]);
const child = parent.resolveAndCreateChild([Service2]);
const service2 = child.get(Service2) as Service2;
service2.service1 instanceof Service1; // true
parent.get(Service2);
// Error: No provider for Service1!
// Resolution path: Service2 -> Service1
Як бачите, Service2 залежить від Service1, причому декоратор skipSelf вказує DI: "При створенні інстансу Service1 пропустити той інжектор, який створить інстанс Service2, і зразу звертатись до батьківського інжектора". Коли створюється батьківський інжектор, йому передають обидва необхідні сервіси, але через skipSelf він не може використати значення для Service1 з власного реєстру, тому він не зможе вирішити вказану залежність.
А при створенні дочірнього інжектора, йому не передали Service1, зате він може звернутись до батьківського інжектора за ним. Тому дочірній інжектор успішно вирішить залежність Service2.