Які найбільш поширені шаблони проектування в Node.js?
Шаблони проектування в Node.js - це готові рішення, побудовані навколо event loop, неблокуючого I/O та кешування require(). Шість найпоширеніших у продакшені: Module, Singleton, Observer/PubSub, Middleware, Factory та Promise Chain.
Теорія
TL;DR
- Node.js адаптує класичні ідеї GoF під async callbacks, потоки та event loop. Модулі - це кухонні станції (кожна робить одне), синглтон - єдиний спільний ніж шефа, pubsub - це голос, що роздає замовлення по всій кухні.
- Головна відмінність від Java: ці патерни використовують кеш
require(),EventEmitterтаasync/awaitзамість ієрархій класів та блокуючих конструкторів. - Правило: якщо більше 3 компонентів взаємодіють - бери PubSub замість прямих викликів.
- Node кешує кожен результат
require()уModule._cache, тому будь-який експортований об'єкт автоматично поводиться як синглтон. - Middleware - це просто ланцюжок функцій через
next(). Ось і вся ментальна модель для Express.
Швидкий приклад
// Module pattern - природно в Node.js (counter.js)
let count = 0; // Приватне - нічого зовні не може це прочитати або змінити напряму
module.exports = {
inc: () => ++count,
get: () => count
};
// app.js
const counter = require('./counter');
counter.inc();
console.log(counter.get()); // 1
console.log(counter.count); // undefined - інкапсуляція працюєNode кешує модуль при першому require(), тому count зберігається у всіх файлах, які імпортують цей модуль. Module та Singleton разом - 8 рядків, без жодного класу.
Чому патерни Node.js відрізняються від OOP-мов
Класичні GoF-патерни припускають, що ти створюєш об'єкти через конструктори і сам керуєш їх lifecycle. Node більшу частину цього пропускає. Кеш модулів бере на себе singleton-поведінку автоматично. EventEmitter покриває pub/sub без окремої бібліотеки. async/await замінює складну роботу з потоками, заради якої ці патерни й придумали.
Тому замість Singleton-класу з приватним конструктором і статичним getInstance() - просто експортуєш екземпляр. Все. Патерн є, він просто виглядає інакше.
Коли який патерн використовувати
- Інкапсулювати приватний стан - Module. За замовчуванням для кожного файлу.
- Поділитися одним екземпляром по всьому застосунку - Singleton. З'єднання з БД, логери, конфіги.
- Розв'язати продюсерів подій від споживачів - Observer/PubSub. Обробка замовлень, WebSocket, події користувача.
- Виконати ланцюг обробки запиту - Middleware. Express-роути, автентифікація, логування.
- Створювати об'єкти без прив'язки до конкретного класу - Factory. Адаптери БД, плагіни, типи на основі конфігу.
- Послідовно обробляти async-кроки - Promise Chain або async iterators. API-пайплайни, трансформації даних.
Таблиця порівняння
| Патерн | Механізм | Async-fit | Реальна бібліотека | Коли використовувати |
|---|---|---|---|---|
| Module | module.exports + closure | Обидва | Вбудований | Завжди, кожен файл |
| Singleton | Кеш require() | Обидва | Winston logger | Один глобальний ресурс |
| Observer/PubSub | EventEmitter | Async | Socket.io | Потоки подій |
| Middleware | Ланцюжок next() | Async | Express | HTTP-пайплайни |
| Factory | Функція, що повертає нові екземпляри | Обидва | Mongoose models | Динамічні типи |
| Promise Chain | .then() / async/await | Async | Axios | Потоки даних |
Як це працює всередині
V8 компілює модуль при першому require(), а потім зберігає результат у Module._cache - C++ хеш-таблиця. Кожний наступний require() того ж шляху повертає кешований об'єкт без повторного виконання файлу. Безкоштовна singleton-поведінка, вбудована в рантайм.
EventEmitter використовує epoll/kqueue через libuv для неблокуючих черг подій. Коли викликаєш .emit(), він запускає зареєстровані callbacks з event loop без блокування. Стек middleware у Express працює через next()-рекурсію всередині Router - між кожним кроком управління повертається до libuv.
Типові помилки
Мутувати стан модуля і очікувати ізоляцію між імпортами:
// Помилка - всі імпортери поділяють один і той же об'єкт cache
let cache = {};
module.exports = {
set: (key, val) => { cache[key] = val; },
get: (key) => cache[key]
};
// Один тест робить cache['user'] = 'Alice', наступний теж це бачитьNode кешує модуль один раз. Якщо потрібна ізоляція - повертай factory-функцію, яка створює свіжий closure при кожному виклику.
Забути видалити слухачів EventEmitter:
// Помилка - handler залишається в пам'яті після закриття сокета
socket.on('data', handler);
socket.end();
// Правильно
const handler = (data) => processData(data);
socket.on('data', handler);
socket.on('close', () => socket.removeListener('data', handler));Забуті слухачі - найпоширеніша причина memory leak у Node-застосунках. Node попереджає, коли один emitter перевищує 10 слухачів за замовчуванням. Це попередження не шум - це сигнал.
Вважати, що singleton працює в кластері:
// Працює в одному процесі, ламається в PM2 cluster
if (Database.instance) return Database.instance;
Database.instance = new Database();Кожен worker-процес має власний Module._cache. Singleton - це per-process, не per-cluster. Для спільного стану між воркерами - IPC через модуль cluster або зовнішній Redis.
Middleware без передачі помилок:
// Помилка - неопрацьований throw підвішує запит
app.use((req, res, next) => {
parseBody(req); // кидає при поганому JSON
next();
});
// Правильно
app.use((req, res, next) => {
try {
parseBody(req);
next();
} catch (e) {
next(e); // Express error handler бере звідси
}
});Factory, який повертає один і той самий екземпляр:
// Це синглтон, а не factory
const getDB = () => db; // Один і той самий об'єкт щоразу
// Справжній factory - новий екземпляр при кожному виклику
const createDB = (config) => new DatabaseAdapter(config);Якщо твій factory завжди повертає той самий об'єкт - це синглтон із зайвими кроками. Factory за визначенням створює нові екземпляри.
Де зустрічається в реальних проектах
- Express - Middleware для автентифікації, логування та rate limiting майже в кожному Node-застосунку.
- Socket.io - PubSub через
EventEmitterдля реалтайм-фіч: чат, live-дашборди, спільна робота. - Winston - Singleton-логер, створений один раз і імпортований скрізь через кеш
require(). - Mongoose - Factory для динамічних моделей:
mongoose.model('User', schema)повертає конструктор на основі конфігу. - Node Redis - Singleton-клієнт із вбудованими PubSub-каналами для розподіленого обміну повідомленнями між процесами.
Я бачив кодові бази, де команди не використовували жодних патернів свідомо - і в результаті отримали випадкові синглтони (спільний стан, що мутується), випадкові middleware (функції, що передають callback) та випадкові factory (функції, що повертають різні форми об'єктів). Розпізнавати і називати патерни - це те, що відокремлює підтримуваний код від купи функцій, які просто якось працюють.
Питання для співбесіди
Q: Як кеш модулів Node перетворює кожен модуль на синглтон?
A: Перший require() виконує файл і зберігає exports у Module._cache. Всі наступні require() повертають кешоване значення без повторного запуску файлу. Один запуск, один об'єкт, на процес.
Q: Яка різниця між EventEmitter PubSub та Redis PubSub?
A: EventEmitter - in-memory і працює тільки всередині одного Node-процесу. Redis PubSub - розподілений: повідомлення доходять до підписників на різних серверах або процесах.
Q: Як реалізувати middleware з таймаутом 5 секунд?
A: Встановити таймер і скинути його при завершенні відповіді:
const timeout = (ms) => (req, res, next) => {
const timer = setTimeout(() => next(new Error('Request timeout')), ms);
res.on('finish', () => clearTimeout(timer));
next();
};
app.use(timeout(5000));Q: Як поділити singleton-з'єднання між воркерами PM2?
A: Майстер-процес створює з'єднання та передає повідомлення воркерам через cluster.worker.send() і process.on('message'). Або використовувати зовнішній пул з'єднань, наприклад pg-pool.
Q: Що станеться, якщо видалити модуль із require.cache?
A: Наступний require() знову виконає файл і створить новий екземпляр. Твій синглтон зник. Саме так працюють hot-reload-інструменти - і це поширена причина плутанини в тестах, які чистять кеш між запусками.
Q: Чому в Node.js уникають class-based singleton?
A: Кешування require() вже робить це автоматично. Клас з приватним конструктором додає boilerplate без жодної реальної переваги. module.exports = new MyService() робить те саме в один рядок.
Приклади
Module і Singleton: спільний логер у застосунку
// logger.js - один екземпляр для всього застосунку
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.simple(),
transports: [new winston.transports.Console()]
});
module.exports = logger; // Кешується після першого require
// server.js
const logger = require('./logger');
logger.info('Server started on port 3000');
// routes/users.js
const logger = require('./logger'); // Той самий об'єкт, новий екземпляр не створюється
logger.info('GET /users called');Обидва файли ділять рівно той самий logger. Без класу, без статичних властивостей - просто кеш модулів. Саме так і працює Winston у реальних проектах.
Ланцюжок middleware: логування та автентифікація в Express
// middleware/index.js
const logRequest = (req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next(); // Завжди передаємо далі
};
const authenticate = (req, res, next) => {
const token = req.headers.authorization;
if (token === 'Bearer secret-token') {
req.user = { id: 1, role: 'admin' };
next(); // Прикріплюємо user і продовжуємо
} else {
res.status(401).json({ error: 'Unauthorized' }); // Ланцюжок зупиняється тут
}
};
module.exports = { logRequest, authenticate };
// app.js
const express = require('express');
const { logRequest, authenticate } = require('./middleware');
const app = express();
app.use(logRequest); // Виконується для кожного запиту
app.use('/api', authenticate); // Тільки для /api/*
app.get('/api/profile', (req, res) => {
res.json({ user: req.user });
});
// GET /api/profile з правильним токеном:
// Лог: 2024-01-15T10:00:00.000Z GET /api/profile
// Відповідь: { user: { id: 1, role: "admin" } }
// Без токена: 401 { error: 'Unauthorized' }Ланцюжок зупиняється на authenticate, якщо токен неправильний. Route handler не виконується. Це і є поведінка Chain of Responsibility - кожен крок вирішує: продовжити чи зупинити.
Observer: сервіс замовлень із кількома підписниками
const EventEmitter = require('events');
class OrderService extends EventEmitter {
async placeOrder(order) {
const saved = { ...order, id: Date.now(), status: 'placed' };
this.emit('orderPlaced', saved); // Один emit, всі handlers реагують
return saved;
}
}
const orderService = new OrderService();
// Три незалежні обробники - OrderService нічого про них не знає
orderService.on('orderPlaced', (order) => {
console.log(`Email надіслано для замовлення ${order.id}`);
});
orderService.on('orderPlaced', (order) => {
console.log(`Оновлено запаси: ${order.item}`);
});
orderService.on('orderPlaced', (order) => {
console.log(`Аналітика: замовлення ${order.id} зафіксовано`);
});
(async () => {
await orderService.placeOrder({ item: 'Laptop', qty: 1 });
// Вивід:
// Email надіслано для замовлення 1705312800000
// Оновлено запаси: Laptop
// Аналітика: замовлення 1705312800000 зафіксовано
})();Додати четвертий обробник - наприклад, Slack-сповіщення - не потребує жодних змін у OrderService. Продюсер відправляє, споживачі вирішують що робити. Саме тому цей патерн добре масштабується при зростанні системи.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.