Awilixify: NestJS-підхід без NestJS і без переписування проєкту
Привіт! Мене звати Руслан, я JavaScript full-stack розробник у компанії FanTeam. Як і більшість JavaScript-розробників, я багато працював із NestJS.
Хочу поділитися фреймворком, який, на мою думку, може стати достойною альтернативою NestJS: він зберігає його найкращі ідеї та архітектурні підходи, але водночас уникає частини його обмежень і зайвої складності. Якщо ви любите модульність неста, але не любите його громісткість, експерементальні декоратори то стаття може бути вам корисна.
Посилання
- GitHub: github.com/wildstyles/awilixify
- Документація: wildstyles.github.io/awilixify
Кожна команда прагне забезпечити найкращий можливий developer experience у проєктах, які вона розробляє. Нам потрібні інструменти, що покращують архітектуру, явні межі між частинами системи та тестованість. Не лише тому, що з ними приємніше писати код, а тому, що вони роблять бізнес зміни дешевшими, швидшими та менш ризикованими. Саме для цього фрейморки і створені.
На жаль, legacy проекти та сучасні JS фреймворки часто погано ладять.
Чому складно впроваджувати фреймворки
Фреймворки часто працюють за принципом «усе або нічого». Вони не просто додають структуру до вашого застосунку — вони беруть на себе його запуск і контроль. Замість існуючого app.listen(…) фреймворк очікує, що ви запускатимете застосунок через нього.
Для нового проєкту це нормально. Але в production Node.js-застосунку це ризиковано. Сервер уже існує. Роути вже зареєстровані. Порядок middleware уже має значення. Зазвичай ви не хочете змінювати все одразу, адже кожна зміна може вплинути на наявну поведінку.
Безпечніший шлях міграції — рухатися поступово: впроваджувати кращу структуру в нових або ізольованих частинах системи, не чіпаючи те, що вже працює.
Додавання архітектури без змін усього застосунку
Awilixify орієнтований на поступове впровадження: ви зберігаєте застосунок у робочому стані таким, яким він є, і додаєте
// існуючий app.js файл
import { DIContext } from "awilixify";
import express from "express";
const app = express();
app.use(authMiddleware);
app.use(metricsMiddleware);
app.use("/api/users", usersRouter);
// Ізольований модульний DI
const context = DIContext.create(AppModule, {
// робимо доступним інстанс експреса
globalModules: [ExpressModule(app)],
});
await context.init();
// існуючий бутстрап залишається незміним
app.listen(3000);
Нативна реєстрація роутів
Можна запитати: але як тоді зареєструвати роут?
Найпростіший варіант — API HTTP-фреймворку, який ви вже використовуєте. Контролер може отримати інстанс застосунку через DI та зареєструвати роути в методі registerRoutes:
// users.controller.ts
import { Deps } from "./users.module";
class UsersController {
constructor(
private readonly app: Deps["app"],
private readonly usersService: Deps["usersService"],
) {}
registerRoutes() {
this.app.get("/api/new-users", async (req, res) => {
res.json(await this.usersService.list());
});
}
}
Тут немає прихованої абстракції. Інстанс фреймворку передається через global module і стає звичайним provider у
Декоратори без втрати контролю
А що, якщо вам подобається визначати маршрути через декоратори?
Це одна з моїх улюблених частин Awilixify: нативні JS-декоратори залишаються декларативними, без потреби в experimental decorators або reflect-metadata. Вони лише додають мета данні до методу контролера. Initializers виконуються при запуску для кожного задекорованого методу:
// users.controllers.ts
import { GET } from "awilixify/http";
import { Deps } from "./users.module";
class UsersController {
constructor(private readonly usersService: Deps["usersService"]) {}
@GET("/api/new-users")
async list() {
return this.usersService.list();
}
}
Якщо ви знайомі з NestJS, це можна уявити як щось схоже на хук фреймворку при запуску. Guards та interceptors виконуються під час запиту. Initializers виконуються при запуску застосунку. Вони знаходять декоровані методи контролерів і підключають їх до реальної runtime-системи.
Під час context.init() initializer читає данні методу контролера, яку додав декоратор, і виконує фактичну bootstrap-роботу: реєструє HTTP-маршрут, підписується на подію, додає OpenAPI-документацію, планує cron job тощо.
Для прив’язки методу контролера до Express роуту initializer може бути таким:
// http.initializer.ts
import { Initializer } from "awilixify";
import {
HTTP_DECORATOR_STATE_TOKEN,
rollUpHttpDecoratorState,
} from "awilixify/http";
import { Deps } from "./express.module";
class HttpInitializer extends Initializer {
// токен звязаний з одним або групою декораторів
readonly token = HTTP_DECORATOR_STATE_TOKEN;
constructor(private readonly app: Deps["app"]) {
super();
}
// виконується для кожного декорованого методу
initialize(context: InitializerContext) {
const methodState = rollUpHttpDecoratorState(
context.decoratorState.root,
context.metadata,
);
for (const verb of methodState.verbs) {
for (const path of methodState.paths) {
// знову, нативний express, але більш автоматизований
// дає повний контроль над API HTTP фреймворку
this.app[verb.toLowerCase()](path, async (req, res) => {
const result = await context.invoke(req, res);
res.send(result);
});
}
}
}
}
HTTP-декоратори є частиною Awilixify, оскільки HTTP — один із ключових сценаріїв використання. Але цей принцип не обмежується HTTP. За допомогою власних декораторів та initializers ви можете застосувати той самий підхід для cron jobs, event listeners, черг, RabbitMQ consumers або будь-якого іншого startup wiring.
Ціна такого підходу — трохи додаткового коду. Перевага — повний контроль над додаванням роутів/крон джоб... , зберігаючи при цьому зручний API на основі декораторів.
Більше прикладів можна переглянути тут.
DI-залежності на рівні модуля
Можливо, ви запитуєте: що таке тип Deps? Чому не імпортувати й не типізувати кожен параметр конструктура окремо?
Це ще одна корисна частина Awilixify. ModuleDef один раз агрегує типи всіх залежностей, доступних усередині модуля:
// users.module.ts
import { type ModuleDef, createModule } from "awilixify";
type UsersModuleDef = ModuleDef<{
providers: {
usersService: UsersService;
};
}>;
// джерело правди всіх доступний провайдерів у модулі
export type Deps = UsersModuleDef["deps"];
// сам модуль який повинен слідувати UsersModuleDef
export const UsersModule = createModule({
name: "UsersModule",
providers: {
usersService: UsersService,
},
controllers: [UsersController],
});
Так само можна описувати інфраструктурні модулі. Наприклад, глобальний Express-модуль може експортувати і наявний інстанс експреса як провайдер, і застосовувати initializers до кожного декорованого методу контролера:
// express.module.ts
import { createModule, ModuleDef } from "awilixify";
type ExpressModuleDef = ModuleDef<{
providers: {
app: ExpressApp;
};
initializers: {
http: typeof HttpInitializer;
};
exportKeys: ["app"];
exportInitializerKeys: ["http"];
}>;
export type Deps = ExpressModuleDef["deps"];
// обгорнути модуль в функцію щоб зробити його динамічним
export function ExpressModule(app: ExpressApp) {
return createModule({
name: "ExpressModule",
providers: {
app,
},
initializers: {
http: HttpInitializer,
},
exports: ["app"],
initializerExports: ["http"],
});
}
Deps стає єдиним джерелом правди про те, що модуль може використовувати:
- providers, оголошені в самому модулі;
- providers, експортовані з імпортованих модулів;
- providers, експортовані з global modules.
Провайдери більше не повинні щоразу імпортувати тип кожної залежності окремо.
Це зменшує повторювальність імпортів і дозволяє тримати можливі залежності в одному місці — у module definition. Не потрібно імпортувати кожен тип залежності, роблячи екран з імпортів на початку кожного файлу.
Підсумок
Awilixify не намагається замінити або абстрагувати ваш наявний фреймворк. Він адаптується до нього й додає поверх нього правила модульної організації.
Він дає змогу впроваджувати модулі, dependency injection, декоратори та startup wiring у застосунок, який у вас уже є.
Водночас не варто сприймати його як невеликий хелпер навколо Awilix. Модель модулів виходить далеко за межі базового DI: interceptors, потужний CQRS, initializers, module overrides і mocking для тестів, dynamic modules та навіть підтримка React — усе це побудовано навколо однієї ідеї.
На практиці він може забезпечити не менше архітектурної структури, ніж великий фреймворк, але без примусу переносити весь застосунок у його runtime.
Для legacy-застосунків це має значення. Архітектурні покращення корисні лише тоді, коли їх можна реально впровадити, не перетворюючи саму міграцію на найбільший ризик.
Дякую за увагу!
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів