Изучите и изучите внутренности WebSockets с помощью этого краткого руководства.

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

В качестве нашего основного источника правды мы будем ссылаться на официальную спецификацию протокола WebSocket — RFC 6455, особенно разделы 1 и 4–7 🤓

Для наших примеров кода я буду использовать Node.js v18.12.1.

1. Фон

WebSocket — это протокол связи, который работает через одно соединение TCP/IP и обеспечивает двунаправленный полнодуплексный канал между клиентом и сервером.

Он облегчает обмен сообщениями клиент-сервер в режиме реального времени через постоянное соединение, в отличие от парадигмы запроса-ответа HTTP, и особенно полезен для веб-приложений, которым требуется обновление данных в режиме реального времени.

Как говорится в реферате RFC 6455,

Цель этой технологии — предоставить механизм для приложений на основе браузера, которым требуется двусторонняя связь с серверами, которая не зависит от открытия нескольких HTTP-соединений (например, с использованием XMLHttpRequest или iframe и длительного опроса).

По сути, WebSockets — это тонкий транспортный уровень с низкими издержками поверх стека TCP/IP.

В следующих разделах этой статьи мы обсудим некоторые особенности протокола, а также постепенно создадим наш сервер WebSocket.

2. Обзор протокола

WebSockets работают через постоянное TCP-соединение, однако оно устанавливается с помощью специального HTTP-запроса на обновление. Вы можете часто видеть результат такого запроса на вкладке «Сеть» вашего браузера при использовании WebSockets в своих проектах — 101 Switching Protocols.

Согласно Разделу 1.7 RFC:

Протокол WebSocket — это независимый протокол на основе TCP. Его единственная связь с HTTP заключается в том, что его рукопожатие интерпретируется HTTP-серверами как запрос на обновление.

По сути, чтобы запустить сервер WebSocket, нам нужно сначала создать HTTP-сервер, а затем по запросу на обновление от клиента выполнить открывающее рукопожатие, которое приведет к переключению протокола с HTTP на WebSockets.

Давайте начнем с реализации нашего сервера, создав файл ws.js и объявив класс WebSocketServer:

Мы расширяем EventEmitter, так как в будущем наш сервер должен будет иметь возможность создавать и подписываться на такие события, как open, upgrade, data и т. д.

Сам сервер будет создан внутри метода _init класса:

Мы не очень хотим обрабатывать какие-либо HTTP-запросы, кроме обновлений, поэтому на все входящие HTTP-запросы мы отвечаем ошибкой 426 Upgrade Required 🚫. Это указывает на то, что наш сервер отказывается обрабатывать запрос с использованием протокола HTTP, но готов сделать это с использованием протокола из заголовка Upgrade.

В следующем разделе мы обсудим и реализуем реальное обновление протокола (открывающее рукопожатие WebSocket).

3. Начальное рукопожатие

Соединение WebSocket создается клиентом, инициирующим «открывающее рукопожатие». Затем сервер должен завершить рукопожатие, ответив определенными заголовками. После этого исходное HTTP-соединение заменяется соединением WebSocket, использующим тот же сокет TCP.

Открывающее рукопожатие описано в Разделе 4 RFC и достаточно просто и понятно.

Клиент инициирует рукопожатие, отправив HTTP-запрос GET со следующими заголовками:

GET / HTTP/1.1
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: kB2x1cO5zjL1ynwrLTSXUQ==
Sec-WebSocket-Version: 13

Комбинация заголовков Connection и Upgrade сигнализирует серверу, что клиент запрашивает установку соединения WebSocket.

Sec-WebSocket-Version представляет версию спецификации. Согласно спецификации, значение этого поля заголовка должно быть 13.

Sec-WebSocket-Key — это случайно сгенерированная строка, уникальная для клиента. Согласно RFC, значение для этого заголовка представляет собой одноразовый номер, состоящий из случайно выбранного 16-байтового значения, закодированного с помощью base64. Нонс должен выбираться случайным образом для каждого соединения (Раздел 4.1).

Значение Sec-WebSocket-Key используется при создании рукопожатия сервера, чтобы указать на принятие соединения.

Чтобы принять входящее соединение, сервер должен ответить HTTP-ответом со статусом 101 Switching Protocols.

Ответ должен содержать заголовок Sec-WebSocket-Accept со значением, сгенерированным следующим образом:

  • сервер должен взять значение Sec-WebSocket-Key и объединить его со значением GUID 258EDFA5-E914–47DA-95CA-C5AB0DC85B11 (это волшебная строка)
  • то полученное значение должно быть SHA-1 хешировано
  • то полученный хеш должен быть закодирован в base64 .

Магическое строковое значение выбрано потому, что:

маловероятно, что он будет использоваться сетевыми конечными точками, которые не понимают протокол WebSocket (Раздел 1.3).

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

Ответ на рукопожатие также должен содержать заголовки Connection: Upgrade и Upgrade: websocket.

После того, как клиент получает ответ сервера с описанными заголовками, устанавливается и открывается WebSocket-соединение для начала передачи данных.

Теперь давайте посмотрим, как реализовать рукопожатие в коде.

Мы продолжим работу над нашим примером WebSocketServer и обновим наш метод _init следующим образом:

Мы отвечаем 400, если значение заголовка Upgrade не равно websocket, и прерываем рукопожатие.

Для удобства мы генерируем событие headers, чтобы иметь возможность подписаться на это событие и посмотреть, какие заголовки были получены при запросе на подключение.

Давайте также добавим объявление «волшебной строки» в конструктор нашего класса:

this.GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

Метод _generateAcceptValue — это тот, который генерирует значение для заголовка Sec-WebSocket-Accept, и он довольно прост (обратите внимание, что нам нужно потребовать модуль crypto от узла):

После этого наш сервер готов к установке WebSocket-соединений ⚡️

Нам просто нужно добавить метод listen в наш класс, создать его экземпляр и проверить, все ли работает так, как мы ожидаем.

Теперь, чтобы запустить наш сервер, мы должны сделать следующее:

Чтобы убедиться, что все работает правильно, запустите файл ws.js командой node ws.js, откройте консоль браузера и создайте новый WebSocket:

const ws = new WebSocket('ws://localhost:4000');

Вернувшись в свой терминал, вы должны увидеть что-то похожее на это:

Вуаля — наше рукопожатие готово!

Красиво 💥

В следующем разделе мы сделаем так, чтобы наш сервер мог получать и анализировать данные от клиента.

4. Получение данных

В протоколе WebSocket данные передаются с помощью последовательности кадров. (Раздел 5.1)

В этом разделе мы сосредоточимся на реализации метода анализа кадров WebSocket. Но сначала давайте обсудим, как на самом деле устроены фреймы.

На рисунке ниже показаны строительные блоки фрейма WebSocket и их размеры в битах.

Значение каждого блока описано в Разделе 5.2 спецификации.

  • FIN — 1 бит. Это первый бит кадра WebSocket. Если этот бит установлен, это означает, что это последний фрагмент сообщения.
  • RSV1, RSV2, RSV3 — по 1 биту. Они зарезервированы для использования расширений WebSocket. На момент написания статьи существует только 2 зарегистрированных расширения: WebSocket Per-Message Deflate и BBF USP Protocol. Это выходит за рамки этой статьи.
  • Opcode — 4 бита. Этот блок используется для интерпретации блока Payload Data. Существуют коды, обозначающие текстовый или двоичный кадр, событие закрытия соединения, кадр продолжения и т. д. Ниже приведены возможные значения opcode и их значения:

В нашем примере мы будем использовать только опкоды 0x01 и 0x08.

  • mask — определяет, маскируется ли блок Payload Data. Если установлено значение 1, должен присутствовать 4-байтовый блок masking-key, который должен использоваться для демаскирования блока Payload Data. Согласно спекуляции:

Клиент ДОЛЖЕН маскировать все кадры, которые он отправляет на сервер. Сервер НЕ ДОЛЖЕН маскировать любые кадры, которые он отправляет клиенту.

По сути, это означает, что мы всегда будем получать замаскированные кадры при тестировании нашего сервера из браузера. И в то же время при отправке ответа мы не должны маскировать нашу полезную нагрузку, иначе браузер откажется обрабатывать наш фрейм и выдаст ошибку.

  • Payload Len + Extended payload length — 7 бит, 7+16 бит или 7+64 бит.

Если длина блока Payload Data составляет 125 байт или меньше, значение payload len соответствует длине блока Payload Data. Если длина полезной нагрузки больше 125 байт, значение блока Payload len может быть либо 126, либо 127:

  • если фактическая длина полезной нагрузки находится в диапазоне от 126 до 65 535 байт, тогда Payload len равно 126, а следующие 16 бит будут рассматриваться как фактическое значение длины полезной нагрузки.
  • если фактическая длина полезной нагрузки составляет от 65 536 байт до ~9223372036,85 гигабайт, тогда Payload len равно 127, а следующие 64 бита будут рассматриваться как фактическое значение длины полезной нагрузки.

Однако, если вам нужно установить Payload len на 127, ваши кадры слишком велики, и вы определенно делаете что-то не так. Вы можете себе представить один фрейм данных размером 9000 петабайт, лол?

  • masking-key — 0 или 4 байта. Если установлен бит mask, то необходимо предоставить 32-битный ключ маскирования. Ключ маскирования используется для демаскирования данных полезной нагрузки, полученных от клиента.
  • Payload Data — фактическая полезная нагрузка, которая отправлена ​​или получена. Большую часть времени он содержит данные приложения (произвольные данные, которые мы — разработчики приложений — отправляем или получаем). Однако он также может содержать данные расширения, если расширение было согласовано во время начального рукопожатия.

Теперь давайте попробуем сами реализовать функцию парсинга фреймов.

Во-первых, давайте определим карту кодов операций в нашем конструкторе WebSocketServer с кодами, которые мы собираемся использовать:

this.OPCODES = { text: 0x01, close: 0x08 };

Теперь давайте определим метод с именем parseFrame, который получает буфер, извлекает из него полезную нагрузку и возвращает строку utf-8.

Поскольку мы не собираемся использовать код операции 0x00 (кадр продолжения), будем считать, что бит FIN всегда установлен.

Мы читаем первый байт буфера и извлекаем значение opcode, которое является последними 4 битами первого байта. Если opcode равно 0x08, мы генерируем событие close и возвращаем null. И мы также отказываемся обрабатывать что-либо, кроме обычного текста (opcode 0x01).

Чтобы обработать событие закрытия соединения, добавим прослушиватель событий close и уничтожим сокет внутри его обратного вызова ☠️

Теперь попробуем извлечь длину полезной нагрузки.

Для этого прочитаем второй байт буфера и извлечем из него последние 7 бит. Это будет блок payload len кадра. Если это значение равно 126 или 127, фактическая длина полезной нагрузки будет определена в следующих 2 или 8 байтах буфера соответственно.

Мы не собираемся использовать значение длины полезной нагрузки, поэтому просто настроим счетчик смещения, чтобы знать, когда фактические данные полезной нагрузки начинаются в буфере.

Начальное значение offset равно 2, потому что мы уже прочитали первый и второй байты буфера. Если значение блока payload len равно 126 или 127, мы знаем, что следующие 2 или 8 байтов будут занимать фактическую длину полезной нагрузки, поэтому мы добавляем соответствующее значение к смещению.

Нам также нужно позаботиться о проверке бита mask (который является первым битом второго байта) и извлечении masking-key :

Если бит mask установлен, то мы знаем, что наша фактическая полезная нагрузка начинается с 4 байтов (запись после ключа маскирования), поэтому мы добавляем 4 к нашему offset. Если бит mask не установлен, то наше значение offset уже указывает на начало payload data в нашем буфере, поэтому мы можем просто прочитать его до конца и вернуться.

Чтобы получить ключ маскировки, мы используем функцию readUInt32BE, которая будет извлекать 32 бита данных из нашего буфера, начиная с байта offset.

Теперь, имея маскирующий ключ, нам нужно демаскировать полезную нагрузку.

Алгоритм маскирования и демаскирования одинаков и определен в Разделе 5.3 спецификации. По сути, это сводится к следующему:

Октет i преобразованных данных представляет собой XOR октета i исходных данных с октетом в индексе i по модулю 4 ключа маскирования.

Мы работаем на уровне байтов, поэтому нам понадобится двоичная арифметика для преобразования каждого бита полезной нагрузки. Мы пройдемся по каждому байту полезной нагрузки и выполним операцию XOR с соответствующим байтом маскирующего ключа:

Теперь давайте обновим наш обратный вызов обновления запроса внутри метода _init, чтобы добавить прослушиватель событий data.

Как видите, мы просто повторно генерируем событие data, но также передаем проанализированные данные наружу (используя parseFrame(buffer)).

Вот как вы можете его использовать:

Чтобы проверить, работает ли это, вы можете перейти в консоль браузера и запустить что-то вроде следующего:

const ws = new WebSocket('ws://localhost:4000');

ws.send(JSON.stringify({ message: 'Hello World' }));

В вашем терминале вы должны увидеть сообщение из браузера:

Ура! 🤘

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

5. Отправка данных

Мы уже знаем структуру фрейма WebSocket, поэтому теперь создать его будет намного проще. Кроме того, нам не нужно делать маскировку!

Чтобы создать кадр, нам нужно ответить на вопрос: Какой размер буфера мы должны выделить для кадра?

Вот что мы уже знаем:

  • нам нужен первый байт для FIN, RSV бит и opcode;
  • нам нужен второй байт для хранения бита mask и payload len;
  • нам нужно решить, нужны ли нам дополнительные 2 или 8 байт для больших полезных нагрузок;
  • а также есть длина нашей фактической полезной нагрузки в байтах.

Обратите внимание, что мы немного упрощаем и не выделяем 4 байта для маскирующего ключа.

Чтобы еще больше упростить ситуацию, предположим, что бит FIN всегда установлен, а значение opcode всегда равно 0x01 (текстовый фрейм).

Затем мы можем просто жестко закодировать первый байт кадра — 0b10000001:

FIN (1), RSV1 (0), RSV2 (0), RSV3 (0), Опкод (0001)

Давайте создадим метод createFrame(data) и заполним первые 2 байта и, возможно, следующие 2 или 8 байтов в качестве значения extra payload length. После этого мы просто добавим наши данные полезной нагрузки в конец полученного буфера:

В приведенном выше коде мы постепенно увеличиваем переменную payloadBytesOffset, чтобы она соответствовала смещению, с которого должна начинаться фактическая полезная нагрузка в буфере.

Как только мы узнаем payloadBytesOffset, мы можем легко вычислить общую длину результирующего буфера, добавив длину полезной нагрузки в байтах и ​​смещение.

Затем мы записываем payload len и, возможно, extra payload length в результирующий буфер. Благодаря вспомогательным функциям BufferwriteUInt16BE и writeBigUInt64BE — это всего лишь вызов функции.

И наконец — наша полезная нагрузка.

Вот так, полегче. 😎

Давайте теперь соберем все вместе в следующем разделе и посмотрим, как можно использовать наш сервер.

6. Собираем все вместе

У нас есть свои методы как для разбора, так и для создания фреймов WebSocket, и мы также реализовали процедуру открытия рукопожатия 💪

Чтобы использовать все это, давайте сначала изменим наш обратный вызов data, чтобы передать функцию reply конечному пользователю:

При этом мы можем не только получать данные, но и отправлять данные обратно клиенту:

Идите вперед и введите в консоли браузера

const ws = new WebSocket('ws://localhost:4000');

ws.addEventListener('message', ({ data }) => { console.log(JSON.parse(data)) });
ws.send(JSON.stringify({ ping: 'Hello World' }));

Вы увидите, что наш сервер отвечает pong:

Ооооо. Теперь давайте построим что-то более существенное.

Давайте определим некоторые методы API, которые наш сервер будет предоставлять клиентам:

Функция sleep — это просто переименованный импорт функции setTimeout:

const { setTimeout: sleep } = require('node:timers/promises');

Теперь давайте заставим наш сервер предоставлять этот API клиентам:

Давайте использовать его сейчас!

В консоли браузера мы можем подключиться к нашему серверу и отправить несколько запросов:

const ws = new WebSocket('ws://localhost:4000');

ws.addEventListener('message', ({ data }) => { console.log(JSON.parse(data)) });

ws.send(JSON.stringify({ method: 'auth', args: ['admin', 'wrong'] }));
ws.send(JSON.stringify({ method: 'auth', args: ['admin', 'secret'] }));
ws.send(JSON.stringify({ method: 'getUsers' }));

В консоли браузера вы должны увидеть что-то вроде этого:

Ух ты, этот RPC использует WebSockets?

Ага, похоже 😏

Поздравляю! Вы дошли до конца этой статьи. К настоящему времени вы должны знать, как протокол WebSocket работает внутри, включая рукопожатие клиент-сервер и кадрирование данных. Как видите, WebSockets не слишком сложный протокол, и даже RFC вполне понятен.

В учебных целях вы можете попробовать реализовать потоковую передачу с помощью нашего WebSocketServer (используя кадры продолжения), что не должно быть слишком сложно, или добавить настоящую JWT-аутентификацию на наш сервер 🤖

Вот полный код этой статьи (я также добавил отслеживание соединений в свойстве clients класса):

7. Дополнительные ресурсы для чтения

  1. RFC 6455 — Протокол WebSocket
  2. WebSockets — концептуальное глубокое погружение (Ably)
  3. Написание серверов WebSocket (MDN)

Спасибо за прочтение.