Dependency Injection
Для чого потрібен DI?
Давайте спочатку ознайомимось із загальною картиною роботи Dependency Injection (або просто DI), а потім в деталях розглянемо кожен важливий її компонент окремо.
Мабуть найпростіше зрозуміти, що саме робить DI, на прикладах. Почнемо з прикладів, де не використовується DI. В даному разі нам потрібен інстанс класу Service3
та його метод doSomething()
:
export class Service1 {}
export class Service2 {
constructor(private service1: Service1) {}
// ...
// Використання this.service1 у якомусь із методів.
}
export class Service3 {
constructor(private service2: Service2) {}
doSomething(param1: any) {
// Використання this.service2 у даному методі.
}
}
export function getService3() {
const service1 = new Service1();
const service2 = new Service2(service1);
return new Service3(service2);
}
Як бачите, Service3
залежить від Service2
, який, у свою чергу, залежить від Service1
. Покищо інстанс Service3
отримати досить просто:
import { getService3 } from './services.js';
export class SomeService {
method1() {
const service3 = getService3();
service3.doSomething(123);
}
}
У функції getService3
захардкоджено створення інстансу Service3
, і це є проблемою, тому що писати юніт-тести для цієї функції проблематично, особливо в контексті EcmaScript Module, оскільки ви не зможете підмінити Service1
та Service2
моками. Ще один серйозний мінус функції getService3
в тому, що в реальному застосунку вона може стати досить складною через потребу враховувати конфігурацію кожної із залежностей. Тобто, наприклад, в одному випадку в тілі getService3
може очікуватись, що вона буде створювати кожен раз нові інстанси Service1
та Service2
, в другому випадку - потрібно щоб вони були одинаками для усього застосунку, а в третьому - що тільки один із них повинен бути одинаком...
В наступному прикладі вже використовується DI, хоча цей приклад майже не відрізняється від попереднього прикладу, де ми також оголошували клас Service3
, але тут ми дописали декоратор injectable
над кожним класом, який має конструктор з параметрами, і не стали створювати функцію getService3
:
import { injectable } from '@ditsmod/core';
export class Service1 {}
@injectable()
export class Service2 {
constructor(private service1: Service1) {}
// ...
// Вико ристання this.service1 у якомусь із методів.
}
@injectable()
export class Service3 {
constructor(private service2: Service2) {}
doSomething(param1: any) {
// Використання this.service2 у даному методі.
}
}
Важливо розуміти, що декоратор injectable
потрібний лише через те, що у JavaScript-коді не існує можливості вказати тип параметра в конструкторі, як це зроблено у TypeScript-коді. Роль декоратора injectable
дуже проста - його наявність говорить TypeScript-компілятору, що потрібно переносити у JavaScript-код ті метадані, які знаходяться у TypeScript-коді у конструкторах класів. Наприклад, наявність декоратора injectable
над класом Service2
буде сигналізувати TypeScript-компілятору, що треба запам'ятати Service1
у якості першого параметра у конструкторі. Ці метадані вивантажуються у JavaScript-код за допомогою TypeScript-компілятора і зберігаються за допомогою методів класу Reflect
з бібліотеки reflect-metadata
.
Пізніше, коли ми передамо до DI класи зі збереженими метаданими, ці метадані DI зможе зчитувати та використовувати для автоматичної підстановки відповідних інстансів класів, тому ми зможемо запитувати інстанс Service3
у конструкторі будь-якого класу у нашій програмі:
import { injectable } from '@ditsmod/core';
import { Service3 } from './services.js';
@injectable()
export class SomeService {
constructor(private service3: Service3) {}
method1() {
this.service3.doSomething(123);
}
}
Як бачите, ми більше не створюємо інстансу Service3
за допомогою оператора new
, натомість цим займається DI і передає у конструктор готовий інстанс. Навіть якщо згодом у конструкторі Service3
параметри будуть змінюватись, нічого не прийдеться змінювати у тих місцях, де використовується Service3
.
"Магія" роботи з метаданими
З точки зору JavaScript-розробника, в тому, що DI якимось чином може проглядати параметри конструкторів класів і бачити там інші класи - це можна назвати "магією". Якщо проглянути репозиторій @ditsmod/core
, можна побачити що:
- у файлі
tsconfig.json
вказано "emitDecoratorMetadata": true; - у файлі
package.json
вказано залежність від бібліотеки reflect-metadata; - є цілий ряд декоратораторів (
rootModule
,featureModule
,controller
,injectable
...).
Усі ці складові якраз і забезпечують "магію" зчитування та збереження метаданих, які ви прописуєте у своїх класах за допомогою декораторів. Вам можна глибоко і не розбиратись як саме працює ця "магія", але варто пам'ятати хоча б які саме складові вона має.
Варто також зазначити, що Ditsmod не використовує нові декоратори, оскільки вони покищо не мають API для роботи з параметрами методів.
Залежність
Якщо для створення інстанса даного класа вам потрібно спочатку створити інстанси інших класів - значить даний клас має залежності. Наприклад, якщо в конструкторі сервісу ви прописуєте ось таке:
import { injectable } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private firstService: FirstService) {}
// ...
}
це означає, що SecondService
має залежність від FirstService
, і очікується що DI вирішить цю залежність наступним чином:
- спочатку DI прогляне конструктор
FirstService
; - якщо у
FirstService
немає залежності, буде створено інстансFirstService
; - інстанс
FirstService
буде передано в конструкторSecondService
.
Якщо після виконання першого пункту виясниться, що FirstService
має свої власні залежності, то DI буде рекурсивно виконувати ці три пункти для кожної даної залежності.
Якщо ви забудете написати (або навмисно видалите) декоратор injectable
перед класом, що має залежності в конструкторі, DI кине помилку про те, що він не може вирішити залежність даного класа. Це відбувається через те, що injectable
бере участь у зчитуванні та збереженні метаданих класу.
Опціональна залежність
Інколи вам може знадобитись вказати опціональну (необов'язкову) залежність в конструкторі. Давайте розглянемо наступний приклад, де після властивості firstService
поставлено знак питання, і таким чином вказано для TypeScript що ця властивість є опціональною:
import { injectable } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private firstService?: FirstService) {}
// ...
}
Але DI проігнорує цю опціональність і видасть помилку у разі відсутності можливості для створення FirstService
. Щоб даний код працював, необхідно скористатись декоратором optional
:
import { injectable, optional } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(@optional() private firstService?: FirstService) {}
// ...
}
Токен залежності
У попередніх прикладах ви вже багато разів бачили токен залежності, але формально покищо ми його не представили. Давайте знову розглянемо попередній приклад:
import { injectable } from '@ditsmod/core';
import { FirstService } from './first.service.js';
@injectable()
export class SecondService {
constructor(private firstService: FirstService) {}
// ...
}
Тут мається на увазі, що FirstService
є класом, і через це він може одночасно використовуватись і у якості TypeScript-типу, і у якості токену. По-суті, токен - це ідентифікатор, який асоціюється із відповідною залежністю. Дуже важливо розуміти, що сам механізм використання токенів потрібний для JavaScript-runtime, тому у якості токенів не можна використовувати такі типи, які у TypeScript-коді ви оголошуєте з ключовими словами interface
, type
, enum
і т.п., бо їх не існує у JavaScript-коді.
На відміну від класу, масив не може одночасно використовуватись і у якості TypeScript-типу, і у якості токену. З іншого боку, токен може мати зовсім нерелевантний тип даних відносно залежності, з якою він асоціюється, тому, наприклад, рядковий тип токена може асоціюватись із залежністю, що має будь-який TypeScript-тип, включаючи масиви, інтерфейси, enum і т.д.
Передати токен можна у короткій або довгій формі вказання залежності. В останньому прикладі використовується коротка форма вказання залежності, вона має суттєві обмеження, бо таким чином можна вказати залежність лише від певного класу.
А ще існує довга форма вказання залежності за допомогою декоратора inject
, вона дозволяє використовувати альтернативний токен:
import { injectable, inject } from '@ditsmod/core';
import { InterfaceOfItem } from './types.js';
@injectable()
export class SecondService {
constructor(@inject('some-string') private someArray: InterfaceOfItem[]) {}
// ...
}
Коли використовується inject
, DI бере до уваги лише переданий в нього токен. В даному разі DI ігнорує тип змінної - InterfaceOfItem[]
, використовуючи в якості токена текстовий рядок some-string
. Іншими словами, DI використовує some-string
як ключ для пошуку відповідного значення для залежності, що має тип 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');
// second-service.ts
import { injectable, inject } from '@ditsmod/core';
import { InterfaceOfItem } from './types.js';
import { SOME_TOKEN } from './tokens.js';
@injectable()
export class SecondService {
constructor(@inject(SOME_TOKEN) private someArray: InterfaceOfItem[]) {}
// ...
}
Провайдери
У DI є реєстр, який по-суті є мапінгом між токеном та значенням, яке потрібно видавати для цього токена. Схематично цей реєстр можна показати так:
токен1 -> значення15
токен2 -> значення100
...
Як можна здогадатись, при вирішенні залежності, DI братиме токени з параметрів конструктора певного класа, і шукатиме для них значення у реєстрі DI. Якщо усі необхідні токени у реєстрі знайшлись, значить їхні значення передаються у конструктор, і таким чино м успішно вирішується залежність даного класа.
DI створює значення у реєстрі для кожного токена використовуючи так звані провайдери. Провайдер може бути або класом, або об'єктом:
import { Class } from '@ditsmod/core';
type Provider = Class<any> |
{ token: NonNullable<unknown>, useClass: Class<any>, multi?: boolean } |
{ token: NonNullable<unknown>, useValue: any, multi?: boolean } |
{ token?: NonNullable<unknown>, useFactory: [Class<any>, Class<any>.prototype.methodName], multi?: boolean } |
{ token?: NonNullable<unknown>, useFactory: (...args: any[]) => any, deps: any[], multi?: boolean } |
{ token: NonNullable<unknown>, useToken: any, multi?: boolean }
*зверніть увагу, що токен для провайдера з властивістю useFactory
є опціональним, оскільки DI може використати функцію чи метод вказаного класу у якості токена.
Отже, щоб DI міг вирішити певну залежність, спочатку необхідно передати відповідний провайдер до реєстру DI, а потім DI буде видавати значення цього провайдера по його токену. Тому якщо ви вказали певну залежність у класі, але не передали відповідного провайдера, DI не зможе вирішити дану залежність. Про те, як саме можна передавати провайдери до DI, йдеться в наступному розділі.
Якщо провайдер представлено у вигляді об'єкта, у його властивості можуть передаватись наступні значення:
-
useClass
- сюди передається клас, чий інстанс буде використано як значення цього провайдера. Приклад такого провайдера:{ token: 'token1', useClass: SomeService }
-
useValue
- сюди передається будь-яке значення, окрімundefined
, DI його видаватиме без змін. Приклад такого провайдера:{ token: 'token2', useValue: 'some value' }
-
useFactory
- сюди можна передавати аргументи у двох формах.-
Перша форма (рекомендована, через свою кращу інкапсуляцію) передбачає, що до
useFactory
передається tuple, де на першому місці повинен бути клас, а на другому місці - метод цього класу, який повинен п овернути якесь значення для вказаного токена. Наприклад, якщо клас буде таким:import { methodFactory } from '@ditsmod/core';
export class ClassWithFactory {
@methodFactory()
method1(dependecy1: Dependecy1, dependecy2: Dependecy2) {
// ...
return '...';
}
}В такому разі провайдер потрібно передавати до реєстру DI в наступному форматі:
{ token: 'token3', useFactory: [ClassWithFactory, ClassWithFactory.prototype.method1] }
Спочатку DI створить інстанс цього класу, потім викличе його метод та отримає результат, який вже і буде асоціюватись з указаним токеном. Метод указаного класу може повертати будь-яке значення, окрім
undefined
. -
Друга форма передбачає, що до
useFactory
можна передавати функцію, яка може мати параметри - тобто може мати залежність. Цю залежність необхідно додатково вручну вказувати у властивостіdeps
у вигляді масиву токенів, причому порядок передачі токенів важливий:function fn1(service1: Service1, service2: Service2) {
// ...
return 'some value';
}
{ token: 'token3', useFactory: fn1, deps: [Service1, Service2] }Зверніть увагу, що у властивість
deps
передаються саме токени провайдерів, і DI їх сприймає саме як токени, а не як провайдери. Тобто для цих токенів до реєстру DI ще треба буде передати відповідні провайдери. Також зверніть увагу, що уdeps
не передаються декоратори для параметрів (наприкладoptional
,skipSelf
і т.д.). Якщо для вашої фабрики необхідні декоратори параметрів - вам потрібно скористатись першою формою передачі аргументів доuseFactory
.
-
-
useToken
- в цю властивість провайдера передається інший токен. Якщо ви записуєте таке:{ token: SecondService, useToken: FirstService }
таким чином ви говорите DI: "Коли споживачі провайдерів запитують токен
SecondService
, потрібно використати значення для токенаFirstService
". Іншими словами, ця директива робить аліасSecondService
, який вказує наFirstService
.
Тепер, коли ви вже ознайомились з поняттям провайдер, можна уточнити, що під залежністю розуміють залежність саме від значення провайдера. Таку залежність мають споживачі значень провайдерів або в конструкторах сервісів, або в конструкторах чи методах контролерів, або в методі get()
інжекторів (про це буде згадано пізніше).
Інжектор
В описі провайдерів було згадано за реєстри DI, тепер давайте розберемось як формуються ці реєстри, і де саме вони знаходяться.
Якщо сильно спростити схему роботи DI, можна сказати що DI приймає масив провайдерів на вході, а на виході видає інжектор, який вміє створювати значення для кожного переданого провайдера. Тобто, реєстри DI формуються на основі масивів провайдерів, які передаються в інжектор:
import { Injector, injectable } from '@ditsmod/core';
class Service1 {}
@injectable()
class Service2 {
constructor(service1: Service1) {}
}
@injectable()
class Service3 {
constructor(service2: Service2) {}
}
const injector = Injector.resolveAndCreate([Service1, Service2, Service3]);
const service3 = injector.get(Service3);
service3 === injector.get(Service3); // true
service3 === injector.resolveAndInstantiate(Service3); // false
Як бачите, метод Injector.resolveAndCreate()
на вході приймає масив провайдерів, а на виході видає інжектор, який вміє видавати значення кожного провайдера по його токену за допомогою методу injector.get()
, з урахуванням усього ланцюжка залежностей (Service3
-> Service2
-> Service1
).
Що робить injector.get()
:
- коли у нього запитують
Service3
, він проглядає конструктор цього класу, бачить залежність відService2
; - потім проглядає конструктор у
Service2
, бачить залежність відService1
; - потім проглядає конструктор у
Service1
, не знаходить там залежності, і тому першим створює інстансService1
; - потім створює інстанс
Service2
використовуючи інстансService1
; - і останнім створює інстанс
Service3
використовуючи інстансService2
; - якщо пізніше будуть запитувати повторно інстанс
Service3
, методinjector.get()
буде повертати раніше створений інстансService3
з кешу даного інжектора.
Інколи останній пункт (коли інстанс Service3
повертається з кешу інжектора), є небажаним. В такому разі ви можете скористатись методом injector.resolveAndInstantiate()
, який приймає провайдер, резолвить його в контексті поточного інжектора, і кожен раз повертає новий інстанс даного провайдера.
Під час автоматичного вирішення залежності класу (коли інжектор не використовується напряму), Ditsmod під капотом використовує метод injector.get()
.
Використовуючи DI, вам можна і не знати весь ланцюжок залежностей Service3
, довірте цю роботу інжектору, головне - передайте в реєстр DI усі необхідні класи. Майте на увазі, що таким чином можна писати unit-тести для окремо-взятих класів.
Ієрархія інжекторів
DI дозволяє створювати ще й ієрархію інжекторів - це коли є батьківські та дочірні інжектори. На перший погляд, немає нічого цікавого у такій ієрархії, бо не зрозуміло для чого вона потрібна, але у Ditsmod ця можливість використовується якраз дуже часто, оскільки вона дозволяє робити архітектуру застосунку модульною. Вивченню специфіки ієрархії варто приділити особливу увагу, це в майбутньому збереже вам не одну годину часу, бо ви знатимете як воно працює і чому воно не знаходить цієї залежності...
При створенні ієрархії, зв'язок утримує лише дочірній інжектор, він має об'єкт батьківського інжектора. В той же час, батьківський інжектор нічого не знає про свої дочірні інжектори. Тобто зв'язок між інжекторами в ієрархії є одностороннім. Умовно, це виглядає наступним чином:
interface Parent {
// Тут є певні властивості батьківського інжектора, але немає дочірнього інжектора
}
interface Child {
parent: Parent;
// Тут існують інші властивості дочірного інжектора.
}
Завдяки наявності об'єкта батьківського інжектора, дочірній інжектор може звертатись до батьківського інжектора, коли у нього запитують значення провайдера, якого у нього немає.
Давайте розглянемо наступний приклад. Для спрощення, тут взагалі не використовуються декоратори, оскільки кожен клас є незалежним:
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); // ОК
parent.get(Service1); // ОК
parent.get(Service1) === child.get(Service1); // true
child.get(Service2); // ОК
parent.get(Service2); // ОК
parent.get(Service2) === child.get(Service2); // false
child.get(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
А Service2
є в обох інжекторах, тому кожен із них створить свою локальну версію цього сервіса, і саме через це даний вираз повертає false
:
parent.get(Service2) === child.get(Service2); // false
Батьківський інжектор не може створити інстансу класу Service3
через те, що батьківський інжектор не має зв'язку з дочірнім інжектором, в якому є Service3
.
Ну і обидва інжектори не можуть видати інстансу Service4
, бо їм не передали цього класу при їхньому створенні.
Ієрархія інжекторів в застосунку Ditsmod
Раніше в документації ви зустрічали наступні властивості об'єкта, які передаються через метадані модуля або контролера:
providersPerApp
- провайдери на рівні застосунку;providersPerMod
- провайдери на рівні модуля;providersPerRou
- провайдери на рівні роута;providersPerReq
- провайдери на рівні HTTP-запиту.
Використовуючи ці масиви, Ditsmod формує різні інжектори, що пов'язані між собою ієрархічним зв'язком. Таку ієрархію можна зімітувати наступним чином:
import { Injector } from '@ditsmod/core';
const providersPerApp = [];
const providersPerMod = [];
const providersPerRou = [];
const providersPerReq = [];
const injectorPerApp = Injector.resolveAndCreate(providersPerApp);
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
const injectorPerRou = injectorPerMod.resolveAndCreateChild(providersPerRou);
const injectorPerReq = injectorPerRou.resolveAndCreateChild(providersPerReq);
Під капотом, Ditsmod робить аналогічну процедуру багато разів для різних модулів, роутів та запитів. Наприклад, якщо застосунок Ditsmod має два модулі, і десять роутів, відповідно буде створено один інжектор на рівні застосунку, по одному інжектору для кожного модуля (2 шт.), по одному інжектору для кожного роуту (10 шт.), і по одному інжектору на кожен запит. Інжектори на рівні запиту видаляються автоматично кожен раз після завершення обробки запиту.
Нагадаємо, що вищі в ієрархії інжектори не мають доступу до нижчих в ієрархії інжекторів. Це означає, що при передачі класу у певний інжектор, потрібно враховувати мінімальний рівень ієрархії йо го залежностей.
Наприклад, якщо ви напишете клас, що має залежність від HTTP-запиту, ви зможете його передати тільки у масив providersPerReq
, бо тільки з цього масиву формується інжектор, до якого Ditsmod буде автоматично додавати провайдер з об'єктом HTTP-запиту. З іншого боку, інстанс цього класу матиме доступ до усіх своїх батьківських інжекторів: на рівні роуту, модуля, та застосунку. Тому даний клас може залежати від провайдерів на будь-якому рівні.
Також ви можете написати певний клас і передати його в масив providersPerMod
, в такому разі він може залежати тільки від провайдерів на рівні модуля, або на рівні застосунку. Якщо він буде залежати від провайдерів, які ви передали в масив providersPerRou
чи providersPerReq
, ви отримаєте помилку про те, що ці провайдери не знайдені.
Ієрархія інжекторів контролера
Будь-який контролер неодинак, окрім свого власного інжектора на рівні запиту, має ще й три батьківські інжектори: на рівні роута, модуля та застосунка. Ці інжектори також формуються на основі провайдерів, які ви передаєте в наступні масиви:
providersPerApp
;providersPerMod
;providersPerRou
;providersPerReq
(це масив, з якого формується інжектор для контролера неодинака).
Тобто контролер неодинак може залежати від сервісів на будь-якому рівні.
Якщо ж контролер є одинаком, його власний інжектор знаходиться на рівні модуля, і він має один батьківський інжектор на рівні застосунку:
providersPerApp
;providersPerMod
(це масив, з якого формується інжектор для контролера одинака).