Skip to content

Анатомия HTTP-транзакции

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

Создание сервера

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

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

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

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

Когда HTTP-запрос достигает сервера, node вызывает функцию обработчика запросов с несколькими удобными объектами для обработки транзакции, запроса и ответа. Мы доберемся до них в ближайшее время. Чтобы фактически обслуживать запросы, необходимо вызвать метод listen для объекта сервера. В большинстве случаев все, что вам нужно передать в listen, — это номер порта, который должен прослушивать сервер. Есть и другие варианты, поэтому обратитесь к справочнику по API.

Метод, URL и заголовки

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

javascript
const { method, url } = request;

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

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

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

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

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

Тело запроса

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

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

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

ПРИМЕЧАНИЕ

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

Коротко об ошибках

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

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

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

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

Что у нас есть на данный момент

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

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

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

.listen(8080); // Активирует этот сервер, прослушивая порт 8080.

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

До сих пор мы вообще не касались объекта ответа, который является экземпляром ServerResponse, который является WritableStream. Он содержит множество полезных методов для отправки данных обратно клиенту. Мы рассмотрим это далее.

Код состояния HTTP

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

javascript
response.statusCode = 404; // Сообщите клиенту, что ресурс не найден.

Есть и другие способы сократить это, как мы увидим в ближайшее время.

Установка заголовков ответа

Заголовки устанавливаются с помощью удобного метода setHeader.

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

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

Явная отправка данных заголовка

Рассмотренные нами методы установки заголовков и кода состояния предполагают, что вы используете "неявные заголовки". Это означает, что вы рассчитываете на то, что node отправит заголовки за вас в нужное время, прежде чем вы начнете отправлять данные тела.

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

Явная отправка данных заголовка

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

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

Отправка тела ответа

Поскольку объект ответа является WritableStream, запись тела ответа клиенту - это всего лишь вопрос использования обычных методов потока.

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

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

javascript
response.end('<html><body><h1>hello,world!</h1></body></html>');

ПРИМЕЧАНИЕ

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

Еще одна быстрая вещь об ошибках

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

Соберем все вместе

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

javascript
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');
        // Примечание: две строки выше можно заменить следующей строкой:
        // response.writeHead(200, {'Content-Type': 'application/json'})
        const responseBody = { headers, method, url, body };
        response.write(JSON.stringify(responseBody));
        response.end();
        // Примечание: две строки выше можно заменить следующей строкой:
        // response.end(JSON.stringify(responseBody))
        // КОНЕЦ НОВОГО МАТЕРИАЛА
      });
  })
  .listen(8080);

Пример EchoServer

Давайте упростим предыдущий пример и сделаем простой эхо-сервер, который просто отправляет обратно в ответе все данные, полученные в запросе. Все, что нам нужно сделать, это взять данные из потока запроса и записать их в поток ответа, как мы это делали ранее.

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

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

.listen(8080);

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

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

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

javascript
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);

NOTE

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

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

javascript
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. Однако в реальном приложении нам нужно было бы проверить ошибку, чтобы понять, какой правильный код состояния и сообщение следует использовать. Как обычно с ошибками, вам следует обратиться к документации по ошибкам.

В ответе мы просто запишем ошибку в stderr.

javascript
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-сервер с функцией обработчика request и заставить его прослушивать порт.
  • Получать заголовки, URL, метод и данные тела из объектов request.
  • Принимать решения о маршрутизации на основе URL и/или других данных в объектах request.
  • Отправлять заголовки, коды состояния HTTP и данные тела через объекты response.
  • Передавать данные из объектов request в объекты response.
  • Обрабатывать ошибки потока как в потоках request, так и в потоках response.

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