Модулі
Одним з головних елементів архітектури Ditsmod - є його модулі. Але чим хороша саме модульна архітектура? - Модульність дозволяє компонувати різні автономні елементи і складати з них масштабований застосунок. Саме завдяки автономності модулів, великі проекти простіше розробляти, тестувати, деплоїти та обслуговувати. Також модульність спрощує перехід на мікросервісну архітектуру, якщо в майбутньому ви приймете рішення, що ваш Ditsmod-застосунок потребує горизонтального масштабування.
Модульна архітектура дозволяє ізолювати в одному модулі декілька файлів коду, що можуть мати різні ролі, але спільну спеціалізацію. Модуль можна порівняти з оркестром, в якому є різні інструменти, але усі вони створюють спільну музику. З іншого боку, потреба в ізоляції різних модулів виникає через те, що вони можуть мати різну спеціалізацію і через це - можуть заважати один-одному. Продовжуючи аналогію з людьми, якщо в одому кабінеті розмістити поліцію та музикантів, або брокерів і перекладачів, швидше за все, вони заважатимуть один-одному. Саме тому для модуля важлива вузька спеціалізація.
Модулі є найбільшими будівельними блоками застосунку, і в їхніх метаданих можуть декларуватись такі складові як:
- контролери, що приймають HTTP-запити та відправляють HTTP-відповід і;
- сервіси, де описується бізнес логіка застосунку;
- інтерсептори та ґарди, що дозволяють автоматизувати обробку HTTP-запитів по типовим патернам;
- декоратори та розширення, що дозволяють доповнювати застосунок новими правилами та новою поведінкою;
- інші класи, інтерфейси, хелпери, типи даних, що призначаються для роботи поточного модуля.
Модулі є двох типів:
- Root module (кореневий модуль).
- Feature module (модуль фіч). Найчастіше саме цей тип модуля використовується для публікації на npmjs.com.
Кореневий модуль
До кореневого модуля підв'язуються інші модулі, він є єдиним на увесь застосунок, а його клас рекомендовано називати AppModule. TypeScript клас стає кореневим модулем Ditsmod завдяки одному з таких декораторів як rootModule, restRootModule, trpcRootModule і т.д., в залежності від архітектурного стилю, який ви використовуєте. Наприклад, якщо ви використовуєте REST, кореневий модуль оголошується наступним чином:
import { restRootModule } from '@ditsmod/rest';
@restRootModule()
export class AppModule {}
Загалом, в декоратор restRootModule можна передавати об'єкт з такими властивостями:
import { restRootModule } from '@ditsmod/rest';
@restRootModule({
imports: [], // Імпорт модулів
appends: [], // Долучення модулів, що мають контролери
providersPerApp: [], // Провайдери на рівні застосунку
providersPerMod: [], // ...на рівні модуля
providersPerRou: [], // ...на рівні роуту
providersPerReq: [], // ...на рівні HTTP запиту
exports: [], // Експорт модулів та провайдерів з поточного модуля
extensions: [], // Розширення
extensionsMeta: {}, // Дані для роботи розширень
resolvedCollisionPerApp: [], // Вирішення колізій імпортованих класів на рівні застосунку
resolvedCollisionPerMod: [], // ...на рівні модуля
resolvedCollisionPerRou: [], // ...на рівні роуту
resolvedCollisionPerReq: [], // ...на рівні HTTP запиту
controllers: [], // Список контролерів в поточному модулі
})
export class AppModule {}
Модуль фіч
TypeScript клас стає feature модулем Ditsmod завдяки одному з таких декораторів як featureModule, restModule, trpcModule і т.д., в залежності від ар хітектурного стилю, який ви використовуєте. Наприклад, якщо ви використовуєте REST, модуль фіч оголошується наступним чином:
import { restModule } from '@ditsmod/rest';
@restModule()
export class SomeModule {}
Файли модулів рекомендується називати із закінченням *.module.ts, а назви їхніх класів - із закінченням *Module.
Він може містити точно такі самі метадані як і кореневі модулі, за виключенням властивості resolvedCollisionPerApp. Окрім того, що feature module можна декларувати прямо у застосунку, його також можна публікувати на npmjs.com.
Експорт, імпорт, долучення
Модуль, де ви декларуєте певні провайдери, називається модулем-хостом для цих провайдерів. А коли ви використовуєте дані провайдери у зовнішньому модулі, то цей зовнішній модуль називається модулем-споживачем даних провайдерів.
Для того, щоб модуль-споживач міг використовувати провайдери з модуля-хоста, спочатку необ хідно експортувати відповідні токени провайдерів з модуля-хоста. Робиться це у метаданих, які передаються у декоратор модуля фіч чи кореневого модуля. Наприклад, якщо ви використовуєте REST, це робиться наступним чином:
import { restModule } from '@ditsmod/rest';
import { Service1 } from './service1.js';
import { Service2 } from './service2.js';
import { Service3 } from './service3.js';
function useFactory(s2: Service2) {
return; // ...
}
@restModule({
providersPerApp: [Service1],
providersPerReq: [Service2, { token: 'some-token', deps: [Service2], useFactory }],
exports: ['some-token'],
})
export class Module1 {}
В даному прикладі, беручи до уваги експортовані токени, Ditsmod буде шукати експортовані провайдери в масиві providersPerReq. Експортувати провайдери, що передаються у providersPerApp, не має сенсу, оскільки з цього масиву буде сформовано інжектор на рівні застосунку. Тобто провайдери з масиву providersPerApp будуть доступними для будь-якого модуля, на будь-якому рівні, і без експорту.
Оскільки з модуля-хоста вам потрібно експортувати лише токени провайдерів, а не самі провайдери, у властивість exports не можна безпосередньо передавати провайдери у формі об'єкта.
Майте на увазі, що з модуля-хоста потрібно експортувати лише ті провайдери, які безпосередньо будуть використовуватись у модулях-споживачах. У прикладі вище, провайдер із some-token залежить від Service2, але Service2 не потрібно експортувати, якщо він безпосередньо не використовується у модулі-споживачу. Таким чином забезпечується інкапсуляція модулів.
Експортувати контролери не має сенсу, оскільки експорт стосується тільки провайдерів.
Експорт провайдерів з модуля фіч
Експортуючи токени з модуля-хоста, ви тим самим декларуєте, що відповідні провайдери можуть використовуватись у модулях-споживачах, якщо вони імпортуватимуть даний модуль-хост.
Експорт провайдерів з кореневого модуля
Експорт провайдерів з кореневого модуля означає, що ці провайдери будуть автоматично додаватись до кожного модуля, який оголошено у вашому застосунку. Разом з тим, ці провайдери не будуть додаватись до зовнішніх модулів, тобто до тих модулів, що ви ставите за допомогою пакетних менеджерів, типу npm, yarn і т.д. Наприклад, якщо ви використовуєте REST, це робиться наступним чином:
import { restRootModule } from '@ditsmod/rest';
import { BodyParserModule } from '@ditsmod/body-parser';
import { Service1 } from './service1.js';
import { Module1 } from './module1.js';
@restRootModule({
imports: [Module1, BodyParserModule],
providersPerReq: [Service1],
exports: [Service1, Module1],
})
export class AppModule {}
В даному випадку, Service1 не буде додаватись до BodyParserModule (бо це зовнішній модуль), натомість буде додаватись до усіх інших модулів застосунку на рівні HTTP-запиту. Як бачите, експортувати можна також і цілі модулі. В даному разі, усі провайдери, що експортуються з Module1, також будуть додаватись до кожного модуля застосунку на відповідному рівні.
Інколи є потреба додавати цілий масив провайдерів, і щоб експортувати їхні токени, є хелпер getTokens():
import { restRootModule } from '@ditsmod/rest';
import { getTokens } from '@ditsmod/core';
import { defaultProviders } from './default-providers.js';
@restRootModule({
providersPerReq: [...defaultProviders],
exports: [...getTokens(defaultProviders)],
})
export class AppModule {}
Тут defaultProviders - це масив провайдерів, і якщо б ви цей масив спробували експортувати, ви б могли отримати помилку, якщо б там були провайдери у вигляді об'єкту. Треба експортувати лише токени провайдерів, тому і використовується getTokens().
Імпорт модуля
Імпортувати окремий провайдер в модуль не можна, але можна імпортувати цілий модуль з усіма провайдерами та розширеннями, що експортуються з нього. Наприклад, якщо ви використовуєте REST, це робиться наступним чином:
import { restModule } from '@ditsmod/rest';
import { Module1 } from './module1.js';
@restModule({
imports: [
Module1
]
})
export class Module2 {}
Якщо з Module1 експортується, наприклад, Service1, то тепер цей сервіс можна використовувати у Module2. Разом з тим, якщо Module1 має контролери, у такій формі імпорту вони будуть ігноруватись. Щоб Ditsmod брав до уваги контролери з імпортованого модуля, цей модуль потрібно імпортувати з префіксом, що передається у path:
import { restModule } from '@ditsmod/rest';
import { Module1 } from './module1.js';
@restModule({
imports: [
{ module: Module1, path: '' }
]
})
export class Module2 {}
Хоча тут path має порожній рядок, але для Ditsmod наявність path означає:
- що потрібно брати до уваги також і контролери з імпортованого модуля;
- використовувати
pathу якості префіксу для усіх контролерів, що імпортуються зModule1.
Як бачите, у попередньому прикладі імпортується на цей раз і не провайдер, і не модуль, а об'єкт. Цей об'єкт має наступний інтерфейс:
DynamicModule
interface DynamicModule {
id?: string;
module: ModuleType<M>;
/**
* ProviderBuilder per the application.
*/
providersPerApp?: ProviderBuilder | Provider[] = [];
/**
* ProviderBuilder per a module.
*/
providersPerMod?: ProviderBuilder | Provider[] = [];
/**
* ProviderBuilder per a route.
*/
providersPerRou?: ProviderBuilder | Provider[] = [];
/**
* ProviderBuilder per a request.
*/
providersPerReq?: ProviderBuilder | Provider[] = [];
/**
* List of modules, `DynamicModule` or tokens of providers exported by this
* module.
*/
exports?: any[];
/**
* This property allows you to pass any information to extensions.
*
* You must follow this rule: data for one extension - one key in `extensionsMeta` object.
*/
extensionsMeta?: E;
}
Щоб скоротити довжину запису при імпорті об'єкту з цим типом, інколи доцільно написати статичний метод у модулі, який імпортується. Щоб наочно побачити це, давайте візьмемо знову попередній приклад:
import { restModule } from '@ditsmod/rest';
import { Module1 } from './module1.js';
@restModule({
imports: [
{ module: Module1, path: '' }
]
})
export class Module2 {}
Якщо б ви оголошували Module1 і знали, що цей модуль є сенс імпортувати багато разів в різні модулі з різними префіксами, в такому разі в даному класі можна написати статичний метод, що повертає об'єкт, спеціально призначений для імпорту:
// ...
export class Module1 {
static withPrefix(path: string) {
return {
module: this,
path,
};
}
}
Тепер об'єкт, що повертає цей метод, можна імпортувати наступним чином:
// ...
@restModule({
imports: [
Module1.withPrefix('some-prefix')
]
})
export class Module2 {}
Статичні методи дозволяють спрощувати передачу параметрів модулів.
Щоб TypeScript контролював, що саме повертає статичний мето д для імпорту, рекомендується використовувати інтерфейс DynamicModule:
import { DynamicModule } from '@ditsmod/core';
// ...
export class Module1 {
static withOpts(someParams: SomeParams): DynamicModule<Module1> {
return {
module: this,
// ...
};
}
}
Імпортуються класи чи інстанси класів?
Давайде розглянемо конкретну ситуацію. В наступному прикладі кожен із провайдерів є класом. Зверніть увагу, в які масиви передаються ці провайдери, і що саме експортується.
// ...
@restModule({
providersPerReq: [Provider1],
exports: [Provider1],
})
export class Module1 {}
Припустимо ми цей модуль будемо імпортувати у Module2, в якого своїх провайдерів немає:
// ...
@restModule({
imports: [Module1],
// ...
})
export class Module2 {}
В результаті такого імпорту, модуль-споживач (Module2) тепер матиме Provider1 на рівні HTTP-запиту, тому що у модулі-хості (Module1) його оголошено на цьому рівні. Під час роботи з Provider1, його інстанси будуть створюватись окремо в обох модулях. Між модулями може бути спільним одинак, тільки якщо його провайдер оголошено на рівні застосунку. В нашому прикладі провайдер оголошено на рівні HTTP-запиту, тому у Module1 та Module2 інстанси Provider1 не будуть спільними на жодному із рівнів.
Отже можна стверджувати, що імпортуються класи, а не їхні інстанси.