Dependency Injection
Підготовка
В наступних прикладах даного розділу припускається, що ви клонували репозиторій ditsmod/rest-starter. Це дасть вам змогу отримати базову конфігурацію для застосунку та експериментувати у теці src/app даного репозиторію.
Окрім цього, якщо ви ще не знаєте, що саме робить рефлектор і що таке "вирішення залежностей", рекомендуємо вам спочатку прочитати попередній розділ Декоратори та рефлектор.
Інжектор, токени та провайдери
У попередньому розділі ми побачили як в конструкторі можна вказувати залежність одного класу від іншого класу, а також як можна автоматично визначити ланцюжок залежностей за допомогою рефлектора. Тепер давайте познайомимось з інжектором - механізмом який дозволяє отримувати інстанси класів, з врахуванням їхніх залежностей. Інжектор працює дуже просто: приймає токен, і видає значення для цього токена. Очевидно, що для такої функціональності потрібні інструкції між тим, що запитують в інжектора, і тим що він видає. Такі інструкції забезпечуються так званими провайдерами.
Давайте розглянемо наступний приклад, який трохи розширює останній приклад з розділу Декоратори та рефлектор:
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. Але щоб так діяти, інжектор, по-перше, бере до уваги наданий йому масив провайдерів. По-друге, він враховує ланцюжок залежностей кожного із провайдерів.
Давайте тепер порушимо пункт 1, і спробуємо під час створення інжектора передати йому пустий масив. В такому разі виклик injector.get() кидатиме помилку:
const injector = Injector.resolveAndCreate([]);
const service3 = injector.get(Service3); // Error: No provider for 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
]);
Щоб краще зрозуміти якими можуть бути провайдери, давайте передамо інжектору масив провайдерів в наступній формі:
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(service1: Service1, service2: Service2) {
// ...
return 'some value';
}
{ token: 'token3', deps: [Service1, Service2], 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 token1
console.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)
// OR
injector.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!
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, бо їм не передали цього класу при їхньому створенні.
Ланцюжок залежностей на різних рівнях
Ланцюжок залежностей провайдерів може бути досить складним, а ієрархія інжекторів ще додає складності. Давайте розглянемо простий випадок, а потім ускладнемо його. Отже в наступному прикладі 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([]);
parent.get(Service); // Instance of Service
child.get(Service); // Instance of Service
Як бачите, в даному прикладі створюються батьківський та дочірній інжектори, причому як Service, так і його залежність - Config - передаються лише в батьківський інжектор. В такому разі, коли у батьківського інжектора запитується значення провайдера з токеном Service, цей інжектор буде діяти за такою логікою:
- спочатку він прогляне масив своїх провайдерів і знайде там
Service, тому він "знатиме", що коли у нього запитуютьService, треба видавати інстанс цього класу; - потім він прогляне список залежностей у
Serviceі знайде тамConfig; - потім він прогляне масив своїх провайдерів і знайде там
{ token: Config, useValue: { one: 1, two: 2 } }, тому він "знатиме", що коли у нього запитуютьConfig, він повинен видати{ one: 1, two: 2 }; - потім він прогляне список залежностей у провайдера з токеном
Configі не знайде там залежностей; - тому для створення інстансу
Serviceбуде використовуватись значення{ one: 1, two: 2 }. Зверніть увагу, що під час створення інстансуServiceне буде створюватись інстансConfig, а буде використано готовий об'єкт{ one: 1, two: 2 }, бо такі інструкції передались у масиві провайдерів під час створення батьківського інжектора.
Коли ж дочірній інжектор створюють з пустим масивом провайдерів, за будь-яким запитом він буде звертатись до батьківсього інжектора:
- спочатку дочірній інжектор прогляне масив своїх провайдерів і не знайде там жодних інструкцій;
- потім дочірній інжектор звернеться до батьківського інжектора і отримає від нього вже готовий інстанс
Service.
У випадку, коли ми Service передаємо в один інжектор, а Config - в інший, тут вже важливо враховувати залежність між ними, тому працювати така схема буде лише у випадку, коли Config передається в батьківський інжектор, а Service - в дочірній. Причому запитувати Service можна лише у дочірнього інжектора:
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 для нього недоступний, оскільки батьківський інжектор не бачить дочірнього інжектора, де є цей провайдер.
Тепер давайте передамо Service у батьківський інжектор, а 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,
]);
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. Не зважаючи на те, що у дочірній інжектор передали провайдер з токеном Config, цей інжектор все одно не може створити інстанс 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[] = [Service];
const providersPerMod: Provider[] = [];
const providersPerRou: Provider[] = [];
const providersPerReq: Provider[] = [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);
// Error: No provider for [Config in App]!
// Resolution path: [Service in Req >> Rou >> Mod >> App] -> [Config in App]
Як бачите, тут інжекторам надаються скорочені назви рівнів ієрархії:
App- рівень застосунку;Mod- рівень модуля;Rou- рівень роуту;Req- рівень запиту.
Помилку викликав вираз injectorPerReq.get(Service), і як каже нам Resolution path у цій помилці, Service шукався на усіх рівнях ієрархії інжекторів - починаючи від Req, і закінчуючи App. Потім пош ук переключився на Config, який шукався лише на рівні App.
Якщо Service передавати на рівні модуля:
const providersPerApp: Provider[] = [];
const providersPerMod: Provider[] = [Service];
const providersPerRou: Provider[] = [];
const providersPerReq: Provider[] = [Config];
В такому разі повідомлення помил ки буде таким:
injectorPerReq.get(Service);
// Error: No provider for [Config in Mod >> App]!
// Resolution path: [Service in Req >> Rou >> Mod] -> [Config in Mod >> App]
Як каже нам Resolution path у цій помилці, Service шукався на трьох рівнях ієрархії інжекторів - починаючи від Req, і закінчуючи Mod. Але чому пошук не продовжився на рівні App? - Тому що під час створення інжекторів, Service було передано саме на рівень Mod. І якщо б на цьому рівні були усі необхідні провайдери, саме на цьому рівні створювався б інстанс Service. Потім пошук переключився на Config, від якого залежить Service, і стартував цей пошук з того самого рівня, на якому було знайдено Service. Завершився пошук Config на рівні App, інжектор якого і кинув помилку про те, що не може знайти провайдера для Config.
Аналізуючи Resolution path, черговий раз підтвердилось, що пошук провайдерів завжди відбувається від нижніх до вищіх рівнів ієрархії інжекторів, і ніколи навпаки.
Мабуть ви здогадуєтесь, що буде, коли Service передавати на рівні роуту:
const providersPerApp: Provider[] = [];
const providersPerMod: Provider[] = [];
const providersPerRou: Provider[] = [Service];
const providersPerReq: Provider[] = [Config];
Так, даний вираз все ще кидає помилку:
injectorPerReq.get(Service);
// Error: No provider for [Config in Rou >> Mod >> App]!
// Resolution path: [Service in Req >> Rou] -> [Config in Rou >> Mod >> App]
Як каже нам Resolution path у цій помилці, Service шукався на двох рівнях ієрархії інжекторів - на Req та Rou рівні. А провайдери для Config шукались вже на трьох верхніх рівнях - від Rou до App.
І нарешті, коли Service передавати на тому самому рівні, що і Config, то вже такий вираз не кидатиме помилок:
const providersPerApp: Provider[] = [];
const providersPerMod: Provider[] = [];
const providersPerRou: Provider[] = [];
const providersPerReq: Provider[] = [Service, Config];
Також не буде помилок, коли Config передавати на вищому рівні, ніж той, на який передали Service. Вираз injectorPerReq.get(Service) нарешті починає працювати, оскільки Service зразу знаходиться, а Config знаходиться на тому самому рівні, або вище.
Під час передачі провайдерів для створення інжекторів, потрібно завжди пам'ятати, що усі провайдери, від яких залежить певний сервіс, повинні бути або на тому самому рівні, що і даний сервіс, або на вищіх рівнях, оскільки пошук відповідних провайдерів буде відбуватись завжди від рівня даного сервісу і вище. Іншими словами, провайдери, від яких залежить певний сервіс, ніколи не будуть шукатись на нижчих рівнях, відносно того рівня, куди передано даний сервіс.
Поточний інжектор
Безпосередньо сам інжектор сервіса чи контролера вам рідко може знадобиться, але ви його можете отримати у конструкторі як і значення будь-якого іншого провайдера:
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']
По-суті, мульти-провайдери дозволяють створювати групи провайдерів, що мають спільний токен. Ця можливість зокрема використовується для ст ворення групи 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.