Веб-сервер с нуля в TypeScript и Node


Сокеты и TCP

С помощью сокетов процессы на компьютере взаимодействуют друг с другом через файловую систему. Сокеты представляют собой особый тип файлов, предоставляющий процессам информацию для чтения и возможность записи с использованием обычного API файловой системы. TCP — дополнительный стандарт для использования сокетов по сети для обеспечения связи между несколькими машинами. Этот стандарт обеспечивает работу HTTP, а вместе с ним и интернета.

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

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

*Ниже мы рассмотрим многопоточные серверы, которые работают с несколькими подключениями одновременно.

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

Сокеты, Node и сеть

В стандартной библиотеке 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

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


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.