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

Web Server

Сокеты и 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, 'rn')
  const [method, url, protocol] = firstLine.split(' ', 3)
  const [headers, body] = divideStringOn(rest, 'rnrn')
  const parsedHeaders = headers.split('rn').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('rn')}

${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()
          })
      })
  })

Теперь при открытии сайта в нескольких вкладках все соединения принимаются в терминале. Все вычисления выполняются различными воркерами без блокировки запросов из-за тяжелых задач.

Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming