Анатомия 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.js для отправки заголовков в нужное время, до начала отправки данных тела.
При желании вы можете явно записать заголовки в поток ответа. Для этого существует метод 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)
ЗАМЕЧАНИЕ
Проверяя 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
.