Перейти до основного вмісту

Розширення

Що робить розширення Ditsmod

Розширення починають працювати, коли Ditsmod зібрав статичні метадані з декораторів на рівні класу, та експортував/імпортував модулі і провайдери саме так, як це було прописано в зібраних статичних метаданих модуля. Як правило, розширення виконує свою роботу перед створенням обробників HTTP-запитів. Щоб змінити або розширити роботу застосунку, розширення використовує статичні метадані, що закріплені за певними декораторами. З іншого боку, розширення може ще й динамічно додавати метадані такого самого типу, як і ці статичні метадані. Розширення можуть ініціалізуватись асинхронно, і можуть залежати один від одного.

Задача більшості розширень полягає в тому, що вони, як на конвеєрі, на вході беруть один багатовимірний масив конфігураційних даних (метаданих), а на виході видають інший (або доповнений) багатовимірний масив, який в кінцевому підсумку інтерпретується цільовим розширенням, наприклад, для створенні роутів та їх обробників. Але не обов'язково щоб розширення працювали над конфігурацією та встановленням обробників HTTP-запитів; вони можуть ще й ініціалізувати конекшени баз даних, записувати логи чи збирати метрики для моніторингу, або виконувати будь-яку іншу роботу.

У більшості випадків, багатовимірні масиви конфігураційних даних відображають структуру застосунку:

  1. вони розбиті по модулям;
  2. у кожному модулі є контролери або провайдери;
  3. кожен контролер має один або більше роутів.

Простий і практичний приклад роботи розширень можна знайти в модулі @ditsmod/body-parser, де працює розширення, що динамічно додає HTTP-інтерсептор для парсингу тіла запиту до кожного роута, що має відповідний метод (POST, PATCH, PUT). Воно це робить один раз перед створенням обробників HTTP-запитів, тому за кожним запитом вже немає необхідності тестувати потребу такого парсингу.

Інший приклад. Модуль @ditsmod/openapi дозволяє створювати OpenAPI-документацію за допомогою власного декоратора @oasRoute. Без роботи розширення, Ditsmod буде ігнорувати метадані з цього нового декоратора. Розширення з цього модуля отримує згаданий вище конфігураційний масив, знаходить там метадані з декоратора @oasRoute, й інтерпретує ці метадані додаючи інші метадані, які будуть використовуватись цільовим розширенням для встановлення роутів.

Що таке "розширення Ditsmod"

У Ditsmod розширенням називається клас, що впроваджує інтерфейс Extension:

interface Extension<T> {
/**
* This method is called at the stage when providers are dynamically added.
*
* @param isLastModule Indicates whether this call is made in the last
* module where this extension is imported or not.
*/
stage1?(isLastModule: boolean): Promise<T>;
/**
* This method is called after the `stage1()` method has executed for all modules
* in the application and this method takes a module-level injector as an argument.
*/
stage2?(injectorPerMod: Injector): Promise<void>;
/**
* This method is called after the `stage2()` method has executed for all modules
* in the application. There is no strict role for this method.
*/
stage3?(): Promise<void>;
}

Готовий простий приклад ви можете проглянути у теці 00-standalone-application.

Реєстрація розширення

Розширення передаються у метадані модуля, у властивість extensions. В залежності від вибраного вами архітектурного стилю, для цього можуть використовуватись такі декоратори як featureModule, restModule, trpcModule і т.д.:

import { restModule } from '@ditsmod/rest';
import { SimpleExtension } from './simple-extension.js';

@restModule({
extensions: [SimpleExtension]
})
export class AppModule {}

Це самий простий спосіб реєстрації розширення, який підходить лише для випадків, коли вам достатньо щоб розширення працювало у тому модулі, де воно оголошено і зареєстровано. Для більш складних конфігурацій, ви можете передати об'єкт, що має наступний тип:

class ExtensionConfig {
extension: ExtensionClass;
/**
* The array of extension classes before which this extension will be called.
*/
beforeExtensions?: ExtensionClass[];
/**
* The array of extension classes after which this extension will be called.
*/
afterExtensions?: ExtensionClass[];
/**
* Each element in this array will form a separate group of extensions together with the current extension.
* When one of the extensions from this array is passed to `ExtensionManager.stage1()`,
* it will return the result of the `Extension.stage1()` method from each extension in the formed group.
*/
groups?: ExtensionClass[];
overrideExtension?: ExtensionClass;
/**
* Indicates whether this extension needs to be exported.
*/
export?: boolean;
/**
* Indicates whether this extension needs to be exported without working in host module.
*/
exportOnly?: boolean;
}

Наприклад:

import { restModule, RouteExtension } from '@ditsmod/rest';
import { SimpleExtension } from './simple-extension.js';

@restModule({
extensions: [
{
extension: SimpleExtension,
beforeExtensions: [RouteExtension],
afterExtensions: [],
export: true,
},
],
})
export class SomeModule {}

Тобто у властивість extension передається клас розширення, яке ви декларуєте і реєструєте у поточному модулі. У властивість beforeExtensions або afterExtensions передаються відповідні класи розширень, якщо вам потрібно щоб зареєстроване розширення працювало перед або після вказаних розширень. Опціонально можна використовувати властивість export або exportOnly для того, щоб вказати, чи потрібно щоб дане розширення працювало у зовнішньому модулі, яке імпортуватиме цей модуль. Окрім цього, властивість exportOnly ще й вказує на те, що дане розширення не потрібно запускати у так званому хост-модулі (тобто в модулі, де оголошується це розширення).

Групи розширень

Будь-яке розширення може входити в одну або декілька груп. Концепція групи розширень аналогічна до концепції групи інтерсепторів. Давайте згадаємо, що група інтерсепторів виконує конкретний вид робіт: доповнює обробку HTTP-запиту для певного роута в контролері. Аналогічно, кожна група розширень - це окремий вид робіт над певними метаданими. Як правило, розширення в певній групі повертають метадані, що мають однаковий базовий інтерфейс. По-суті, групи розширень дозволяють абстрагуватись від конкретних розширень, роблячи важливими лише вид роботи, що виконується у даних групах.

Наприклад, у @ditsmod/rest є RouteExtension, що обробляє метадані, зібрані з декоратора @route(). Якщо в якомусь застосунку потрібна документація OpenAPI - можна додатково підключити модуль @ditsmod/openapi, де зареєстровано OpenapiRouteExtension, що працює з декоратором @oasRoute(). В метаданих модуля @ditsmod/openapi вказано, що OpenapiRouteExtension потрібно використовувати в одній групі з RouteExtension:

extensions: [
{ extension: OpenapiRouteExtension, groups: [RouteExtension], export: true },
// ...
],

Як бачите, групи формуються завдяки властивості groups у метаданих модуля. Ці два розширення зібрані в одну групу через те, що обидва вони налаштовують роути, а їхні методи stage1() повертають дані з однаковим базовим інтерфейсом. Тепер, якщо обидва ці розширення імпортуються в один і той самий модуль, усі споживачі, що запитують дані від RouteExtension, отримуватимуть також результати роботи від OpenapiRouteExtension, яке повертає дані з розширеним інтерфейсом.

Спільний базовий інтерфейс даних, який повертає кожне з розширень у певній групі, - це важлива умова, оскільки інші розширення можуть очікувати дані із цієї групи, і вони будуть опиратись саме на цей базовий інтерфейс. Звичайно ж, базовий інтерфейс при потребі можна розширювати, але не звужувати.

Окрім цього, важливою є також послідовність запуску окремих груп розширень і залежність між ними. У нашому прикладі, після того, як відпрацює група з RouteExtension та OpenapiExtension, їхні дані збираються в один масив і передаються до PreRouterExtension. Навіть якщо ви пізніше зареєструєте більше нових розширень у групі з RouteExtension, все-одно PreRouterExtension буде запускатись вже після того як відпрацюють абсолютно усі розширення у групі з RouteExtension, включаючи ваші нові розширення.

Ця фіча є дуже зручною, оскільки вона інколи дозволяє інтегрувати зовнішні модулі Ditsmod (наприклад, з npmjs.com) у ваш застосунок без жодних налаштувань, просто імпортуючи їх у потрібний модуль. Імпортовані розширення, що входять до певних груп, будуть запускатись у правильній послідовності, навіть якщо вони імпортовані з різних зовнішніх модулів.

Зверніть увагу, що у властивості groups вказуються класи розширень, які виступають у ролі токенів окремих груп:

extensions: [
{ extension: Extension3, groups: [Extension1, Extension2], export: true },
// ...
],

На основі такої конфігурації, буде створено дві окремі групи розширень:

  1. Extension1, Extension3;
  2. Extension2, Extension3.

Якщо в поточному модулі інші розширення також вкажуть ці самі токени груп в groups, дані групи розширяться:

extensions: [
{ extension: Extension4, groups: [Extension1, Extension2], export: true },
// ...
],

Причому не важливо, чи Extension4 оголошено в поточному модулі, чи воно імпортувалось з іншого модуля. Тепер у цих групах будуть такі елементи:

  1. Extension1, Extension3, Extension4;
  2. Extension2, Extension3, Extension4.

Використання ExtensionManager

Якщо певне розширення має залежність від іншого розширення, рекомендується вказувати таку залежність за допомогою ExtensionManager. Він ініціалізує розширення дотримуючись відповідної послідовності, що вказана у конфігах цих розширень, кешує результати роботи методів extension.stage1(), кешує результати роботи груп розширень, кидає помилки про циклічні залежності між розширеннями, і показує весь ланцюжок розширень, що призвів до зациклення. Окрім цього, ExtensionManager дозволяє збирати результати ініціалізації розширень з усього застосунку, а не лише з одного модуля.

Припустимо Extension2 очікує результати роботи методу stage1() від Extension1, тому в конструкторі вказується залежність від ExtensionManager, а у extension2.stage1() викликається this.extensionManager.stage1():

import { injectable, Extension, ExtensionManager } from '@ditsmod/core';
import { Extension1 } from './extension1.js';

@injectable()
export class Extension2 implements Extension<void> {
constructor(private extensionManager: ExtensionManager) {}

async stage1() {
const stage1ExtensionMeta = await this.extensionManager.stage1(Extension1);

stage1ExtensionMeta.groupData.forEach((stage1Meta) => {
const someData = stage1Meta;
// Do something here.
// ...
});
}
}

Зверніть увагу, що stage1ExtensionMeta.groupData завжди буде мати масив результатів, не залежно від того, чи в поточному модулі Extension1 входить у групу розширень, чи ні. Тут stage1ExtensionMeta має наступний інтерфейс:

interface Stage1ExtensionMeta<T = any> {
delay: boolean;
countdown: number;
groupDataPerApp: Stage1ExtensionMetaPerApp<T>[];
moduleName: string,
groupDebugMeta: Stage1DebugMeta<T>[],
groupData: T[],
}

interface Stage1DebugMeta<T = any> {
extension: Extension<T>,
payload: T,
delay: boolean,
countdown: number,
}

Якщо stage1ExtensionMeta.delay === true - це означає, що властивість groupDataPerApp містить дані ще не з усіх модулів, куди імпортовано дане розширення (Extension1). Властивість countdown вказує, у скількох модулях ще залишилось відпрацювати даному розширенню, щоб властивість groupDataPerApp містила дані з усіх модулів. Тобто властивості delay та countdown стосуються лише властивості groupDataPerApp.

У властивості groupData знаходиться масив, де зібрані дані з поточного модуля від одного чи декількох розширень.

Важливо пам'ятати, що для кожного модуля створюється окремий інстанс певного розширення. Наприклад, якщо Extension2 імпортовано у три різні модулі, то Ditsmod буде послідовно обробляти ці три модулі із трьома різними інстансами Extension2. Окрім цього, якщо Extension2 потребує підсумкові дані, наприклад, від Extension1 із чотирьох модулів, а саме Extension2 імпортовано лише у три модулі, це означає, що з одного модуля Extension2 може і не отримати необхідних даних.

В такому випадку потрібно передавати this у якості другого аргументу до extensionManager.stage1:

import { injectable, Extension, ExtensionManager } from '@ditsmod/core';
import { Extension1 } from './extension1.js';

@injectable()
export class Extension2 implements Extension<void> {
constructor(private extensionManager: ExtensionManager) {}

async stage1() {
const stage1ExtensionMeta = await this.extensionManager.stage1(Extension1, this);
if (stage1ExtensionMeta.delay) {
return;
}

stage1ExtensionMeta.groupDataPerApp.forEach((totaStage1Meta) => {
totaStage1Meta.groupData.forEach((metadataPerMod3) => {
// Do something here.
// ...
});
});
}
}

Тобто коли вам потрібно щоб Extension2 отримало дані від Extension1 з усього застосунку, другим аргументом для методу extensionManager.stage1 потрібно передавати this:

const stage1ExtensionMeta = await this.extensionManager.stage1(Extension1, this);

В такому разі гарантується, що інстанс Extension2 отримає дані з усіх модулів, куди імпортовано Extension1. Навіть якщо Extension1 та Extension2 будуть імпортовані у окремі модулі (тобто вони не зустрічаються у спільному модулі), все-одно у підсумку extension2.stage1 отримає дані від extension1.stage1 з усіх модулів.

Токени груп розширень

Давайте повернемось до попереднього прикладу з кодом, де оголошуються дві окремі групи розширень, коли у властивість groups ми передаємо класи двох розширень:

extensions: [
{ extension: Extension3, groups: [Extension1, Extension2], export: true },
// ...
],

І, як вже було сказано, на основі цієї конфігурації створюються дві окремі групи:

  1. Extension1, Extension3;
  2. Extension2, Extension3.

Тепер, коли ви ознайомились з ExtensionManager, важливо наголосити на тому, що пошук груп розширень відбуваються за токенами - за тим класами розширень, які ми раніше вказали у властивості groups:

await this.extensionManager.stage1(Extension1); // Повертаються дані від Extension1 та Extension3
await this.extensionManager.stage1(Extension2); // Повертаються дані від Extension2 та Extension3
await this.extensionManager.stage1(Extension3); // Повертаються дані лише від Extension3

Тобто тут Extension1 та Extension2 фактично виступають у ролі токенів (чи ідентифікаторів) груп.

Динамічне додавання провайдерів

Якщо ви використовуєте @ditsmod/rest, будь-яке розширення може вказати залежність від групи розширень з токеном RouteExtension, щоб динамічно додавати провайдери на будь-якому рівні. Розширення з цієї групи використовують метадані з інтерфейсом MetadataPerMod2 і повертають метадані з інтерфейсом MetadataPerMod3.

Можна проглянути як це зроблено у BodyParserExtension:

import { RouteExtension, HTTP_INTERCEPTORS } from '@ditsmod/rest';

// ...
@injectable()
export class BodyParserExtension implements Extension<void> {
constructor(
protected extensionManager: ExtensionManager,
protected perAppService: PerAppService,
) {}

async stage1() {
const stage1ExtensionMeta = await this.extensionManager.stage1(RouteExtension);
stage1ExtensionMeta.groupData.forEach((metadataPerMod3) => {
const { aControllerMetadata, providersPerMod } = metadataPerMod3;
aControllerMetadata.forEach(({ providersPerRou, providersPerReq, httpMethod, singleton }) => {
// Merging the providers from a module and a controller
const mergedProvidersPerRou = [...metadataPerMod3.providersPerRou, ...providersPerRou];
const mergedProvidersPerReq = [...metadataPerMod3.providersPerReq, ...providersPerReq];

// Creating a hierarchy of injectors.
const injectorPerApp = this.perAppService.injector;
const injectorPerMod = injectorPerApp.resolveAndCreateChild(providersPerMod);
const injectorPerRou = injectorPerMod.resolveAndCreateChild(mergedProvidersPerRou);
if (singleton) {
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: CtxBodyParserInterceptor, 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 });
}
}
});
});
}
}

В даному разі, HTTP-інтерсептор додається в масив providersPerReq, у метадані контролера. Але перед цим, створюється ієрархія інжекторів для того, щоб отримати певну конфігурацію, яка вказує нам чи потрібно додавати такий інтерсептор. Якщо б не потрібно було перевіряти будь-яку умову, ми могли б не створювати ієрархії інжекторів, і зразу б додали інтерсептор на рівні запиту.

Звичайно ж, таке динамічне додавання провайдерів можливе лише перед створенням обробників HTTP-запитів.