Асинхронный веб-сервер - основа большинства современных интернет-сервисов: сайтов, API, интернет-магазинов, систем личных кабинетов и платформ доставки контента.
Такой сервер принимает запросы и обслуживает их, не удерживая отдельный поток выполнения в ожидании каждой операции ввода-вывода. Пока приложение получает данные из базы, читает файл или обращается к внешнему API, Node.js может продолжать обрабатывать другие соединения.
Благодаря этому одна программа способна обслуживать множество параллельных клиентов, хотя у неё есть важное ограничение: длительные вычисления в JavaScript могут задержать все запросы, выполняемые в том же потоке.
Построить надёжный сервер на Node.js означает не просто вызвать метод createServer. Нужно продумать маршруты, формат ответов, обработку ошибок, проверку входных данных, работу с асинхронными операциями и защиту от чрезмерной нагрузки. Мы разберём основы асинхронной модели Node.js, создадим простой HTTP-сервер, постепенно превратим его в основу интернет-API и обсудим, как подготовить приложение к реальному трафику.
Примеры ориентированы на современную поддерживаемую версию Node.js и стандартные модули платформы, поэтому для первого проекта не понадобятся сторонние библиотеки.
Это помогает увидеть, какие задачи решает сам runtime, а какие остаются обязанностью разработчика. В примерах используется JavaScript с синтаксисом async/await; при желании те же принципы можно применять в TypeScript или с веб-фреймворками.
Что означает асинхронность в Node.js
Node.js выполняет JavaScript в основном потоке, а операции ввода-вывода организует через событийную модель. Когда приложение начинает, например, чтение файла, оно может передать задачу системным механизмам и не ждать её завершения, блокируя весь код.
После окончания операции runtime уведомляет программу, и продолжение работы запускается позднее.
Это не означает, что вся операция обязательно выполняется "в фоне" одним и тем же способом.
Сетевые операции часто обслуживаются механизмами операционной системы, а часть операций, включая некоторые файловые и криптографические задачи, может использовать пул потоков libuv. Детали зависят от вида операции и платформы, но практический вывод один: корректно организованный ввод-вывод позволяет обслуживать множество запросов без создания потока JavaScript на каждого посетителя.
Асинхронная модель особенно полезна для интернет-приложений, в которых запросы часто ждут внешние ресурсы. Это может быть база данных, файловое хранилище, платёжная система, сервис рекомендаций или API доставки.
Пока один клиент ожидает ответ от такого ресурса, сервер способен принять запрос следующего пользователя.
Однако асинхронность не ускоряет автоматически любой код. Если обработчик выполняет длинный цикл, обрабатывает крупное изображение синхронной функцией или рассчитывает сложную модель в основном потоке, другие задачи JavaScript будут ждать. Поэтому сервер Node.js хорошо подходит для большого объёма сетевого ввода-вывода, но ресурсоёмкие вычисления следует планировать отдельно.
| Тип работы | Пример | Рекомендация |
|---|---|---|
| Сетевой ввод-вывод | Запрос к внешнему API | Использовать асинхронный клиент и тайм-ауты |
| Работа с базой данных | Чтение списка товаров | Применять пул соединений и ограничивать время ожидания |
| Файловый ввод-вывод | Чтение конфигурации или загрузка файла | Предпочитать асинхронные методы и потоковую передачу |
| Интенсивные вычисления | Обработка видео или расчёт большой модели | Выносить в Worker Threads, очередь задач или отдельный сервис |
Подготовка проекта
Сначала установите актуальную поддерживаемую версию Node.js. Для рабочего сервиса желательно выбирать LTS-релиз: такие версии рассчитаны на длительную эксплуатацию и регулярно получают исправления безопасности. Проверить наличие среды можно командами node --version и npm --version.
Создайте каталог проекта и файл с исходным кодом. Команда
npm init -yсоздаст базовый файлpackage.json, в котором удобно указать сценарии запуска, зависимости и версию Node.js.
Даже если сервер пока не использует пакеты из npm, манифест позволит запускать приложение одинаково на компьютере разработчика и в среде развёртывания.
Например, базовая структура может выглядеть так:
internet-server/
package.json
server.js
.env
В файле package.json можно определить команду запуска и требование к среде. При использовании переменных окружения не следует хранить в репозитории пароли, ключи и другие секреты.
Файл .env обычно добавляют в исключения системы контроля версий, а значения для production задают средствами платформы развёртывания.
{
"name": "internet-server",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"start": "node server.js"
}
}
Поле "type": "module" позволяет использовать стандартные ES-модули: import и export. Если проект настроен на CommonJS, те же примеры можно переписать с помощью require. Важно придерживаться одного стиля модулей внутри приложения, чтобы не усложнять загрузку и тестирование кода.
Первый HTTP-сервер
Модуль node:http входит в стандартную поставку Node.js. Он предоставляет интерфейс для создания HTTP-сервера без дополнительных зависимостей.
Обработчик получает объект запроса с методом, адресом и заголовками, а также объект ответа, через который приложение задаёт статус, заголовки и тело сообщения.
Минимальный сервер можно записать так:
import { createServer } from 'node:http';
const server = createServer((request, response) => {
response.writeHead(200, {
'content-type': 'text/plain; charset=utf-8'
});
response.end('Сервер работает');
});
const port = Number(process.env.PORT) || 3000;
server.listen(port, '0.0.0.0', () => {
console.log(`Сервер запущен на порту ${port}`);
});
Функция createServer принимает обработчик, который вызывается для каждого HTTP-запроса. В приведённом варианте обработчик синхронно отправляет короткий текст и завершает ответ методом end.
Сервер слушает адрес 0.0.0.0, поэтому его можно открыть не только через локальный интерфейс внутри процесса, но и через сетевые интерфейсы машины, если это разрешают правила сети.
Для разработки адрес localhost часто предпочтительнее, поскольку он ограничивает доступ локальным компьютером. Для контейнера или виртуальной машины обычно требуется слушать все интерфейсы и отдельно настраивать сетевую защиту. Значение порта лучше читать из переменной окружения: хостинговая платформа может назначить порт самостоятельно.
Метод response.end завершает отправку ответа. Если вызвать его повторно или продолжить записывать данные после завершения, поведение приложения будет ошибочным.
По мере усложнения сервера полезно создавать отдельные функции для формирования JSON, ошибок и заголовков, чтобы каждый обработчик придерживался одинаковых правил.
Асинхронный обработчик запросов
Важное отличие реального интернет-сервера от демонстрационного примера состоит в необходимости ждать асинхронные операции. Синтаксис async/await помогает записывать такой код в последовательной форме, сохраняя неблокирующее поведение ввода-вывода.
Слово await приостанавливает выполнение конкретной асинхронной функции, но не останавливает весь сервер.
У createServer нет специального ограничения, запрещающего передать функцию с ключевым словом async. Но сам HTTP-модуль не превращает отклонённое обещание в корректный ответ клиенту. Поэтому обработчик необходимо обернуть в собственный механизм перехвата ошибок.
import { createServer } from 'node:http';
async function handleRequest(request, response) {
const result = await loadPublicData();
response.writeHead(200, {
'content-type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(result));
}
const server = createServer((request, response) => {
handleRequest(request, response).catch((error) => {
console.error('Ошибка обработки запроса:', error);
if (!response.headersSent) {
response.writeHead(500, {
'content-type': 'application/json; charset=utf-8'
});
}
response.end(JSON.stringify({
error: 'internal_server_error'
}));
});
});
Здесь loadPublicData обозначает асинхронную операцию, например чтение из базы. В рабочем коде ошибка должна приводить к безопасному сообщению клиенту и подробной записи в журнале.
Внутренние сообщения, стек вызовов и сведения о конфигурации не следует отправлять посетителю: они могут раскрыть структуру приложения или чувствительные данные.
Проверка response.headersSent нужна, если ошибка произошла после начала отправки ответа. После передачи заголовков изменить статус уже нельзя. В такой ситуации зачастую остаётся закрыть соединение, а не пытаться начать новый ответ.
Для потоковой передачи данных логика будет ещё сложнее, поэтому ошибки потока нужно обрабатывать отдельно.
Функция handleRequest на этом этапе иллюстрирует общий принцип, но производственный сервер требует единого обработчика ошибок. Иначе маршруты начинают по-разному возвращать ошибки и забывают завершать соединение. Централизованная обработка помогает согласовать HTTP-статусы, формат JSON и журналирование.
Маршрутизация и разбор адреса
Веб-сервер обычно предоставляет несколько адресов: например, страницу состояния, список товаров и карточку товара. Для разбора адреса удобно использовать встроенный класс URL.
Он корректно отделяет путь от параметров запроса и избавляет от хрупких проверок строк, в которых легко перепутать адрес и его query-параметры.
Ниже приведён каркас маршрутизации для небольшого JSON API. Он проверяет метод и путь, а затем отвечает на известные запросы:
import { createServer } from 'node:http';
const server = createServer(async (request, response) => {
try {
const url = new URL(
request.url ?? '/',
`http://${request.headers.host ?? 'localhost'}`
);
if (request.method === 'GET' && url.pathname === '/health') {
sendJson(response, 200, { status: 'ok' });
return;
}
if (request.method === 'GET' && url.pathname === '/api/products') {
const products = await getProducts();
sendJson(response, 200, { data: products });
return;
}
sendJson(response, 404, { error: 'not_found' });
} catch (error) {
console.error(error);
sendJson(response, 500, { error: 'internal_server_error' });
}
});
function sendJson(response, statusCode, value) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff'
});
response.end(JSON.stringify(value));
}
Для интернет-сервиса важно явно определять поведение для неизвестных маршрутов и методов. Ответ 404 Not Found сообщает, что запрошенный ресурс не существует, тогда как 405 Method Not Allowed уместен, когда путь известен, но конкретный HTTP-метод не поддерживается.
При использовании 405 серверу следует указать допустимые методы в заголовке Allow.
Параметры адреса можно получить из объекта URL, например через url.searchParams.get('page'). Значения из строки запроса всегда являются недоверенными данными: их нужно преобразовать, проверить диапазон и применить безопасные значения по умолчанию.
Для пагинации особенно важно ограничивать максимальный размер страницы, чтобы один запрос не пытался загрузить всю базу.
В демонстрационном коде значение заголовка Host участвует в построении базового адреса. В приложениях за обратным прокси необходимо точно определить, каким заголовкам можно доверять: сведения вроде X-Forwarded-For могут подделываться клиентом, если прокси настроен неправильно.
В простом сервере не следует использовать такие заголовки для принятия решений о доступе без проверки сетевой архитектуры.
Чтение тела запроса
Тело POST- или PUT-запроса поступает в виде потока. Сервер не обязан заранее получить весь объём данных целиком, поэтому чтение происходит частями.
Для небольших JSON-сообщений поток можно собрать в строку, но необходимо ограничить размер тела: без лимита недобросовестный клиент способен отправить очень большой объём данных и занять память процесса.
Пример функции для небольшого JSON API:
async function readJson(request, maxBytes = 1_000_000) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of request) {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
const error = new Error('Request body is too large');
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
if (text.length === 0) {
return {};
}
try {
return JSON.parse(text);
} catch {
const error = new Error('Invalid JSON');
error.statusCode = 400;
throw error;
}
}
Размер в примере составляет примерно один мегабайт и приведён только как иллюстрация. Подходящий предел зависит от назначения маршрута: форма обратной связи обычно не нуждается в крупных сообщениях, а импорт данных может обрабатываться отдельным способом.
Ошибку превышения лимита обычно сопоставляют со статусом 413 Payload Too Large.
Проверки синтаксиса JSON недостаточно. После чтения необходимо проверить структуру объекта: обязательные поля, типы, допустимую длину строк и значения перечислений.
Например, поле цены должно быть положительным числом в ожидаемом диапазоне, а идентификатор товара - иметь установленный формат. Такая проверка уменьшает количество ошибок и помогает не передавать некорректные данные в базу.
Для загрузки файлов не следует собирать весь запрос в памяти при помощи Buffer.concat. Потоковая обработка позволяет передавать данные в файловое хранилище постепенно.
При этом всё равно нужны ограничения по размеру, проверка типа файла, защита от небезопасных имён и правила хранения, исключающие прямое исполнение загруженного содержимого.
Подключение асинхронного хранилища
Большинство интернет-сервисов хранит данные не только в памяти процесса. Сервер обращается к базе данных или внешнему хранилищу, а результат приходит асинхронно. Конкретный клиент выбирают с учётом модели данных, требований к транзакциям, доступности и операционной поддержки.
Важно изолировать работу с хранилищем от маршрутов, чтобы серверная логика не зависела от деталей конкретной библиотеки.
Например, маршрут может вызывать функцию слоя данных:
async function getProducts({ limit = 20, offset = 0 } = {}) {
return database.product.findMany({
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' }
});
}
Здесь объект database условен: его предоставляет выбранный клиент базы. Главный принцип - не создавать новое соединение к базе для каждого запроса. Обычно применяется пул соединений, который повторно использует ограниченное число подключений.
Слишком большой пул может перегрузить базу, а слишком маленький - стать очередью, задерживающей ответы API.
Запросы к базе необходимо ограничивать по времени ожидания, если это поддерживает используемый клиент. Без тайм-аута зависшая операция может удерживать ресурсы дольше необходимого.
Кроме того, важны транзакции для связанных изменений: если интернет-магазин уменьшает остаток товара и создаёт заказ, эти действия должны выполняться согласованно, чтобы ошибка посередине не оставила систему в противоречивом состоянии.
Запросы к внешним API также требуют тайм-аутов и обработки недоступности. Ответ соседнего сервиса может задержаться, вернуть ошибку или оказаться некорректным.
Иногда уместен ограниченный повторный запрос с задержкой, но повторять операцию создания платежа без механизма идемпотентности опасно: при сетевом сбое платёж может быть выполнен дважды. Для изменяющих запросов следует проектировать устойчивую к повторам семантику.
Ошибки, статусы и единый формат ответа
HTTP-статус помогает клиенту понять результат запроса без анализа текста сообщения. Для успешного получения данных обычно используют 200 OK, для успешного создания ресурса - 201 Created, а для запроса без тела ответа - 204 No Content.
Неправильный запрос может получить 400 Bad Request, отсутствие авторизации - 401 Unauthorized, недостаток прав - 403 Forbidden, а отсутствие ресурса - 404 Not Found.
Сбой приложения или базы обычно обозначают статусом 500 Internal Server Error. Если сервис временно не способен обслуживать запрос из-за перегрузки или планового ограничения, могут применяться подходящие статусы семейства 5xx.
Не следует возвращать 200 для каждой ситуации, помещая ошибку только в JSON: это мешает клиентам, браузерам, системам мониторинга и промежуточным прокси корректно реагировать на сбои.
Формат ошибок полезно сделать предсказуемым. Например, API может возвращать поле error с кодом ошибки и безопасное человекочитаемое описание. Внутренний стек вызовов при этом остаётся в серверном журнале. Ответ не должен раскрывать SQL-запросы, содержимое переменных окружения, локальные пути или сведения о том, какие учётные записи существуют, если это помогает злоумышленнику.
Удобная архитектура разделяет ожидаемые ошибки клиента и непредвиденные ошибки сервера. Ошибку валидации можно преобразовать в
400с указанием проблемных полей, а внезапное исключение - в общий500.
Это позволяет клиенту исправлять запрос, не раскрывая внутреннее устройство сервиса.
Полезно учитывать особый случай, когда клиент сам закрывает соединение. К моменту завершения асинхронной операции отправлять результат уже может быть некому.
В более сложных приложениях для отмены ненужных запросов применяют AbortController и связывают сигнал отмены с обработчиком запроса и внешними клиентами, если они поддерживают такую возможность.
Безопасность интернет-сервера
Асинхронная программа не становится безопасной автоматически. Каждый запрос может содержать произвольные заголовки, параметры и тело, а сервер должен рассматривать их как недоверенный ввод.
Проверяйте тип, размер и диапазон данных на границе приложения и не используйте пользовательский ввод для формирования команд оболочки, путей к файлам или запросов к базе без безопасного API и параметризации.
Если API использует сессии или токены, необходимо корректно проверять аутентификацию и авторизацию. Эти понятия различаются: аутентификация отвечает на вопрос, кто отправил запрос, а авторизация - что этому субъекту разрешено делать. Наличие действительного токена само по себе не должно давать пользователю доступ к данным других аккаунтов.
Проверки прав нужны для конкретного объекта и конкретного действия.
Секреты следует хранить вне исходного кода, использовать минимально необходимые права и регулярно менять ключи по установленной процедуре. В журналах нельзя без необходимости записывать пароли, токены доступа, платёжные реквизиты и полные тела запросов.
Для защиты соединения с сайтом применяется HTTPS, обычно завершаемый на правильно настроенном прокси или балансировщике; внутренний трафик также требует отдельной оценки рисков.
Следует ограничивать частоту запросов там, где это важно: например, на маршрутах входа, восстановления пароля и дорогих поисковых операциях. Ограничение может учитывать адрес клиента, учётную запись или другие сигналы, но должно быть настроено с учётом прокси и особенностей пользователей.
На уровне приложения также полезны пределы размера тела, количества одновременных запросов и времени обработки.
Проверяйте входные данные и ограничивайте их размер.
Используйте параметризованные запросы к базе данных.
Возвращайте безопасные сообщения, а подробности записывайте в контролируемый журнал.
Настройте HTTPS, управление секретами и минимальные права доступа.
Добавляйте ограничения частоты запросов и тайм-ауты для внешних зависимостей.
Не доверяйте клиентским заголовкам, если их источник не проверен через доверенный прокси.
Потоки и передача больших данных
HTTP-запрос и ответ в Node.js представлены потоками. Потоковая модель важна для загрузки файлов, выгрузки отчётов и передачи больших объёмов данных.
Если прочитать многогигабайтный файл целиком в память, несколько параллельных запросов могут привести к исчерпанию памяти и аварийному завершению процесса.
При передаче файла на диск можно использовать асинхронный конвейер из модуля node:stream/promises. Конвейер учитывает обратное давление: если приёмник временно не успевает обрабатывать данные, источник не должен без ограничений накапливать новые части в памяти.
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(request, createWriteStream('/safe/storage/upload.bin'));
Этот пример не является готовым обработчиком загрузки. В реальном сервисе нельзя принимать произвольный путь из запроса и подставлять его в createWriteStream: пользователь способен попытаться записать данные за пределы предназначенного каталога.
Имя файла следует формировать на сервере, проверять максимальный размер, анализировать содержимое и определять, будет ли файл доступен напрямую или только через контролируемый endpoint.
При потоковой отправке ответа нужно корректно обрабатывать ошибки как источника, так и клиента. Если пользователь закрыл вкладку, передача может стать бесполезной, и дорогую операцию желательно отменить.
Для статических ресурсов на практике часто используют специализированный веб-сервер или CDN, а Node.js оставляют для API и динамической логики.
Обратное давление имеет значение не только для файлов.
Оно проявляется при создании крупных CSV-отчётов, экспорте базы и передаче событий через длительное соединение. Правильная потоковая архитектура помогает удерживать расход памяти под контролем, но требует аккуратной работы с закрытием соединения, тайм-аутами и ошибками.
Производительность и масштабирование
Скорость сервера зависит не только от Node.js. На неё влияют база данных, сеть, внешние API, формат данных, объём ответа, настройки TLS, файловое хранилище и число доступных ресурсов.
Поэтому прежде чем менять архитектуру, нужно измерить поведение приложения под реалистичной нагрузкой и определить узкое место.
Для наблюдения за приложением полезно собирать задержки запросов, количество ответов с ошибками, число активных соединений, расход памяти и загрузку CPU. Среднее время ответа недостаточно: оно может скрыть медленные запросы, которые портят опыт значительной части посетителей.
Вместе с ним анализируют, например, медиану и высокие процентили времени ответа, соблюдая правила конфиденциальности и политики хранения данных.
Node.js хорошо справляется с большим количеством ожидающих сетевых операций, но один основной поток JavaScript может стать ограничением для вычислительной нагрузки. Используйте профилирование, чтобы найти функции, блокирующие event loop. Если CPU-затратную работу нельзя упростить или перенести, её можно выполнять в Worker Threads, отдельном процессе или специализированном сервисе.
Запуск нескольких процессов позволяет использовать несколько ядер и повышает пропускную способность, но усложняет эксплуатацию. В production эту задачу обычно решают оркестратор контейнеров, менеджер процессов или платформа облака, а перед процессами размещают балансировщик.
Приложение при этом должно быть готово к тому, что запросы попадут на разные экземпляры, поэтому состояние пользовательской сессии обычно хранят вне локальной памяти процесса.
Кэширование уменьшает число обращений к медленным источникам, но требует ясной политики согласованности и времени жизни данных. Публичный каталог товаров может кэшироваться иначе, чем остаток товара или персональная информация.
Ошибка в правилах кэширования способна показать одному пользователю данные другого, поэтому ключи кэша и заголовки ответа нужно проектировать с учётом авторизации и приватности.
| Наблюдаемый симптом | Вероятная причина | Что проверить |
|---|---|---|
| Высокая задержка при низкой загрузке CPU | Ожидание базы или внешнего API | Тайм-ауты, пул соединений, медленные запросы и сетевые метрики |
| Высокая загрузка CPU | Вычисления в основном потоке или большое число преобразований | Профиль CPU, event loop, объём сериализации |
| Рост памяти при загрузке файлов | Буферизация больших тел или отсутствие потоковой обработки | Лимиты размера, потоки и утечки ссылок |
| Ошибки при всплесках трафика | Перегрузка сервиса или зависимости | Ограничение параллелизма, очереди, лимиты базы и масштабирование |
Тайм-ауты, отмена и управление нагрузкой
Входящий HTTP-запрос не должен заставлять сервер ждать бесконечно. У запроса может быть тайм-аут, но его значение нужно согласовать с прокси, балансировщиком и ожидаемым временем операции.
Слишком короткий лимит приведёт к обрывам нормальных запросов, а слишком длинный позволит медленным соединениям надолго занимать ресурсы.
Тайм-ауты нужно задавать и для исходящих вызовов. Когда приложение обращается к внешнему сервису, оно может передать ему сигнал отмены, например через AbortSignal.timeout, если используемая версия Node.js и клиентская библиотека это поддерживают.
После превышения лимита операция прекращается или становится отменённой, а маршрут возвращает контролируемый ответ.
Ограничение параллелизма особенно полезно для операций, которые расходуют общие ресурсы: массового экспорта, отправки писем или обращения к внешнему API с жёсткой квотой.
Если запускать неограниченное число одинаковых задач, сервер может превратить короткий всплеск трафика в очередь, перегрузить базу и увеличить задержку для всех пользователей.
Для длительных фоновых задач лучше отвечать клиенту быстро и передавать работу в очередь. Например, интернет-магазин может принять запрос на формирование отчёта, создать задачу и вернуть идентификатор, по которому клиент позднее проверит её состояние.
Такой подход отделяет время жизни HTTP-соединения от времени выполнения тяжёлой работы и упрощает повторный запуск после временного сбоя.
Логи, мониторинг и диагностика
Сервер в интернете должен сообщать, что происходит внутри, но журналирование необходимо проектировать разумно.
Запись на каждый запрос может содержать идентификатор запроса, метод, маршрут, статус и длительность. Не стоит без анализа писать полный URL с чувствительными параметрами, содержимое авторизационных заголовков и личные данные пользователя.
У каждого запроса полезно иметь корреляционный идентификатор. Он помогает сопоставить запись веб-сервера, логи базы и сведения от внешних сервисов.
Если идентификатор поступает от клиента, его следует проверить или сформировать заново в доверенной точке, чтобы произвольное содержимое не ломало формат журналов и не создавало неоднозначность.
Мониторинг должен включать не только доступность порта. Простая проверка состояния может подтверждать, что процесс запущен, но не доказывает, что база доступна и приложение способно выполнить полезную работу.
Часто разделяют лёгкий liveness-сигнал и readiness-проверку зависимостей: первую используют для обнаружения зависшего процесса, вторую - чтобы решить, готов ли экземпляр принимать трафик.
Для диагностики инцидентов полезны структурированные журналы, метрики и трассировка распределённых запросов. Важно заранее определить, кто получает уведомление при росте ошибок и какие действия выполняются.
Наблюдаемость помогает не только устранить сбой, но и проверить, действительно ли оптимизация уменьшила задержку, а не перенесла нагрузку на другой компонент.
Корректное завершение работы
Процесс сервера может останавливаться при обновлении приложения, масштабировании или перезапуске среды. Если завершать его мгновенно, активные запросы оборвутся, а операции записи могут не успеть закончиться.
Поэтому серверу требуется graceful shutdown: перестать принимать новые соединения, дать текущим запросам ограниченное время на завершение и закрыть подключения к базе и другим ресурсам.
Node.js предоставляет методы управления HTTP-сервером, а приложение может слушать сигналы операционной системы. Упрощённая схема выглядит так:
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Получен сигнал ${signal}, завершаем работу`);
server.close(async (error) => {
try {
await database.close();
} catch (closeError) {
console.error('Ошибка закрытия базы:', closeError);
}
if (error) {
console.error('Ошибка закрытия сервера:', error);
process.exitCode = 1;
}
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Объект database в примере условный, а конкретный метод завершения зависит от используемого клиента.
В production обычно добавляют защитный таймер: если запросы не завершились за заданное время, процесс принудительно останавливается.
Таймер должен быть согласован с настройками платформы, иначе оркестратор может завершить контейнер раньше, чем закончится аккуратное выключение.
При получении сигнала приложение также может начать возвращать отказ готовности, чтобы балансировщик прекратил направлять на него новые запросы.
Такая подготовка особенно важна при развёртывании без заметного перерыва для посетителей. Проверяйте сценарий остановки не только вручную, но и в тестовой среде, максимально похожей на production.
Тестирование сервера
Тесты помогают проверить не только отдельные функции, но и фактическое поведение HTTP API. Для начала достаточно убедиться, что сервер возвращает ожидаемые статусы, заголовки и JSON для успешных запросов, ошибок валидации и неизвестных маршрутов.
Встроенный тестовый модуль node:test позволяет создавать тесты без обязательного внешнего фреймворка.
При интеграционном тестировании удобно запускать сервер на временном порту или создавать сервер без немедленного открытия фиксированного порта, а затем отправлять реальные HTTP-запросы.
Тесты следует изолировать от production-базы: используют отдельную тестовую базу, временное хранилище или контролируемые заглушки. Иначе повторный запуск тестов может изменять реальные данные и создавать труднообъяснимые сбои.
Проверяйте не только успешные сценарии, но и пределы системы: слишком большое тело запроса, неверный JSON, медленный внешний сервис, закрытие соединения клиентом, недоступность базы и некорректные параметры пагинации.
Для платёжных и других критичных операций отдельно проверяют повторные запросы и идемпотентность.
Нагрузочное тестирование должно напоминать ожидаемую картину использования сервиса. Если измерять только один короткий запрос, результат мало скажет о поведении при длительной работе, всплесках трафика или медленных зависимостях.
Проводите такие испытания в разрешённой среде, контролируйте влияние на базы и внешние сервисы и не направляйте генератор нагрузки на чужую инфраструктуру.
Когда стоит использовать фреймворк
Низкоуровневый модуль node:http полезен, чтобы понять основу протокола и собрать небольшой сервис. Но по мере роста приложения вручную поддерживать маршрутизацию, middleware, проверку схем и единообразную обработку ошибок становится неудобно.
Веб-фреймворк может предоставить готовые средства для этих задач и уменьшить количество повторяющегося кода.
Выбирать библиотеку следует не только по популярности. Учитывайте срок поддержки, совместимость с используемой версией Node.js, доступность обновлений безопасности, производительность в нужном сценарии и понятность команде.
Проверьте, как фреймворк работает с асинхронными обработчиками, ошибками, потоками, валидацией и подключением middleware.
Фреймворк не отменяет базовых требований. Он не делает автоматически корректными права доступа, не гарантирует безопасную работу с файлами и не предотвращает перегрузку базы.
Архитектурные границы всё равно полезно сохранять: маршруты принимают HTTP-запросы, слой предметной логики реализует правила приложения, а слой данных отвечает за хранилище.
Для крупных приложений важна не только скорость разработки отдельного маршрута, но и удобство сопровождения. Структура модулей, соглашения о форматах ответов, тестирование и документация API помогают нескольким разработчикам работать согласованно.
Если проект вырастает из простого сервера, стоит вводить эти правила постепенно, а не ждать, пока изменения станут рискованными.
Полный минимальный пример API
Ниже собраны основные принципы: отдельная функция для JSON, корректная маршрутизация, чтение тела с ограничением и централизованный перехват ошибок. Для наглядности данные хранятся в памяти, поэтому после перезапуска процесса список сбрасывается.
В реальном сервисе вместо массива подключают базу данных и добавляют проверку доступа.
import { createServer } from 'node:http';
const products = [
{ id: 1, name: 'Сетевой адаптер', price: 1490 }
];
function sendJson(response, statusCode, value) {
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'x-content-type-options': 'nosniff'
});
response.end(JSON.stringify(value));
}
async function readJson(request, maxBytes = 100_000) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) {
const error = new Error('Слишком большой запрос');
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
if (!text) return {};
try {
return JSON.parse(text);
} catch {
const error = new Error('Некорректный JSON');
error.statusCode = 400;
throw error;
}
}
async function handleRequest(request, response) {
const url = new URL(
request.url ?? '/',
`http://${request.headers.host ?? 'localhost'}`
);
if (request.method === 'GET' && url.pathname === '/health') {
sendJson(response, 200, { status: 'ok' });
return;
}
if (request.method === 'GET' && url.pathname === '/api/products') {
sendJson(response, 200, { data: products });
return;
}
if (request.method === 'POST' && url.pathname === '/api/products') {
const body = await readJson(request);
const name = typeof body.name === 'string' ? body.name.trim() : '';
const price = Number(body.price);
if (!name || name.length > 120 || !Number.isFinite(price) || price <= 0) {
sendJson(response, 400, { error: 'invalid_product' });
return;
}
const product = {
id: products.length + 1,
name,
price
};
products.push(product);
sendJson(response, 201, { data: product });
return;
}
sendJson(response, 404, { error: 'not_found' });
}
const server = createServer((request, response) => {
handleRequest(request, response).catch((error) => {
console.error(error);
if (response.destroyed) return;
const statusCode = Number.isInteger(error.statusCode)
? error.statusCode
: 500;
sendJson(response, statusCode, {
error: statusCode === 500
? 'internal_server_error'
: 'invalid_request'
});
});
});
const port = Number(process.env.PORT) || 3000;
server.listen(port, '0.0.0.0', () => {
console.log(`API доступно на порту ${port}`);
});
Этот код демонстрирует механизм, но не является готовой системой для интернет-магазина. В нём нет аутентификации, постоянного хранения, ограничителя частоты запросов, полноценного журналирования и обработки конкурирующих изменений.
Например, два одновременных запроса могут получить одинаковый идентификатор из массива, а данные пропадут при перезапуске. Такие вопросы решаются базой данных и продуманной бизнес-логикой.
Для локальной проверки запустите команду
npm start, затем отправьте GET-запрос на/healthили/api/products. Создание товара выполняется POST-запросом с заголовкомContent-Type: application/jsonи телом вроде{"name":"Кабель","price":590}.
Для production необходимо использовать HTTPS и настроить сетевой доступ так, чтобы порт приложения не был открыт шире, чем требуется.
По мере развития проекта полезно перенести обработчики в отдельные модули, добавить слой предметной логики и подключить схему валидации. Маршруты должны оставаться тонкими: получить данные HTTP-запроса, передать их приложению, преобразовать результат в ответ.
Это упрощает тестирование и позволяет заменить хранилище или веб-фреймворк без переписывания правил бизнеса.
Типичные ошибки при разработке
Одна из распространённых ошибок - считать, что наличие async автоматически делает любой код безопасным и быстрым.
Асинхронная функция всё ещё может выполнять тяжёлые вычисления синхронно, а неограниченное число параллельных операций способно перегрузить базу. Следите за тем, сколько задач одновременно запускает каждый маршрут и сколько ресурсов они потребляют.
Вторая ошибка - забывать обрабатывать отклонённые обещания. Если асинхронный обработчик выбросил исключение, а код не перехватил его, приложение может не сформировать корректный ответ.
Все границы между HTTP-обработчиком, внешней библиотекой и фоновым процессом должны иметь понятную стратегию ошибок.
Третья ошибка - принимать данные без ограничения. Это касается не только файлов, но и параметров поиска, глубины пагинации, количества элементов в массиве и длины строк.
Разумные лимиты защищают как сервер, так и базу данных, а клиентам дают предсказуемые правила использования API.
Наконец, не стоит переносить всё состояние в глобальные переменные процесса. Локальная память не синхронизируется между несколькими экземплярами сервера и исчезает после перезапуска.
Она подходит для короткоживущих кешей и конфигурации, но учётные записи, корзины, заказы и другие важные данные должны храниться в подходящем постоянном хранилище.
Краткая памятка по запуску в интернете
Перед публикацией проверьте, что приложение слушает нужный порт, корректно завершает работу и не раскрывает внутренние сведения в ответах. Убедитесь, что переменные окружения задаются средой развёртывания, а секреты исключены из репозитория.
Все внешние зависимости должны иметь понятные тайм-ауты и обработку отказа.
Затем проверьте HTTPS, настройки обратного прокси, лимиты тела запросов, правила доступа и журналирование. Если используется балансировщик, отдельно настройте проверки готовности, время ожидания и поведение при обновлении экземпляров.
Публичные файлы и тяжёлые статические ресурсы часто выгоднее обслуживать через CDN или специализированный сервер, оставляя Node.js для динамических запросов.
Не начинайте масштабирование с предположений. Сначала измерьте фактическое время ответа и ресурсы на характерной нагрузке, затем устраните узкое место: медленный запрос к базе, лишнюю сериализацию, блокирующий код или недостаток экземпляров.
Такой порядок помогает избежать ненужного усложнения инфраструктуры и точнее оценить результат изменений.
Асинхронный веб-сервер на Node.js строится вокруг неблокирующего ввода-вывода, но его надёжность определяется целым набором решений: структурой HTTP API, проверкой данных, ограничением ресурсов, обработкой ошибок, безопасностью и наблюдаемостью.
Начните с небольшого сервера на стандартном модуле node:http, проверьте основные сценарии и постепенно добавляйте хранилище, маршрутизацию и защиту.
Когда приложение будет готово к реальному интернет-трафику, измеряйте его работу, учитывайте поведение зависимостей и не забывайте, что асинхронный код должен оставаться управляемым, а не просто выглядеть современно.
Примечание. Примеры показывают базовые технические приёмы и требуют адаптации к версии Node.js, выбранной базе данных, модели угроз и требованиям конкретного сервиса.