Анатомия HTTP-транзакции
Цель этого руководства — предоставить прочное понимание процесса обработки HTTP в Node.js. Мы предполагаем, что вы в общих чертах знаете, как работают HTTP-запросы, независимо от языка или среды программирования. Также мы предполагаем некоторую знакомство с Node.js EventEmitters и Streams. Если вы не совсем с ними знакомы, стоит быстро просмотреть API-документацию для каждого из них.
Создание сервера
Любое веб-серверное приложение node в какой-то момент должно будет создать объект веб-сервера. Это делается с помощью createServer
.
const http = require('node:http');
const server = http.createServer((request, response) => {
// здесь происходит магия!
});
Функция, передаваемая в createServer
, вызывается один раз для каждого HTTP-запроса, поступившего на этот сервер, поэтому она называется обработчиком запросов. Фактически, объект Server, возвращаемый createServer
, является EventEmitter, и то, что у нас здесь есть, — это просто сокращенный вариант создания объекта сервера, а затем добавления прослушивателя позже.
const server = http.createServer();
server.on('request', (request, response) => {
// здесь происходит то же самое волшебство!
});
Когда HTTP-запрос достигает сервера, node вызывает функцию обработчика запросов с несколькими удобными объектами для обработки транзакции, запроса и ответа. Мы доберемся до них в ближайшее время. Чтобы фактически обслуживать запросы, необходимо вызвать метод listen
для объекта сервера. В большинстве случаев все, что вам нужно передать в listen
, — это номер порта, который должен прослушивать сервер. Есть и другие варианты, поэтому обратитесь к справочнику по API.
Метод, URL и заголовки
При обработке запроса первое, что вы, вероятно, захотите сделать, — это посмотреть на метод и URL, чтобы можно было предпринять соответствующие действия. Node.js делает это относительно безболезненным, помещая удобные свойства в объект запроса.
const { method, url } = request;
Объект запроса является экземпляром IncomingMessage
. Метод здесь всегда будет обычным HTTP-методом/глаголом. URL — это полный URL без сервера, протокола или порта. Для типичного URL это означает все после и включая третий прямой слэш.
Заголовки тоже находятся неподалеку. Они находятся в своем собственном объекте в запросе под названием headers
.
const { headers } = request;
const userAgent = headers['user-agent'];
Важно отметить здесь, что все заголовки представлены только в нижнем регистре, независимо от того, как клиент их фактически отправил. Это упрощает задачу разбора заголовков для любых целей.
Если некоторые заголовки повторяются, их значения перезаписываются или объединяются в виде строк, разделенных запятыми, в зависимости от заголовка. В некоторых случаях это может быть проблематично, поэтому также доступен rawHeaders
.
Тело запроса
При получении POST или PUT запроса тело запроса может быть важным для вашего приложения. Получение данных из тела немного сложнее, чем доступ к заголовкам запроса. Объект запроса, который передается в обработчик, реализует интерфейс ReadableStream
. Этот поток можно прослушивать или перенаправлять в другое место, как и любой другой поток. Мы можем получить данные прямо из потока, прослушивая события 'data'
и 'end'
потока.
Каждый фрагмент, испускаемый в событии 'data'
, является Buffer
. Если вы знаете, что это будут строковые данные, лучше всего собрать данные в массив, а затем, при 'end'
, объединить и преобразовать их в строку.
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-ответ об ошибке. Подробнее об этом позже.)
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));
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
.
response.statusCode = 404; // Сообщите клиенту, что ресурс не найден.
Есть и другие способы сократить это, как мы увидим в ближайшее время.
Установка заголовков ответа
Заголовки устанавливаются с помощью удобного метода setHeader
.
response.setHeader('Content-Type', 'application/json');
response.setHeader('X-Powered-By', 'bacon');
При установке заголовков в ответе регистр их имен не имеет значения. Если вы устанавливаете заголовок несколько раз, отправляется последнее установленное вами значение.
Явная отправка данных заголовка
Рассмотренные нами методы установки заголовков и кода состояния предполагают, что вы используете "неявные заголовки". Это означает, что вы рассчитываете на то, что node отправит заголовки за вас в нужное время, прежде чем вы начнете отправлять данные тела.
При желании вы можете явно записать заголовки в поток ответа. Для этого существует метод writeHead
, который записывает код состояния и заголовки в поток.
Явная отправка данных заголовка
response.writeHead(200, {
'Content-Type': 'application/json',
'X-Powered-By': 'bacon',
});
После установки заголовков (неявно или явно) вы готовы начать отправку данных ответа.
Отправка тела ответа
Поскольку объект ответа является WritableStream
, запись тела ответа клиенту - это всего лишь вопрос использования обычных методов потока.
response.write('<html>');
response.write('<body>');
response.write('<h1>Hello, World!</h1>');
response.write('</body>');
response.write('</html>');
response.end();
Функция end
в потоках также может принимать некоторые необязательные данные для отправки в качестве последнего фрагмента данных в потоке, поэтому мы можем упростить приведенный выше пример следующим образом.
response.end('<html><body><h1>hello,world!</h1></body></html>');
ПРИМЕЧАНИЕ
Важно установить статус и заголовки, прежде чем начинать записывать фрагменты данных в тело. Это имеет смысл, поскольку заголовки идут перед телом в HTTP-ответах.
Еще одна быстрая вещь об ошибках
Поток ответа также может генерировать события 'error', и в какой-то момент вам придется с этим столкнуться. Все советы по ошибкам потока запроса все еще применимы и здесь.
Соберем все вместе
Теперь, когда мы узнали о создании 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');
// Примечание: две строки выше можно заменить следующей строкой:
// 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
Давайте упростим предыдущий пример и сделаем простой эхо-сервер, который просто отправляет обратно в ответе все данные, полученные в запросе. Все, что нам нужно сделать, это взять данные из потока запроса и записать их в поток ответа, как мы это делали ранее.
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.
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
для направления данных из одного в другой. Это именно то, что нам нужно для эхо-сервера!
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
.
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
.