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); // Instance of Service3
service3 === injector.get(Service3); // true
Як бачите, метод Injector.resolveAndCreate() на вході приймає масив провайдерів, а на виході видає інжектор, який вміє створювати інстанс кожного переданого класу за допомогою методу injector.get(), з урахуванням усього ланцюжка залежностей (Service3 -> Service2 -> Service1).
Отже, які задачі стоять перед інжектором, і що робить його метод injector.get():
- Під час створення інжектора, йому передається масив провайдерів - тобто масив інструкцій між тим, що у нього запитують (токеном), та тим, що він повинен видавати (значенням). Цей етап є дуже важливим для подальшого функціонування інжектора. Якщо ви не передасте усіх необхідних провайдерів, інжектор не матиме відповідних інструкцій, коли ви будете запитувати певний токен.
- Після створення інжектора, коли у нього запитують токен
Service3, він проглядає масив провайдерів і бачить там інструкцію{ token: Service3, useClass: Service3 }, тому він "розуміє", що для токенаService3треба видавати інстанс класуService3. - Потім він проглядає конструктор класу
Service3, бачить залежність відService2. - У попередньому пункті, по-суті, у нього запитують токен
Service2, тому він проглядає масив провайдерів і бачить там інструкцію{ token: Service2, useClass: Service2 }, тому він "розуміє", що для токенаService2треба видавати інстанс класуService2. - Потім проглядає конструктор у
Service2, бачить залежність відService1. - У попередньому пункті у нього запитують токен
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); // instance of 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');
// second-service.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[]) {}
// ...
}