Skip to main content

Які найбільш поширені шаблони проектування в 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.

Швидкий приклад

js
// 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Реальна бібліотекаКоли використовувати
Modulemodule.exports + closureОбидваВбудованийЗавжди, кожен файл
SingletonКеш require()ОбидваWinston loggerОдин глобальний ресурс
Observer/PubSubEventEmitterAsyncSocket.ioПотоки подій
MiddlewareЛанцюжок next()AsyncExpressHTTP-пайплайни
FactoryФункція, що повертає нові екземпляриОбидваMongoose modelsДинамічні типи
Promise Chain.then() / async/awaitAsyncAxiosПотоки даних

Як це працює всередині

V8 компілює модуль при першому require(), а потім зберігає результат у Module._cache - C++ хеш-таблиця. Кожний наступний require() того ж шляху повертає кешований об'єкт без повторного виконання файлу. Безкоштовна singleton-поведінка, вбудована в рантайм.

EventEmitter використовує epoll/kqueue через libuv для неблокуючих черг подій. Коли викликаєш .emit(), він запускає зареєстровані callbacks з event loop без блокування. Стек middleware у Express працює через next()-рекурсію всередині Router - між кожним кроком управління повертається до libuv.

Типові помилки

Мутувати стан модуля і очікувати ізоляцію між імпортами:

js
// Помилка - всі імпортери поділяють один і той же об'єкт cache let cache = {}; module.exports = { set: (key, val) => { cache[key] = val; }, get: (key) => cache[key] }; // Один тест робить cache['user'] = 'Alice', наступний теж це бачить

Node кешує модуль один раз. Якщо потрібна ізоляція - повертай factory-функцію, яка створює свіжий closure при кожному виклику.

Забути видалити слухачів EventEmitter:

js
// Помилка - 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 працює в кластері:

js
// Працює в одному процесі, ламається в PM2 cluster if (Database.instance) return Database.instance; Database.instance = new Database();

Кожен worker-процес має власний Module._cache. Singleton - це per-process, не per-cluster. Для спільного стану між воркерами - IPC через модуль cluster або зовнішній Redis.

Middleware без передачі помилок:

js
// Помилка - неопрацьований 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, який повертає один і той самий екземпляр:

js
// Це синглтон, а не 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: Встановити таймер і скинути його при завершенні відповіді:

js
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: спільний логер у застосунку

js
// 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

js
// 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: сервіс замовлень із кількома підписниками

js
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. Продюсер відправляє, споживачі вирішують що робити. Саме тому цей патерн добре масштабується при зростанні системи.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?