Перейти к содержанию

Node.js: HTTP-сервер и ответы JSON

До сих пор DeskQueue выполнялась как краткая программа терминала. Теперь создадим HTTP-сервер: процесс останется ждать запросы, а клиент сможет получить заявки по адресу /api/tickets. Используем встроенный node:http, чтобы увидеть основные части ответа без фреймворка. Затем Express сократит служебный код, сохранив тот же договор.

Начальное состояние — файлы и данные из предыдущего урока. В stages/05 сервер один раз загружает две заявки, затем слушает 127.0.0.1:3000. Адрес петли ограничивает доступ локальной машиной. Это читательский учебный стенд; данная редакционная работа не открывает порт и не запускает процесс на компьютере пользователя.

Метод, адрес и ответ

HTTP-запрос содержит метод и адрес. Метод описывает намерение, а путь выбирает ресурс. Один путь может иметь разные операции: чтение через GET, создание через POST. В первом сервере поддержим только чтение списка и короткий адрес состояния. Остальные пути получат 404, а неподдерживаемый метод известного ресурса — 405.

Полный server.js отделяет сериализацию ответа от выбора маршрута. Передавать JavaScript-объект напрямую в res.end нельзя: сетевой ответ состоит из байтов или строки. Сначала преобразуем объект в JSON и указываем соответствующий тип содержимого.

import { createServer } from 'node:http';
import { loadTickets } from './ticket-source.js';
const tickets = await loadTickets();
function json(res, status, value) {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(value));
}
const server = createServer((req, res) => {
  const path = new URL(req.url, 'http://localhost').pathname;
  if (path === '/health' && req.method === 'GET') {
    return json(res, 200, { service: 'DeskQueue', status: 'ok' });
  }
  if (path === '/api/tickets') {
    if (req.method !== 'GET') {
      res.setHeader('Allow', 'GET');
      return json(res, 405, { error: 'method_not_allowed' });
    }
    return json(res, 200, tickets);
  }
  json(res, 404, { error: 'not_found' });
});
server.listen(3000, '127.0.0.1', () => console.log('DeskQueue: http://127.0.0.1:3000'));

new URL отделяет путь от строки запроса. Адрес /api/tickets?status=open имеет тот же pathname, хотя фильтр пока не реализован. Нельзя сравнивать всю req.url с фиксированным путём и случайно считать любой параметр неизвестной страницей. Вместе с тем отсутствие ошибки не означает поддержку фильтра: текущий сервер явно игнорирует параметры и возвращает весь набор.

У функции json два действия: назначить статус и заголовок, затем завершить ответ. После завершения нельзя продолжить и отправить второй результат. Поэтому в ветках маршрутизации используются return. Эта деталь не относится специально к Node.js: у одного запроса должен быть один согласованный ответ. Подробности встроенного сервера находятся в документации HTTP Node.js.

Чего ожидает клиент

Для GET /api/tickets ожидается статус 200 и JSON-массив с прежними двумя заявками. Для /unknown — статус 404 и объект ошибки. Наличие JSON не превращает ошибку в успех: клиент сначала учитывает статус, затем разбирает тело согласно договору. Для POST /api/tickets ожидается 405 и заголовок Allow: GET, поскольку адрес существует, а создание ещё не реализовано.

Браузерная адресная строка обычно отправляет GET, поэтому через неё удобно наблюдать чтение. Для остальных методов читателю понадобится HTTP-клиент или собственный fetch. Открытая страница с JSON не является интерфейсом очереди: это представление ответа сервера. В курс не добавляем случайную форму только ради красивой картинки; сначала установим API, на который сможет опереться любой клиент.

/health пока сообщает лишь, что процесс способен ответить. Он не проверяет базу, потому что её ещё нет. Когда появится PostgreSQL, выделим readiness: готовность обслуживать операции зависит от подключения и схемы. Смешивать все проверки в один безусловный ok опасно, поскольку работающий процесс может не выполнять полезную функцию.

Время жизни сервера

Вызов listen создаёт ожидающий сетевой ресурс, поэтому программа не завершается сразу после сообщения. Сообщение появляется в callback после начала слушания. Если порт занят, запуск должен обнаружить ошибку, а не сообщить ложный успех. В итоговом состоянии мы добавим явное сопровождение таких ошибок и корректное завершение. Сейчас важна сама связь между открытым сервером и временем жизни процесса.

Загрузка файла происходит до начала слушания. Если исходный JSON неверен, процесс не должен принимать запросы с неполным набором данных. Эта последовательность является простейшей проверкой начальной готовности. Альтернатива — начать слушать и временно отвечать 503, но для маленького примера лишнее состояние пока не требуется.

Все запросы видят один массив текущего процесса. Два серверных процесса получили бы отдельные массивы. Даже если они слушают разные порты, это не становится общей очередью. Так мы постепенно подходим к необходимости постоянной базы, не скрывая ограничение начального сервера. Совместный доступ к PostgreSQL изменит происхождение данных, а не смысл HTTP-адреса.

Также не используем URL напрямую как путь файла. Сервер отвечает только на заранее определённые ресурсы. Если механически соединять пользовательский путь с каталогом, появляются вопросы обхода границ, кодировки и доступа к закрытым файлам. Статические файлы будут задачей готового middleware либо отдельного сервера; наш API возвращает данные из заданного хранилища.

Результат ступени — процесс, который ожидает HTTP-запросы и даёт клиенту различимые ответы. Вы можете объяснить назначение статуса, заголовка и тела, а также разницу неизвестного пути и неверного метода. Теперь перенесём этот же договор в Express и начнём развивать операции над заявками.

Путь /api/tickets обозначает коллекцию, а не имя файла tickets.json. Клиент не должен зависеть от расположения исходных данных. Если позднее база заменит JSON, адрес останется прежним. Это также позволяет серверу выбирать ограниченный набор полей: внутренний объект может содержать служебные сведения, которые не входят в ответ. В начальном примере формы совпадают ради ясности, но совпадение не является обязательным свойством любого API.

При обсуждении ошибки различайте отсутствие HTTP-ответа и ответ с ошибочным статусом. Если процесс не работает или адрес слушания неверный, клиент не получит наш JSON not_found. Если сервер ответил 404, сетевое соединение уже позволило обратиться к приложению, а проблема относится к выбору ресурса. Такое разделение экономит время диагностики. Сначала установите, пришёл ли вообще ответ, затем его статус и тип содержимого, затем ожидаемую форму данных. Удачная загрузка адреса /health ещё не удостоверяет правильность каждого маршрута, но помогает отделить доступность процесса от ошибки конкретного пути.

Внутри локальной машины слово localhost может разрешаться также в IPv6. Мы явно слушаем IPv4-адрес петли и в примерах обращаемся к нему, чтобы не скрывать это различие за именем. В другом окружении адрес выбирают согласно его сетевому договору.

Метод чтения также не должен менять заявки только потому, что клиент открыл адрес повторно. Браузер, промежуточный кеш или наблюдающий инструмент способен повторять чтение без намерения выполнить команду. Поэтому создание и изменение получат отдельные методы. Такая семантика не гарантирует отсутствие технических побочных действий, например записи журнала, но отделяет их от предметного изменения очереди. Уже первый сервер должен показывать эту разницу ясно.

Оглавление курса

Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.