Розширення
Що робить розширення Ditsmod
Як правило, розширення виконує свою роботу перед створенням обробників HTTP-запитів. Щоб змінити або розширити роботу застосунку розширення використовує статичні метадані, закріплені за певними декораторами. З іншого боку, розширення може ще й динамічно додавати метадані такого самого типу, як і ці статичні метадані. Розширення можуть ініціалізуватись асинхронно, і можуть залежати один від одного.
Задача більшості роз ширень полягає в тому, що вони, як на конвеєрі, на вході беруть один багатовимірний масив конфігураційних даних (метаданих), а на виході видають інший (або доповнений) багатовимірний масив, який в кінцевому підсумку інтерпретується цільовим розширенням, наприклад, для створенні роутів та їх обробників. Але не обов'язково щоб розширення працювали над конфігурацією та встановленням обробників HTTP-запитів; вони можуть ще й записувати логи чи збирати метрики для моніторингу, або виконувати будь-яку іншу роботу.
У більшості випадків, дані багатовимірні масиви відображають структуру застосунку:
- вони розбиті по модулям;
- у кожному модулі є контролери або провайдери;
- кожен контролер має один або більше роутів.
Наприклад, в модулі @ditsmod/body-parser працює розширення, що динамічно додає HTTP-інтерсептор для парсингу тіла запиту до кожного роута, що має відповідний метод (POST, PATCH, PUT). Воно це робить один раз перед створенням обробників HTTP-запитів, тому за кожним запитом вже немає необхідності тестувати потребу такого парсингу.
Інший приклад. Модуль @ditsmod/openapi дозволяє створювати OpenAPI-документацію за допомогою власного декоратора @oasRoute
. Без роботи розширення, Ditsmod буде ігнорувати метадані з цього нового декоратора. Розширення з цього модуля отримує згаданий вище конфігураційний масив, знаходить там метадані з декоратора @oasRoute
, й інтерпретує ці метадані додаючи інші метадані, які будуть використовуватись іншим розширенням для встановлення роутів.
Що таке "розширення Ditsmod"
У Ditsmod розширенням називається клас, що впроваджує інтерфейс Extension
:
interface Extension<T> {
init(isLastModule: boolean): Promise<T>;
}
Кожне розширення потрібно реєструвати, про це буде згадано пізніше, а зараз припустимо, що така реєстрація відбулася, після чого йде наступний процес:
- збираються метадані з усіх декораторів (
@rootModule
,@featureModule
,@controller
,@route
...); - зібрані метадані передаються в DI з токеном
MetadataPerMod1
, отже - будь-яке розширення може отримати ці метадані у себе в конструкторі; - починається по-модульна робота розширень:
- у кожному модулі збираються розширення, що створені в цому модулі, або імпортовані в цей модуль;
- кожне з цих розширень отримує метадані, зібрані теж у цьому модулі, і викликаються методи
init()
даних розширень.
- створюються обробники HTTP-запитів;
- застосунок починає працювати у звичному режимі, обробляючи HTTP-запити.
Метод init()
певного розширення може викликатись стільки разів, скільки разів він прописаний у тілі інших розширень, які залежать від роботи даного розширення, +1. Цю особливість необхідно обов'язково враховувати, щоб не відбувалась зайва ініціалізація:
import { injectable, Extension } from '@ditsmod/core';
@injectable()
export class MyExtension implements Extension<any> {
private data: any;
async init() {
if (this.data) {
return this.data;
}
// ...
// Щось хороше робите
// ...
this.data = result;
return this.data;
}
}
Готовий простий приклад ви можете проглянути у теці 09-one-extension.
Групи розширень
Будь-яке розширення повинно входити в одну або декілька груп. Концепція групи розширень аналогічна до концепції групи інтерсепторів. Зверніть увагу, що група інтерсепторів виконує конкретний вид робіт: доповнення обробки HTTP-запиту для певного роута. Аналогічно, кожна група розширень - це окремий вид робіт над певними метаданими. Як правило, розширення в певній групі повертають метадані, що мають однаковий базовий інтерфейс. По-суті, групи розширень дозволяють абстрагуватись від конкретних розширень; натомість вони роблять важливими лише вид роботи, який виконується у даних групах.
Наприклад, у @ditsmod/routing
існує група ROUTES_EXTENSIONS
в яку по-дефолту входить єдине розширення, що обробляє метадані, зібрані з декоратора @route()
. Якщо в якомусь застосунку потрібна документація OpenAPI, можна підключити модуль @ditsmod/openapi
, де також зареєстровано розширення у групі ROUTES_EXTENSIONS
, але це розширення працює з декоратором @oasRoute()
. В такому разі, у групі ROUTES_EXTENSIONS
вже буде зареєстровано два розширення, кожне з яких готуватиме дані для встановлення маршрутів роутера. Ці розширення зібрані в одну групу, оскільки вони налаштовують роути, а їхні методи init()
повертають дані з однаковим базовим інтерфейсом.
Спільний базовий інтерфейс даних, який повертає кожне з розширень у певній групі, - це важлива умова, оскільки інші розширення можуть очікувати дані із цієї групи, і вони будуть опиратись саме на цей базовий інтерфейс. Звичайно ж, базовий інтерфейс при потребі можна розширювати, але не звужувати.
Окрім спільного базового інтерфейсу, важливою є також послідовність запуску груп розширень і залежність між ними. У нашому прикладі, після того, як відпрацюють усі розширення з групи ROUTES_EXTENSIONS
, їхні дані збираються в один масив і передаються до групи PRE_ROUTER_EXTENSIONS
. Навіть якщо ви пізніше зареєструєте більше нових розширень у групі ROUTES_EXTENSIONS
, все-одно група PRE_ROUTER_EXTENSIONS
буде запускатись після того як відпрацюють абсолютно усі розширення з групи ROUTES_EXTENSIONS
, включаючи ваші нові розширення.
Ця фіча є дуже зручною, оскільки вона інколи дозволяє інтегрувати зовнішні модулі Ditsmod (наприклад, з npmjs.com) у ваш застосунок без жодних налаштувань, просто імпортувавши їх у потрібний модуль. Саме завдяки групам розширень, імпортовані розширення будуть запускатись у правильній послідовності, навіть якщо вони імпортовані з різних зовнішніх модулів.
Так працює, наприклад, розширення з @ditsmod/body-parser
. Ви просто імпортуєте BodyParserModule
, і його розширення вже буде запускатись у правильному порядку, який прописаний у цьому модулі. В даному разі, його розширення буде працювати після групи ROUTES_EXTENSIONS
, але перед групою PRE_ROUTER_EXTENSIONS
. Причому зверніть увагу, що BodyParserModule
і гадки не має, які саме розширення будуть працювати у цих групах, для нього важливим є лише:
- інтерфейс даних, який будуть повертати розширення з групи
ROUTES_EXTENSIONS
; - порядок запуску, щоб роути не були встановлені ще до роботи цього модуля (тобто щоб група
PRE_ROUTER_EXTENSIONS
працювала саме після нього, а не перед ним).
Це означає, що BodyParserModule
буде брати до уваги роути, що встановлені за допомогою декораторів @route()
або @oasRoute()
, або будь-яких інших декораторів із цієї групи, оскільки їх обробкою займаються розширення, що працюють перед ним у групі ROUTES_EXTENSIONS
.
Реєстрація розширення
Зареєструйте розширення в існуючій групі розширень, або створіть нову групу, навіть якщо у ній буде єдине розширення. Для нової групи вам потрібно буде створити новий DI токен.
Створення токена нової групи
Токен групи розширень повинен бути інстансом класу InjectionToken
.
Наприклад, щоб створити токен для групи MY_EXTENSIONS
, необхідно зробити наступне:
import { InjectionToken, Extension } from '@ditsmod/core';
export const MY_EXTENSIONS = new InjectionToken<Extension<void>[]>('MY_EXTENSIONS');
Як бачите, кожна група розширень повинна указувати, що DI повертатиме масив інстансів розширень: Extension<void>[]
. Це треба робити обов'язков о, відмінність може бути хіба що в типі даних для дженеріка Extension<T>[]
.
Реєстрація розширення у групі
В масив extensions
, що знаходиться в метаданих модуля, можуть передаватись об'єкти наступного типу:
class ExtensionOptions {
extension: ExtensionType;
/**
* Extension group token.
*/
token: InjectionToken<Extension<any>[]>;
/**
* The token of the group before which this extension will be called.
*/
nextToken?: InjectionToken<Extension<any>[]>;
/**
* Indicates whether this extension needs to be exported.
*/
exported?: boolean;
/**
* Indicates whether this extension needs to be exported without working in host module.
*/
exportedOnly?: boolean;
}
Властивість nextToken
використовується, коли вашу групу розширень потрібно запускати перед іншою групою розширень:
import { featureModule, ROUTES_EXTENSIONS } from '@ditsmod/core';
import { MyExtension, MY_EXTENSIONS } from './my.extension.js';
@featureModule({
extensions: [
{ extension: MyExtension, token: MY_EXTENSIONS, nextToken: ROUTES_EXTENSIONS, exported: true }
],
})
export class SomeModule {}
Тобто у властивість token
передається токен групи MY_EXTENSIONS
, до якої належить ваше розширення. У властивість nextToken
передається токен групи розширень ROUTES_EXTENSIONS
, перед якою потрібно запускати групу MY_EXTENSIONS
. Опціонально можна використовувати властивість exported
або exportedOnly
для того, щоб вказати, чи потрібно щоб дане розширення працювало у зовнішньому модулі, яке імпортуватиме цей модуль. Окрім цього, властивість exportedOnly
ще й вказує на те, що дане розширення не потрібно запускати у так званому хост-модулі (тобто в модулі, де оголошується це розширення).
Використання ExtensionsManager
Якщо певне розширення має залежність від іншого розширення, рекомендується вказувати таку залежність посередньо через групу розширень. Для цього вам знадобиться ExtensionsManager
, який ініціалізує групи розширень, кидає помилки про циклічні залежності між розширеннями, і показує весь ланцюжок розширень, що призвів до зациклення. Окрім цього, ExtensionsManager
дозволяє збирати результати ініціалізації розширень з усього застосунку, а не лише з одного модуля.
Припустимо MyExtension
повинно дочекатись завершення ініціалізації групи OTHER_EXTENSIONS
. Щоб зробити це, у конструкторі треба указувати залежність від ExtensionsManager
, а у init()
викликати init()
цього сервісу:
import { injectable } from '@ditsmod/core';
import { Extension, ExtensionsManager } from '@ditsmod/core';
import { OTHER_EXTENSIONS } from './other.extensions.js';
@injectable()
export class MyExtension implements Extension<void> {
private inited: boolean;
constructor(private extensionsManager: ExtensionsManager) {}
async init() {
if (this.inited) {
return;
}
const totalInitMeta = await this.extensionsManager.init(OTHER_EXTENSIONS);
totalInitMeta.groupInitMeta.forEach((initMeta) => {
const someData = initMeta.payload;
// Do something here.
// ...
});
this.inited = true;
}
}
ExtensionsManager
буде послідовно викликати ініціалізацію усіх розширень з указаної групи, а результат їхньої роботи повертатиме в об'єкті, що має наступний інтерфейс:
interface TotalInitMeta<T = any> {
delay: boolean;
countdown = 0;
totalInitMetaPerApp: TotalInitMetaPerApp<T>[];
groupInitMeta: ExtensionInitMeta<T>[],
moduleName: string;
}
Якщо властивість delay == true
- це означає, що властивість totalInitMetaPerApp
містить дані ще не з усіх модулів, куди імпортовано дану групу розширень (OTHER_EXTENSIONS
). Властивість countdown
вказує, у скількох модулях ще залишилось відпрацювати даній групі розширень, щоб властивість totalInitMetaPerApp
містила дані з усіх модулів. Тобто властивості delay
та countdown
стосуються лише властивості totalInitMetaPerApp
.
У властивості groupInitMeta
знаходиться масив, де зібрані дані з поточного модуля від різних розширень з даної групи розширень. Кожен елемент масиву groupInitMeta
має наступний інтерфейс:
interface ExtensionInitMeta<T = any> {
/**
* Instance of an extension.
*/
extension: Extension<T>;
/**
* Value that `extension` returns from its `init` method.
*/
payload: T;
delay: boolean;
countdown: number;
}
Властивість extension
містить інстанс розширення, а властивість payload
- дані, які повертає метод init()
цього розширення.
Якщо властивість delay == true
та countdown > 0
- це означає, що дане розширення ще не відпрацювало в інших модулях, куди його імпортували. Тобто властивості delay
та countdown
стосуються самого розширення, і не стосуються даної групи розширень (в даному разі - групи OTHER_EXTENSIONS
).
Важливо пам'ятати, що для кожного модуля створюється окремий інстанс певного розширення. Наприклад, якщо MyExtension
імпортовано у три різні модулі, то Ditsmod буде послідовно обробляти ці три модулі із трьома різними інстансами MyExtension
. Окрім цього, якщо MyExtension
потребує підсумкові дані, наприклад, від групи розширень OTHER_EXTENSIONS
із чотирьох модулів, а саме MyExtension
імпортовано лише у три модулі, це означає, що з одного модуля MyExtension
може і не отримати необхідних даних.
В такому випадку потрібно передавати true
у якості аргументу для другого параметра методу extensionsManager.init
:
import { injectable } from '@ditsmod/core';
import { Extension, ExtensionsManager } from '@ditsmod/core';
import { OTHER_EXTENSIONS } from './other.extensions.js';
@injectable()
export class MyExtension implements Extension<void> {
private inited: boolean;
constructor(private extensionsManager: ExtensionsManager) {}
async init() {
if (this.inited) {
return;
}
const totalInitMeta = await this.extensionsManager.init(OTHER_EXTENSIONS, true);
if (totalInitMeta.delay) {
return;
}
totalInitMeta.totalInitMetaPerApp.forEach((totaInitMeta) => {
totaInitMeta.groupInitMeta.forEach((initMeta) => {
const someData = initMeta.payload;
// Do something here.
// ...
});
});
this.inited = true;
}
}
Тобто коли вам потрібно щоб MyExtension
отримало дані з групи OTHER_EXTENSIONS
з усього застосунку, другим аргументом для методу init
потрібно передавати true
:
const totalInitMeta = await this.extensionsManager.init(OTHER_EXTENSIONS, true);
В такому разі гарантується, що інстанс MyExtension
отримає дані з усіх модулів, куди імпортовано OTHER_EXTENSIONS
. Навіть якщо MyExtension
буде імпортовано у певний модуль, в якому немає розширень із групи OTHER_EXTENSIONS
, але ці розширення є в інших модулях, все-одно метод init
даного розширення буде викликано після ініціалізації усіх розширень, тому MyExtension
отримає дані від OTHER_EXTENSIONS
з усіх модулів.
Динамічне додавання провайдерів
Будь-яке розширення може вказати залежність від групи розширень ROUTES_EXTENSIONS
, щоб динамічно додавати провайдери на будь-якому рівні. Розширення з цієї групи використовують метадані з інтерфейсом MetadataPerMod1
і повертають метадані з інтерфейсом MetadataPerMod2
.
Можна проглянути як це зроблено у BodyParserExtension:
@injectable()
export class BodyParserExtension implements Extension<void> {
private inited: boolean;
constructor(
protected extensionManager: ExtensionsManager,
protected perAppService: PerAppService,
) {}
async init() {
if (this.inited) {
return;
}
const totalInitMeta = await this.extensionManager.init(ROUTES_EXTENSIONS);
totalInitMeta.groupInitMeta.forEach((initMeta) => {
const { aControllersMetadata2, providersPerMod } = initMeta.payload;
aControllersMetadata2.forEach(({ providersPerRou, providersPerReq, httpMethod, isSingleton }) => {
// Merging the providers from a module and a controller
const mergedProvidersPerRou = [...initMeta.payload.providersPerRou, ...providersPerRou];
const mergedProvidersPerReq = [...initMeta.payload.providersPerReq, ...providersPerReq];
// Creating a hierarchy of injectors.
const injectorPerApp = this.perAppService.injector;
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
const injectorPerRou = injectorPerMod.resolveAndCreateChild(mergedProvidersPerRou);
if (isSingleton) {
let bodyParserConfig = injectorPerRou.get(BodyParserConfig, undefined, {}) as BodyParserConfig;
bodyParserConfig = { ...new BodyParserConfig(), ...bodyParserConfig }; // Merge with default.
if (bodyParserConfig.acceptMethods!.includes(httpMethod)) {
providersPerRou.push({ token: HTTP_INTERCEPTORS, useClass: SingletonBodyParserInterceptor, multi: true });
}
} else {
const injectorPerReq = injectorPerRou.resolveAndCreateChild(mergedProvidersPerReq);
let bodyParserConfig = injectorPerReq.get(BodyParserConfig, undefined, {}) as BodyParserConfig;
bodyParserConfig = { ...new BodyParserConfig(), ...bodyParserConfig }; // Merge with default.
if (bodyParserConfig.acceptMethods!.includes(httpMethod)) {
providersPerReq.push({ token: HTTP_INTERCEPTORS, useClass: BodyParserInterceptor, multi: true });
}
}
});
});
this.inited = true;
}
}
В даному разі, HTTP-інтерсептор додається в масив providersPerReq
, у метадані контролера. Але перед цим, створюється ієрархія інжекторів для того, щоб отримати певну конфігурацію, яка вказує нам чи потрібно додавати такий інтерсептор. Якщо б не потрібно було перевіряти будь-яку умову, ми могли б не створювати ієрархії інжекторів, і зразу б додали інтерсептор на рівні запиту.
Звичайно ж, таке динамічне додавання провайдерів можливе лише перед створенням обробників HTTP-запитів.