С помощью сокетов процессы на компьютере взаимодействуют друг с другом через файловую систему. Сокеты представляют собой особый тип файлов, предоставляющий процессам информацию для чтения и возможность записи с использованием обычного API файловой системы. TCP — дополнительный стандарт для использования сокетов по сети для обеспечения связи между несколькими машинами. Этот стандарт обеспечивает работу HTTP, а вместе с ним и интернета.
Сокеты работают по системе клиент-сервер. Первое, что происходит при запуске сервера, это создание прослушивающего сокета. Он настроен с IP и портом и представляет собой специальный сокет, который используется для связи между ОС и сервером, а не между сервером и клиентами. После создания прослушивающего сокета сервер отправляет ему команду accept
. ОС отвечает попыткой подключиться к IP-адресу и возвращается на сервер.
Затем создается клиентский сокет для входящего соединения, который используется для передачи данных между сервером и клиентом. После того, как сервер завершает соединение с клиентом, он снова отправляет команду accept
, и следующее соединение возвращается на сервер*.
*Ниже мы рассмотрим многопоточные серверы, которые работают с несколькими подключениями одновременно.
В этот момент вступает в силу концепция backlog
— управляемая операционной системой очередь со всеми клиентами, которые предстоит обработать. При создании слушающего сокета необходимо определить допустимое количество клиентов в этой очереди. Это число является компромиссом между экономией ресурсов на создание очереди клиентов и отказом при максимальной загрузке.
В стандартной библиотеке Node сокеты используются с помощью пакета net
, который предоставляет возможность соединения с сокетами, чтение из и запись в них, а также запуска сервера. Мы начнем с создания серверного сокета и прослушивания IP и порта. listen
будет отправлять сокету команду accept
и прочитывать все полученные данные.
import * as net from 'net'
const PORT = 3000
const IP = '127.0.0.1'
const BACKLOG = 100
net.createServer()
.listen(PORT, IP, BACKLOG)
Следующий шаг — выполнение действий при подключении клиента к сокету. Библиотека net
во многом зависит от шаблона наблюдатель, известному всем разработчикам JavaScript. Таким образом, прослушивание входящих подключений выполняется путем прослушивания события connection
:
net.createServer()
.listen(PORT, IP, BACKLOG)
.on('connection', socket =>
console.log(`new connection from ${socket.remoteAddress}:${socket.remotePort}`
)
При каждом подключении клиента происходит обратный вызов с клиентским сокетом этого соединения в качестве параметра. С помощью этого сокета можно прочитывать данные, отправленные клиентом, и отправлять обратный ответ. Для этого используется событие data
в сокете соединения, которому предоставляется обратный вызов, принимающий Buffer
с отправленными клиентом данными. Мы также можем вызвать write
в сокет для обратной отправки данных.
Последняя задача — закрытие (end
) соединения после окончания работы. В противном случае может возникнуть переполнение открытых соединений. Эта логика не касается многокомпонентных тел и заголовка Keep-Alive
. Они не являются необходимыми для написания простого и понятного сервера и могут быть добавлены позже.
net.createServer()
.listen(PORT, IP, BACKLOG)
.on('connection', socket => socket
.on('data', buffer => {
const request = buffer.toString()
socket.write('hello world')
socket.end()
})
Это все, что нужно для работы с TCP-соединениями в Node. При вызове по адресу curl
появится ответ. Однако открытие localhost:3000
в браузере не будет доступно, поскольку для этого нужно реализовать стандарт HTTP.
curl 127.0.0.1:3000
hello world%
HTTP — стандарт для связи через TCP-сокеты. Он описывает способ форматирования сообщений и то, как сервер должен управлять соединениями. HTTP-сообщение от клиента к серверу (запрос) выглядит следующим образом:
GET / HTTP/1.1
Host: localhost:3000
Здесь мы видим больше знакомых концепций, используемых при вызовах API, например, с fetch
. Сообщение начинается с глагола (метода): GET
, затем находится URL, в данном случае домашняя страница /
. При написании фреймворка за сервером он используется для маршрутизации, поиска верного контроллера и выполнения действий с ним. Последняя запись в первой строке — версия HTTP, которая используется для совместимости со старыми клиентами. Но сейчас мы не будем рассматривать этот момент.
Со второй строки появляются заголовки — пары ключ-значение, соединенные с запросом. Каждый из них находится на отдельной строке, а ключ отделяется от значения с помощью :
. Последний заголовок сопровождается пустой строкой, после которой начинается тело запроса. Визуально это выглядит следующим образом:
export interface Request {
protocol: string
method: string
url: string
headers: Map<string, string>
body: string
}
Парсинг строки запроса, представленной выше, можно выполнить с помощью магической строки, показанной ниже. С помощью этой функции можно вызывать контроллеры по системе, схожей с написанием конечных точек в бэкенд-фреймворке.
const parseRequest = (s: string): Request => {
const [firstLine, rest] = divideStringOn(s, '\r\n')
const [method, url, protocol] = firstLine.split(' ', 3)
const [headers, body] = divideStringOn(rest, '\r\n\r\n')
const parsedHeaders = headers.split('\r\n').reduce((map, header) => {
const [key, value] = divideStringOn(header, ': ')
return map.set(key, value)
}, new Map())
return { protocol, method, url, headers: parsedHeaders, body }
}
const divideStringOn = (s: string, search: string) => {
const index = s.indexOf(search)
const first = s.slice(0, index)
const rest = s.slice(index + search.length)
return [first, rest]
}
HTTP-ответ следует тем же принципам, что и HTTP-запрос. Основное отличие заключается в первой строке, где показан результат запроса вместо запрашиваемого ресурса. После этого находятся знакомые заголовки и тело.
HTTP/1.1 200 OK
Content-Type: application/text
<html>
<body>
<h1>Greetings!</h1>
</body>
</html>
Из этой информации можно создать интерфейс Response
и написать функцию, которая превращает его в строку перед отправкой обратно через сокет:
export interface Response {
status: string
statusCode: number
protocol: string
headers: Map<string, string>
body: string
}
const compileResponse = (r: Response): string => `${r.protocol} ${r.statusCode} ${r.status}
${Array.from(r.headers).map(kv => `${kv[0]}: ${kv[1]}`).join('\r\n')}
${r.body}`
При добавлении этого кода обратно в сервер мы получаем следующий фрагмент, который позволяет просматривать веб-сайт в браузере, обслуживаемый нашим собственным сервером! В HTTP есть еще множество элементов, например, куки. Они извлекаются из заголовка Cookie
, что возможно осуществить в данной реализации. Настройка куки выполняется через заголовок Set-Cookie, который может встречаться в ответе несколько раз. Его нужно добавить в интерфейс Response
, поскольку ATM не позволяет использовать несколько заголовков с одним и тем же именем.
socket.write(compileResponse({
protocol: 'HTTP/1.1',
headers: new Map(),
status: 'OK',
statusCode: 200,
body: `<html><body><h1>Greetings</h1></body></html>`
}))
Теперь это настоящий сервер!
На данный момент наш сервер работает только на одном процессе. Это означает, что при обработке одного запроса все входящие соединения накапливаются в резерве. Увидеть этот процесс в действии можно, обновив код для вычисления последовательности Фибоначчи, равной 100, и отправки обратного ответа. Если вы откроете несколько вкладок в браузере и просмотрите вывод консоли, то увидите, что подключается только первая вкладка, а остальные остаются в очереди, пока сервер не завершит вычисление первого результата.
net.createServer()
.listen(PORT, IP, BACKLOG)
.on('connection', socket => {
console.log('new connection')
socket
.on('data', buffer => {
console.log('data')
socket.write(fibonacci(100))
console.log('done with connection')
socket.end()
})
})
const fibonacci = (n: number) => (n < 2) ? n
: fibonacci(n - 2) + fibonacci(n - 1)
Это может стать серьезной проблемой при работе с приложением, в котором есть медленные конечные точки (например, функция экспорта). Они блокируют весь сервер и вызывают время простоя до завершения экспорта. Есть множество способов избежать этого с помощью модели параллелизма, которая позволяет серверу принимать входящие соединения во время работы конечной точки экспорта. Многие серверы производственного уровня помещают клиентские сокеты в очередь и позволяют другим потокам обрабатывать их.
В этой статье мы остановимся на использовании workerpool для выполнения тяжелых вычислений и сохранения всех подобных соединений в основном процессе. Workerpool позволяет сохранить пул фоновых воркеров в Node, которому можно передавать задачи для выполнения в фоновом режиме. Пул находит и присваивает воркер для выполнения работы. Функция пула exec
возвращает промис, который разрешается при завершении задачи. Таким образом, обработка входящих соединений не блокируется во время вычисления последовательности Фибоначчи из 100 чисел:
import * as wp from 'workerpool'
const workerpool = wp.pool()
net.createServer()
.listen(PORT, IP, BACKLOG)
.on('connection', socket => {
console.log('new connection')
socket
.on('data', buffer => {
console.log('data')
workerpool.exec(() => fibonacci(100), [])
.then(res => {
socket.write(res)
console.log('done with connection')
socket.end()
})
})
})
Теперь при открытии сайта в нескольких вкладках все соединения принимаются в терминале. Все вычисления выполняются различными воркерами без блокировки запросов из-за тяжелых задач.
Перевод статьи Wim Jongeneel: A Web Server From Scratch in TypeScript and Node
Комментарии