На этой странице

Цель этого руководства — дать прочное понимание того, как в Node.js устроена обработка HTTP. Мы предполагаем, что вы в общих чертах знаете, как работают HTTP-запросы, независимо от языка или среды программирования. Также предполагаем некоторое знакомство с EventEmitter и потоками (streams) в Node.js. Если вы не очень хорошо с ними знакомы, стоит бегло пройтись по документации API по каждому из них.

Любое серверное веб-приложение на node в какой-то момент должно создать объект веб-сервера. Это делается с помощью createServer.

const http = require('node:http');

const server = http.createServer((request, response) => {
  // здесь происходит магия!
});

Функция, передаваемая в createServer, вызывается один раз для каждого HTTP-запроса, приходящего на этот сервер, поэтому она называется обработчиком запросов (request handler). На самом деле объект Server, возвращаемый createServer, — это EventEmitter, и то, что мы здесь видим, — просто сокращённая запись создания объекта server с последующим добавлением слушателя.

const server = http.createServer();
server.on('request', (request, response) => {
  // здесь происходит та же самая магия!
});

Когда HTTP-запрос попадает на сервер, Node вызывает функцию-обработчик запросов с парой удобных объектов для работы с транзакцией — request и response. К ним мы скоро вернёмся.

Чтобы сервер действительно обслуживал запросы, у объекта server нужно вызвать метод listen. В большинстве случаев всё, что нужно передать в listen, — это номер порта, который сервер должен слушать. Есть и другие опции, так что сверьтесь со справочником API.

При обработке запроса первое, что вы, вероятно, захотите сделать, — посмотреть на метод и URL, чтобы предпринять соответствующие действия. Node.js делает это относительно безболезненным, помещая удобные свойства на объект request.

Объект request — это экземпляр IncomingMessage.

method здесь всегда будет обычным HTTP-методом/глаголом. url — это полный URL без сервера, протокола и порта. Для типичного URL это означает всё начиная с третьего слэша включительно.

Заголовки тоже недалеко. Они лежат в собственном объекте на request под названием headers.

const { headers } = request;
const userAgent = headers['user-agent'];

Здесь важно отметить, что все заголовки представлены только в нижнем регистре, независимо от того, как клиент их на самом деле отправил. Это упрощает парсинг заголовков для любых целей.

Если какие-то заголовки повторяются, их значения перезаписываются или объединяются в строки, разделённые запятыми, в зависимости от заголовка. В некоторых случаях это может создавать проблемы, поэтому доступен также rawHeaders.

При получении запроса POST или PUT тело запроса может быть важно для вашего приложения. Добраться до данных тела чуть сложнее, чем обратиться к заголовкам запроса. Объект request, передаваемый обработчику, реализует интерфейс ReadableStream. Этот поток можно слушать или перенаправлять (pipe) куда угодно, как любой другой поток. Мы можем забирать данные прямо из потока, слушая его события 'data' и 'end'.

Чанк, эмитируемый в каждом событии 'data', — это Buffer. Если вы знаете, что это будут строковые данные, лучше всего собирать данные в массив, а затем на 'end' конкатенировать и преобразовать в строку.

let body = [];
request
  .on('data', chunk => {
    body.push(chunk);
  })
  .on('end', () => {
    body = Buffer.concat(body).toString();
    // на этом этапе в `body` целиком хранится тело запроса в виде строки
  });

Это может показаться немного нудным, и во многих случаях так и есть. К счастью, в npm есть модули вроде concat-stream и body, которые помогают спрятать часть этой логики. Важно хорошо понимать, что происходит, прежде чем идти этим путём, — за тем вы здесь и находитесь!

Поскольку объект request — это ReadableStream, он также является EventEmitter и ведёт себя как таковой при возникновении ошибки.

Ошибка в потоке request проявляется в виде эмита события 'error' на потоке. Если у вас нет слушателя этого события, ошибка будет выброшена, что может уронить вашу программу на Node.js. Поэтому вам следует добавить слушатель 'error' к вашим потокам request, даже если вы просто залогируете ошибку и продолжите работу. (Хотя, вероятно, лучше отправить какой-нибудь HTTP-ответ с ошибкой. Об этом позже.)

request.on('error', err => {
  // Это печатает сообщение об ошибке и трассировку стека в `stderr`.
  console.error(err.stack);
});

Есть и другие способы обработки этих ошибок — например, другие абстракции и инструменты, — но всегда помните, что ошибки могут случаться и случаются, и вам придётся с ними разбираться.

К этому моменту мы разобрали создание сервера, а также извлечение метода, URL, заголовков и тела из запросов. Если всё это собрать вместе, получится примерно так:

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // На этом этапе у нас есть заголовки, метод, url и тело, и теперь мы можем
        // сделать всё необходимое, чтобы ответить на этот запрос.
      });
  })
  .listen(8080); // Активирует этот сервер, слушая порт 8080.

Если запустить этот пример, мы сможем принимать запросы, но не отвечать на них. Более того, если открыть этот пример в веб-браузере, ваш запрос завис бы по таймауту, потому что клиенту ничего не отправляется в ответ.

Пока что мы вообще не касались объекта response, который является экземпляром ServerResponse и представляет собой WritableStream. Он содержит много полезных методов для отправки данных обратно клиенту. Этим мы займёмся дальше.

Если вы не потрудитесь его задать, HTTP-код состояния ответа всегда будет 200. Разумеется, не каждый HTTP-ответ этого заслуживает, и в какой-то момент вам наверняка понадобится отправить другой код состояния. Для этого можно задать свойство statusCode.

Есть и другие сокращения для этого, как мы скоро увидим.

Заголовки устанавливаются через удобный метод setHeader.

response.setHeader('Content-Type', 'application/json');
response.setHeader('X-Powered-By', 'bacon');

При установке заголовков ответа регистр их имён не важен. Если задать заголовок несколько раз, отправлено будет последнее заданное вами значение.

Уже рассмотренные способы установки заголовков и кода состояния предполагают, что вы используете «неявные заголовки» (implicit headers). Это значит, что вы рассчитываете на то, что node отправит заголовки за вас в нужный момент до начала отправки данных тела.

При желании можно явно записать заголовки в поток ответа. Для этого есть метод writeHead, который записывает код состояния и заголовки в поток.

response.writeHead(200, {
  'Content-Type': 'application/json',
  'X-Powered-By': 'bacon',
});

Как только вы задали заголовки (неявно или явно), вы готовы начать отправку данных ответа.

Поскольку объект response — это WritableStream, запись тела ответа клиенту сводится к использованию обычных методов потоков.

response.write('<html>');
response.write('<body>');
response.write('<h1>Hello, World!</h1>');
response.write('</body>');
response.write('</html>');
response.end();

Функция end у потоков также может принимать необязательные данные для отправки в качестве последней порции данных в потоке, так что пример выше можно упростить до следующего.

Важно задать статус и заголовки до того, как вы начнёте записывать чанки данных в тело. Это логично, ведь в HTTP-ответах заголовки идут перед телом.

Поток response тоже может эмитировать события 'error', и в какой-то момент вам придётся разобраться и с этим. Все советы по ошибкам потока request здесь по-прежнему применимы.

Теперь, когда мы научились формировать HTTP-ответы, соберём всё вместе. Развивая ранний пример, мы сделаем сервер, который отправляет обратно все данные, присланные нам пользователем. Отформатируем эти данные как JSON с помощью JSON.stringify.

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // НАЧАЛО НОВОГО КОДА

        response.on('error', err => {
          console.error(err);
        });

        response.statusCode = 200;
        response.setHeader('Content-Type', 'application/json');
        // Примечание: 2 строки выше можно заменить следующей:
        // response.writeHead(200, {'Content-Type': 'application/json'})

        const responseBody = { headers, method, url, body };

        response.write(JSON.stringify(responseBody));
        response.end();
        // Примечание: 2 строки выше можно заменить следующей:
        // response.end(JSON.stringify(responseBody))

        // КОНЕЦ НОВОГО КОДА
      });
  })
  .listen(8080);

Упростим предыдущий пример и сделаем простой echo-сервер, который просто отправляет обратно в ответе любые данные, полученные в запросе. Всё, что нам нужно, — забрать данные из потока запроса и записать эти данные в поток ответа, аналогично тому, что мы делали ранее.

const http = require('node:http');

http
  .createServer((request, response) => {
    let body = [];
    request
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        response.end(body);
      });
  })
  .listen(8080);

Теперь давайте это подкрутим. Мы хотим отправлять echo только при следующих условиях:

  • Метод запроса — POST.
  • URL — /echo.

В любом другом случае мы хотим просто ответить 404.

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      let body = [];
      request
        .on('data', chunk => {
          body.push(chunk);
        })
        .on('end', () => {
          body = Buffer.concat(body).toString();
          response.end(body);
        });
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

Проверяя URL таким образом, мы выполняем своего рода «маршрутизацию» (routing). Другие формы маршрутизации могут быть как простыми, как инструкции switch, так и сложными, как целые фреймворки вроде express. Если вам нужно что-то, что делает маршрутизацию и больше ничего, попробуйте router.

Отлично! Теперь попробуем это упростить. Помните, что объект request — это ReadableStream, а объект response — WritableStream. Значит, мы можем использовать pipe, чтобы направить данные из одного в другой. Именно это нам и нужно для echo-сервера!

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

Ура, потоки!

Однако мы ещё не совсем закончили. Как не раз упоминалось в этом руководстве, ошибки могут случаться и случаются, и нам нужно с ними разбираться.

Чтобы обработать ошибки в потоке запроса, мы залогируем ошибку в stderr и отправим код состояния 400, обозначающий Bad Request. В реальном приложении, впрочем, мы бы захотели изучить ошибку, чтобы определить корректный код состояния и сообщение. Как обычно с ошибками, вам стоит сверяться с документацией по Error.

Что касается ответа, мы просто залогируем ошибку в stderr.

const http = require('node:http');

http
  .createServer((request, response) => {
    request.on('error', err => {
      console.error(err);
      response.statusCode = 400;
      response.end();
    });
    response.on('error', err => {
      console.error(err);
    });
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

Теперь мы охватили большую часть основ обработки HTTP-запросов. На этом этапе вы должны уметь:

  • Создавать экземпляр HTTP-сервера с функцией-обработчиком запросов и заставлять его слушать порт.
  • Получать заголовки, URL, метод и данные тела из объектов request.
  • Принимать решения о маршрутизации на основе URL и/или других данных в объектах request.
  • Отправлять заголовки, HTTP-коды состояния и данные тела через объекты response.
  • Перенаправлять (pipe) данные из объектов request и в объекты response.
  • Обрабатывать ошибки потоков как в потоке request, так и в потоке response.

Из этих основ можно построить HTTP-серверы на Node.js для множества типичных сценариев. Эти API предоставляют ещё много всего, так что обязательно прочитайте документацию API по EventEmitter, потокам и HTTP.