Від робочого скрипту до продакшн-API Node.js: цикл подій, робочі потоки, логування
Як цикл подій, блокуючі операції, робочі потоки, таймаути, конкурентність, структуровані журнали та доступ до баз даних формують API Node.js, яке функціонує ефективно в умовах продакшну.
Джерела: The Node.js Runtime Is the Easy Part
Змусити API на Node.js відповідати — це рідко є складною частиною. Ендпоїнт повертає дані, фронтенд їх відображає, набір тестів працює без проблем. Справжні труднощі починаються у продакшені, коли одночасно надходять тисячі запитів, база даних уповільнюється, API від третіх сторін перестає відповідати, використання пам’яті зростає, або запит, який зазвичай виконується за 200 мілісекунд, раптово займає 12 секунд. Існує ще одне менш помітне питання: чи зрозуміє цей код інший розробник через шість місяців?
На ці запитання відповідають характеристики роботи середовища виконання та кілька принципів проектування. Нижче ви побачите, де модель виконання Node.js проявляє себе найкраще, де вона дає збої, та які підходи допомагають зберігати відповідність сервісу навіть під час справжнього трафіку: переміщення обчислень CPU у робочі потоки, встановлення таймаутів, одночасні мережеві запити, структуроване логування та ретельні запити до бази даних.
У чому насправді є сильні сторони середовища виконання
Стандартне пояснення полягає у тому, що Node.js дозволяє JavaScript виконуватися поза браузером. Це правда, але воно не враховує найцікавішу частину. Насправді Node.js пропонує середовище виконання на основі подій із асинхронним введенням/виведенням та дуже великою екосистемою навколо нього. Така архітектура стає корисною тоді, коли сервер витрачає більшу частину свого часу на очікування чогось іншого:
- запити до бази даних
- вихідні HTTP-запити
- читання та запис файлів
- мережевий трафік в цілому
- очереді повідомлень
- довготривалі з’єднання WebSocket
Простежте один запит через типовий бекенд:
Client
↓
API Server
↓
Database
↓
External API
↓
Response
Майже в жоден момент цього ланцюга сервер не зайнятий обробкою даних. Він переважно чекає на базу даних, а потім на зовнішній API. Модель Node.js розроблена так, щоб використовувати цей час без діяльності для обробки інших запитів, і розуміння цього є ціннішим, ніж будь-який конкретний API.
Як цикл подій змінює вашу ментальну модель
JavaScript у додатку Node.js зазвичай виконується на одній основній нитці, тоді як цикл подій координує асинхронні операції навколо неї. Спрощена схема життєвого циклу запиту:
Incoming Request
↓
JavaScript
↓
Start I/O operation
↓
Don't block execution
↓
Event Loop
↓
I/O completes
↓
Callback / Promise
↓
Continue execution
Ось як це виглядає у звичайному коді:
const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
user,
orders
};
await зупиняє цю функцію, а не весь процес. Поки база даних обробляє перше запитання, движок може вільно обробляти інші функції-відповіді та інші запити. Саме тому один процес Node.js може обробляти велику кількість запитів, які переважно чекають у мережі.
Є одна нюанс, на яку варто звернути увагу: два вищезгадані запити виконуються один за одним, оскільки другий await починається лише після того, як перший завершиться. Ми повернемося до цього у розділі про конкурентність.
Асинхронність — це не те саме, що швидкість
У цій моделі є жорсткий ліміт. Якщо ваш JavaScript виконує тривалі обчислення, ніщо інше на цій нитці не може працювати, доки вони не завершаться. Такий цикл утримує нитку зайнятою протягом усього свого часу існування:
function expensiveOperation() {
let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
result += i;
}
return result;
}
Поки він працює, вхідні запити не приймаються, обіцянки не виконуються, а таймери не спрацьовують за графіком. Не існує механізму планування, який би переривав цей цикл, щоб дати можливість іншим запитам. Саме тому розуміння поведінки середовища виконання є важливішим, ніж запам’ятовування його API.
Проблема блокуючого обробника
Тепер уявіть, що така робота виконується всередині HTTP-обробника. Зміна розміру зображень, обробка великих об’ємів даних чи складні обчислення, які виконуються безпосередньо у шляху запиту, у коді виглядають безневинними:
app.post("/process", async (req, res) => {
const result = expensiveComputation(req.body);
res.json(result);
});
Проблема виникає лише під навантаженням. Одна заявка запускає дорогу функцію та монополізує цикл подій. Усі інші заявки очікують у черзі після неї. Затримки зростають у всіх випадках, а не лише для вимогливих кінцевих точок, і сам процес додатку стає вузьким місцем. Позначення обробника як async не допомагає, оскільки обчислення всередині залишаються синхронними.
Більш міцний дизайн повністю виводить роботу, залежну від CPU, за межі шляху обробки заявок:
Client
↓
Node.js API
↓
Queue
↓
Worker
↓
Heavy computation
↓
Database / Storage
API приймає завдання, поміщає його у чергу та швидко відповідає, тоді як спеціалізовані працівники обробляють завдання у власному темпі та зберігають результати у базі даних чи системі об’єктного зберігання. API залишається реактивним, і ви можете масштабувати працівників незалежно від веб-шару. Саме цей перехід від написання обробників до проектування потоку роботи є основною різницею між простим використанням Node.js та створенням систем на його основі.
Коли не варто використовувати чергу
Черга додає необхідність враховувати інфраструктуру, повторні спроби та принцип кінцевої консистентності. Для коротких завдань, які вимагають синхронного повернення результату, потоки працівників Node зберігають роботу в межах процесу, але не на головному потоці. Для завдань, які виконуються за кілька мілісекунд, їх виконання безпосередньо часто є достатнім. Рішення залежить від тривалості виконання завдання та від того, чи мусить клієнт чекати на відповідь.
Express — це фреймворк, а не середовище виконання
Багато розробників змішують поняття Node.js та Express. Node.js — це середовище виконання. Express — це веб-фреймворк, створений на його основі. Ви можете обслуговувати HTTP, використовуючи лише вбудований модуль http:
const http = require("http");
const server = http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "application/json"
});
res.end(JSON.stringify({
message: "Hello"
}));
});
server.listen(3000);
Це працює, але з ростом додатку на вас лягає обов’язок з роутингу, проміжного програмування, аналізу тіла запиту, перевірки даних та обробки помилок. Фреймворки існують для того, щоб зняти цей тягар з вас. Та сама ідея в Express виглядає ось так:
import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
});
app.listen(3000);
Express не замінює нічого в Node.js; він пропонує зручнішу абстракцію над тими самими примітивами. Чітке розуміння цієї різниці допомагає під час дебагування: повільний ендпоїнт може бути проблемою фреймворку, проблемою часу виконання або ж нічим з цього.
Код для продакшну — це переважно питання меж
Крихітний сервіс може знаходитися в одному файлі. Продакшн-система — ні, тому що різні функції потребують чітких меж між собою. Один розумний план структурирування:
src/
├── api/
│ ├── routes/
│ ├── controllers/
│ └── middleware/
│
├── services/
│ └── userService.js
│
├── repositories/
│ └── userRepository.js
│
├── config/
│ └── environment.js
│
├── jobs/
│ └── emailWorker.js
│
└── app.js
Конкретні назви папок не є ключовими. Важливо те, щоб обов’язки залишалися розділеними:
- Контролер не повинен накопичувати сотні рядків бізнес-правил.
- Репозиторій не повинен нічого знати про те, як формуються відповіді HTTP.
- Сервіс не повинен приймати об’єкти
reqчиresвід Express як вхідні дані.
Саме ці межі дозволяють робити код тестованим окремо, замінювати його по частинах та робити його зрозумілим для наступної людини, яка його читає. Для більш детального розгляду цієї структури дивіться наш посібник з проектування API на Node.js з різними шарами.
Проектування для виникнення помилок у асинхронному коді
Кожен розробник Node.js рано чи пізно дізнається цей принцип:
const data = await fetchData();
Процес виробництва ставить ще складніші запитання щодо цього. Що, якщо fetchData() буде виконуватися 30 секунд? Що, якщо він кине помилку? Що, якщо він кине помилку три рази поспіль? Що, якщо вищестоящий сервіс повністю не працюватиме? Що, якщо запис у базу даних вдасться, а наступний крок зазнає невдачі, залишаючи систему напівоновленою?
Надійний код бекенду визначає поведінку у таких ситуаціях, а не лише описує успішний сценарій. Ось невеликий приклад, який чітко показує можливі невдачі:
async function getUserProfile(userId) {
try {
const user = await userService.getUser(userId);
if (!user) {
throw new Error("User not found");
}
return user;
} catch (error) {
logger.error({
userId,
error: error.message
});
throw error;
}
}
Функція перетворює відсутнього користувача на помилку, фіксує невдачі разом із необхідним контекстом для їх дослідження та знову кидає їх, щоб викликаючий міг вирішити, як на це відреагувати. Основний принцип полягає у тому, що обробка невдач — це рішення з боку проектувальника, а не щось, що додається після інциденту.
Практична застереження: якщо кожен шар фіксує помилки та знову їх кидає, одна невдача створює кілька записів у журналі. Вирішіть, де фіксувати помилки (зазвичай один раз, на межі, яка ними керує), а внутрішні шари нехай додають контекст замість цього.
Ніколи не чекайте нескінченно: таймаути
Ось виклик іншому сервісу без жодного захисту:
const response = await fetch("https://example.com/api");
Якщо віддалений сервер приймає з’єднання, але ніколи не відповідає, цей await може застрягати набагато довше, ніж зможе терпіти користувач, постійно займаючи ресурси. У продакшн-системі потрібен чітко визначений верхній ліміт. За допомогою стандартного AbortController, доступного у сучасних версіях Node.js, можна встановити такий ліміт:
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000);
try {
const response = await fetch(
"https://example.com/api",
{
signal: controller.signal
}
);
return await response.json();
} finally {
clearTimeout(timeout);
}
Таймер припиняє запит через п’ять секунд, а блок finally скидає його, щоб він не спрацьовував після успішної відповіді. Новіші версії середовищ виконання також надають AbortSignal.timeout(ms), який виражає ту саму ідею за допомогою одного виразу.
П’ять секунд — це не магічне число, і воно може бути занадто великим для кінцевої точки, орієнтованої на користувача. Виберіть значення в межах бюджету затримки кінцевої точки. Непохитною умовою є те, що необмежене очікування не є прийнятним способом виникнення помилки.
Конкурентність без паралелізму
Коли дві операції не залежать одна від одної, очікування їх по черзі марнує час:
const user = await getUser();
const orders = await getOrders();
Другий виклик навіть не починається, поки не закінчиться перший, тому загальна затримка дорівнює сумі обох. Зазвичай краще запускати їх одночасно та чекати на завершення обох:
const [user, orders] = await Promise.all([
getUser(),
getOrders()
]);
Обидві заявки зараз виконуються одночасно, і загальна затримка приблизно дорівнює затримці повільнішої з них. Це конкурентність, а не паралельні обчислення: ваш JavaScript все ще виконується в одній потоці, але процеси очікування перекриваються. Різниця має найбільше значення у кінцевих точках, які підключаються до кількох послуг нижчого рівня.
Є два крайні випадки, про які варто пам’ятати. Promise.all відхиляється як тільки будь-який з вхідних елементів відхиляється, тому використовуйте Promise.allSettled, коли допускаються часткові результати. Крім того, одночасне виконання сотень викликів може перевантажити пул баз даних або API з обмеженою швидкістю, тому необхідно обмежувати рівень конкурентності, коли список є великим.
Продуктивність залежить від архітектури
Часто трапляється, що команди дискутують щодо мікрорівневих рішень, таких як класичні...
for
порівняння з лупом
forEach
у той час як той самий кінцевий пункт виконує п’ять зайвих запитів до бази даних. Або щоб побачити невеликий допоміжний інструмент, ретельно налаштований, тоді як API повертає величезні JSON-дані, або коли кожен запит чекає 800 мілісекунд на повільний запит.
У реальних сервісах час відповіді зазвичай визначається наступним:
- способом доступу до бази даних
- затримками в мережі
- неефективними або без індексів запитами
- надмірною серіалізацією
- залежністю від зовнішніх API
- операціями, які зовсім не потрібні
- тиском у пам’яті
- відсутністю або погано спроєктованою кешуванням
Найбільші позитивні зміни зазвичай досягаються завдяки архітектурним змінам, а не синтаксису. Якщо ви хочете структурований спосіб визначення пріоритетів, перегляньте нашу систему пріоритетів для оптимізації продуктивності API Node.js.
Оперативність спостереження є частиною продукту
Сервер, який не може повідомити вас про те, що він зробив, дуже складно керувати. Логування є першим етапом, але такий рядок майже безполезний у продакшені:
console.log("Something went wrong");
Структуроване логування фіксує назву події та ідентифікатори, які потрібні для її дослідження:
logger.error({
event: "payment_failed",
orderId,
userId,
error: error.message
});
Оскільки кожен запис є об’єктом, а не вільним текстом, журнали можна фільтрувати, агрегувати та аналізувати: наприклад, усі події типу payment_failed для одного користувача.
Однак лише журнали залишають прогалини. У продакшн-системах зазвичай використовуються три взаємодоповнюючі сигнали:
Logs
↓
What happened?
Metrics
↓
How often / how much?
Traces
↓
Where did the request spend its time?
Журнали повідомляють про те, що сталося, метрики — про те, як часто та в якій кількості, а трейси показують, де запит витрачав час у різних сервісах. Коли хтось повідомляє, що «застосунок працює повільно», ці сигнали дозволяють вам відповідати з доказами, а не з припущеннями.
Середовище виконання не проектує ваш доступ до даних
Вибір Node.js не визначає базу даних. Він добре працює з PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch та багатьма іншими. Набагато важливішим є те, як код взаємодіє з обраною базою даних. Цей ендпоїнт виглядає абсолютно логічним:
app.get("/products", async (req, res) => {
const products = await db.query(
"SELECT * FROM products"
);
res.json(products);
});
У продакшені це відразу ж породжує низку запитань:
- Скільки рядків містить сьогодні
products, а скільки — наступного року? - Чи є пагінація?
- Чи підтримується цей запит індексом?
- Чи може ця відповідь зрости до 50 MB?
- Як він поводиться під конкуруючим трафіком?
- Чи встановлено розумні обмеження для пулу з’єднань?
- Чи може помилка бази даних потрапити до клієнта?
Node.js не відповість на жодне з цих запитань за вас. Ці рішення належать тому, хто проектує сервіс.
Node.js як один із багатьох компонентів
Node.js є поширеним у хмарних середовищах частково тому, що його легкий модель процесів добре підходить до контейнерів та сервісно-орієнтованих архітектур. У таких системах він зазвичай є лише однією з складових серед кількох: шар API разом із чергами, працівниками, кешами, базами даних та іншими сервісами, кожен з яких має власні обов’язки. Він не обов’язково має бути всією системою, а справжній бекенд рідко є просто „Express API“.
Де Node.js потребує допомоги
Жоден середовище виконання не підходить для кожного типу завдань. Node.js проявляє найкращі результати, коли робота переважно полягає у операціях вводу-виводу, а завдання, що вимагають багато обчислювальних ресурсів CPU, потребують більш ретельного проектування. Приклади:
- обробка зображень у великих масштабах
- кодування відео
- інтенсивні наукові обчислення
- деякі завдання машинного навчання
- складні криптографічні операції
- дуже масштабні трансформації даних
Жоден із цих факторів не виключає Node.js з таких систем. Це означає, що процес, який обробляє HTTP-запити, не повинен виконувати важку роботу. Варіанти включають:
- робочі потоки всередині одного процесу
- окремі фонові процеси-робітники
- очереді між API та робітниками
- спеціалізовані сервіси для виконання ресурсомістких операцій
- інші мови чи середовища виконання, які краще підходять для обробки даних
Мета не полягає у тому, щоб примушувати вирішувати кожну проблему за допомогою Node.js, а у тому, щоб використовувати його там, де його модель виконання є оптимальною.
Питання „чому“, а не лише „як“
Спочатку постає питання про те, як створити API за допомогою Node.js. З набуттям досвіду з’являється питання про те, чому API слід створювати саме таким чином, що призводить до іншого набору запитань:
- Чи повинна ця операція бути синхронною чи виконуватися через очередь?
- Чи потрібно кешувати відповідь?
- Чи належать ці дані цьому сервісу?
- Чи повинен цей ендпоїнт залежати від трьох інших сервісів, і що станеться, якщо один з них зламається?
- Де слід реалізувати повторні спроби?
- Що потрібно фіксувати в журналі та що потрібно вимірювати?
- Як це буде працювати під навантаженням?
Жоден з цих питань не є специфічним для Node.js. Це питання інженерії програмного забезпечення, а Node.js надає інструменти для реалізації відповідей.
Короткий чек-лист для продакшну
- Цикл подій робить Node.js чудовим для одночасного виконання завдань у мережі та на диску, але будь-яка синхронна робота процесора зупиняє кожен запит у цій нитці.
- Перенесіть важкі обчислення за межі шляху запиту за допомогою робочих ниток, черг та окремих сервісів.
- Розглядайте сценарії збоїв та явні таймаути як частину проектування; необмежене очікування є помилкою.
Promise.all, а також обмежуйте кількість одночасних операцій, коли їх багато.