От рабочего скрипта к производственному 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.
Как цикл событий меняет ваше представление
В приложении Node.js JavaScript обычно выполняется на одной основной нити, в то время как цикл событий координирует асинхронные операции вокруг нее. Упрощенная картина жизненного цикла запроса:
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 не помогает, поскольку вычисления внутри него по-прежнему синхронные.
Более надёжный дизайн полностью выносит работу, зависящую от процессора, за пределы пути обработки запросов:
Client
↓
Node.js API
↓
Queue
↓
Worker
↓
Heavy computation
↓
Database / Storage
API принимает задание, помещает его в очередь и быстро отвечает, в то время как специализированные рабочие процессы обрабатывают задания в собственном темпе и записывают результаты в базу данных или систему хранения объектов. API остается отзывчивым, и вы можете масштабировать рабочие процессы независимо от веб-слоя. Именно этот сдвиг — от написания обработчиков к проектированию потока работы — во многом отличает простое знакомство с Node.js от создания на его основе сложных систем.
Когда не стоит использовать очередь
Очередь добавляет необходимость учитывать инфраструктуру, повторные попытки и принцип конечной консистентности. Для кратких задач, требующих обработки на процессоре и синхронного возврата результата, потоки Node.js обрабатывают работу внутри процесса, но не на главном потоке. Для задач, занимающих несколько миллисекунд, их выполнение непосредственно в коде часто бывает достаточно. Решение зависит от времени выполнения задачи и от того, должен ли клиент ждать ответа.
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 МБ?
- Как он ведет себя при одновременном входе запросов?
- Установлены ли разумные ограничения для пула соединений?
- Может ли ошибка базы данных просочиться к клиенту?
Node.js не ответит на ни один из этих вопросов за вас. Решения по этим вопросам принимает тот, кто проектирует сервис.
Node.js как один из многих компонентов
Node.js широко используется в облачных средах отчасти потому, что его легковесная модель процессов хорошо сочетается с контейнерами и проектированием, ориентированным на сервисы. В таких системах он обычно является лишь одним из элементов среди многих других: слой API наряду с очередями, рабочими процессами, кэшами, базами данных и другими сервисами, каждый из которых выполняет свои функции. Он не обязан быть всей системой, и настоящий бэкенд редко представляет собой просто «API на базе Express».
Где Node.js нуждается в поддержке
Нет такой среды выполнения, которая подошла бы для любой нагрузки. Node.js проявляет наилучшие результаты, когда основная часть работы связана с вводом-выводом, а задачи, требующие большой нагрузки на процессор, требуют более тщательного проектирования. Примеры:
- обработка изображений в масштабах
- кодирование видео
- интенсивные научные вычисления
- некоторые задачи машинного обучения
- тяжелые криптографические операции
- очень масштабные преобразования данных
Ничто из этого не исключает Node.js из использования в подобных системах. Это означает, что процесс, обрабатывающий HTTP-запросы, не должен выполнять тяжелую работу. Варианты включают:
- рабочие потоки внутри одного процесса
- отдельные фоновые процессы-рабочие
- очереди между API и рабочими процессами
- специализированные сервисы для выполнения ресурсоемких операций
- другие языки или среды выполнения, более подходящие для таких вычислений
Цель не в том, чтобы принуждать использовать Node.js для каждой задачи, а в том, чтобы применять его там, где его модель выполнения наиболее подходит.
Вопрос «почему», а не только «как»
В начале возникает вопрос о том, как построить API с использованием Node.js. С накоплением опыта появляется вопрос о том, почему API следует создавать именно таким образом, что приводит к другому набору вопросов:
- Должна ли эта операция выполняться синхронно или через очередь?
- Следует ли кэшировать ответ?
- Принадлежат ли эти данные этому сервису?
- Должен ли этот эндпоинт зависеть от трех других сервисов, и что произойдет, если один из них выйдет из строя?
- Где следует выполнять попытки повторной отправки запроса?
- Что следует записывать в логи и что следует измерять?
- Как будет вести себя этот сервис при высокой нагрузке?
Ни один из этих вопросов не связан исключительно с Node.js. Это вопросы инженерии программного обеспечения, а Node.js предоставляет инструменты для реализации ответов на них.
Краткий чек-лист для производственной среды
- Цикл событий делает Node.js отличным инструментом для одновременного ожидания сетевых операций и операций с диском, но любая синхронная работа процессора блокирует все запросы на этой нити.
- Перенесите тяжелые вычисления вне пути обработки запросов с помощью рабочих нитей, очередей или отдельных сервисов.
- Рассматривайте сценарии сбоев и явные таймауты как часть проектирования; неограниченное ожидание является багом.
- Выполняйте независимые запросы последовательных операций одновременно с помощью
Promise.all, а также ограничивайте уровень одновременности при широком распространении запросов. - Проблемы с производительностью обычно связаны с запросами, размером данных и количеством сетевых переходов, а не с синтаксисом циклов.
- Структурированные логи, метрики и трейсы превращают жалобы из производственной среды в конкретные доказательства.
- Фреймворки и среды выполнения являются инструментами; архитектура, границы и доступ к данным остаются ответственностью разработчика.