Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Які найбільш поширені шаблони проектування в Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Шаблони проектування в Node.js** - це готові рішення для асинхронної, подієвої архітектури. Найпоширеніші: Module (замикання (closure) через `module.exports`), Singleton (кеш `require()`), Observer (`EventEmitter`), Middleware (ланцюжок `next()`), Factory (динамічне створення об'єктів) та Promise Chain для async-потоків. ```js // Кожен модуль автоматично поводиться як синглтон module.exports = { inc: () => ++count, get: () => count }; const c1 = require('./counter'); const c2 = require('./counter'); // c1 === c2: true ``` **Головне:** `require()` кешує exports модуля, тому спільний стан є синглтоном за замовчуванням - без жодного додаткового коду.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Шаблони проектування в 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 | Реальна бібліотека | Коли використовувати | |--------|----------|-----------|--------------------|----------------------| | 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. ### Типові помилки **Мутувати стан модуля і очікувати ізоляцію між імпортами:** ```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`. Продюсер відправляє, споживачі вирішують що робити. Саме тому цей патерн добре масштабується при зростанні системи.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.