30 июля

Пишем свой dependency free WebSocket сервер на Node.js

Node.JSTypeScript


Node.js — это популярный инструмент для построения клиент-серверных приложений. При правильном использовании, Node.js способен обрабатывать большое количество сетевых запросов, используя всего один поток. Несомненно, сетевой ввод — вывод является одной из сильнейших сторон этой платформы. Казалось бы, что используя Node.js для написания серверного кода приложения, активно использующего различные сетевые протоколы, разработчики должны знать, как эти протоколы работают, но зачастую это не так. Виной тому еще одна сильная сторона Node.js, это его пакетный менеджер NPM, в котором можно найти готовое решение практически под любую задачу. Используя готовые пакеты, мы упрощаем себе жизнь, переиспользуем код (и это правильно), но в то же время скрываем от себя, за ширмой библиотек, суть происходящих процессов. В этой статье мы постараемся разобраться в протоколе WebSocket, реализуя часть спецификации, не используя внешних зависимостей. Добро пожаловать под кат.


Историческая справка


Для начала необходимо разобраться с исторической составляющей, а именно, зачем придумали сетевой протокол WebSocket и что послужило главной мотивацией для его создания. Изначально приложения, которым требовался активный обмен данными с сервером, использовали протокол http, что накладывало много ограничейний, связанных с этим протоколом. Ведь при создании http не предпологалось использовать его как двунаправленный протокол. Http работает по принципу request/reply — клиент отправляет запрос на сервер, а сервер на этот запрос формирует ответ и отправляет его клиенту. Каждый раз при такой схеме происходит установка нового соединения (напомню, что я рассказываю про стародревние времена до http 2.0). Протокол не подразумевает, что сервер сам может инициировать соединение с клиентом и отправить ему сообщение. Поэтому многие клиентские приложения, на подобии чатов, используя проткол http, были вынуждены с определенным интервалом опрашивать сервер на предмет изменений его состояния. Существует спецификация RFC6202, которая описывает лучшие практики относительно того, как серверу передавать сообщения клиенту по своей инициативе. Первая версия стандарта протокола WebSocket появилась в 2008 году, после чего несколько раз перерабатывалась. То, что мы знаем как WebSocket на данный момент появилось в 2011 году в виде 13ой версии протокола и описанной в стандарте RFC6455. Протокол находится на том же уровне сетевой модели OSI что и http и так же работает поверх tcp. WebSocket решает все описанные проблемы присущие http. Протокол WebSocket является двунаправленным, что означает, что после установки соединения, клиент и сервер могут обмениваться асинхронными сообщениями по открытому подключению. Инициировать подключение может как клиент так и сервер. К слову сказать, поддержка протокола WebSocket в браузере появилась в 2009 году и первым браузером, реализовавшем стандарт, был Google Chrome 4й версии. Но от к слов к делу, у нас есть протокол, давайте разберемся с ним и начнем его реализовывать. Работа с WebSocket делится на два больших этапа:


  1. Создание соединения с помощью процесса рукопожатия (handshake)
  2. Передача данных

Рукопожатие


Для того, чтобы клиент смог установить соединение с сервером, по протоколу WebSocket, нужно перевести http сервер в этот режим работы. Чтобы это сделать, нужно отправить GET запрос со специальными заголовками. Но чтобы понять, какие заголовки отправляются на сервер из браузера, при попытки установить сокетное соединение, не будем сразу смотреть в спецификацию к протоколу, а начнем писать сервер и увидем эти заголовки в консоли. Для начала напишем http сервер, который будет принимать любой запрос и выводить в консоль заголовки этого запроса. Код я буду писать на typescript и запускать с помощью ts-node.


import * as http from 'http';
import * as stream from 'stream';

export class SocketServer {
  constructor(private port: number) {
    http
      .createServer()
      .on('request', (request: http.IncomingMessage, socket: stream.Duplex) => {
        console.log(request.headers);
      })
      .listen(this.port);
      console.log('server start on port: ', this.port);
  }
}

new SocketServer(8080);

Сервер будет запущен на порту 8080. Теперь откроем консоль разработчика в браузере и напишем следующий код.


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

При создании объекта класса WebSocket, браузер попытается подключиться к серверу. У полученного объекта можно посмотреть текущее состояние подключения с помощью свойства readyState. Это свойство может принимать одно из четырех значений:


  • 0 — установка соединения
  • 1 — соединение установлено. Данные можно передавать
  • 2 — соединение находится в процессе закрытия
  • 3 — соединение закрыто

Если сейчас посмотреть свойство readyState, то оно будет в состоянии 0, но через некоторое время перейдет в состояние 3. Это происходит потому, что мы ничего не ответили на запрос перевода сервера на работу с другим протоколом. Подробнее про WebSocket API в браузере можно почитать тут


В консоли с запущенным сервером получим следующее:


{
  host: 'localhost:8080',
  connection: 'Upgrade',
  pragma: 'no-cache',
  'cache-control': 'no-cache',
  'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36',
  upgrade: 'websocket',
  origin: 'chrome-search://local-ntp',
  'sec-websocket-version': '13',
  'accept-encoding': 'gzip, deflate, br',
  'accept-language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
  'sec-websocket-key': 'h/k2aB+Gu3cbgq/GoSDOqQ==',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'
}

Для перехода на другой протокол используется стандартный механизм, описанный в стандарте http RFC2616. Происходит http запрос типа GET, в котором передаётся заголовок upgrade с названием протокола, на который клиент хочет переключить сервер. Если сервер поддерживает желаемый протокол, то он должен ответить кодом 101, если нет — вернуть ошибку. В описании протокола WebSocket дополнительно передаётся еще несколько заголовков, часть из которых опциональны:


  • sec-websocket-version версия проткола. На текущий момент это 13я версия
  • sec-websocket-extensions список расширений протокола, которые хочет использовать клиент. В данном случае, это сжатие сообщений
  • sec-websocket-protocol в этом заголовки клиент может передать список подпротоколов, на которых клиент хочет общаться с сервером. При этом сервер, если поддерживает эти подпротоколы, должен выбрать один из переданных и отправить его название в заголовках ответа. Подпротокол — это формат данных, в котором будут отправляться и приниматься сообщения.
  • sec-websocket-key самый важный заголовок для установки подключения. В нем передаётся случайный ключ. Этот ключ должен быть уникальным для каждого рукопожатия.

Чтобы клиент понял, что сервер успешно перешел на нужный протокол, сервер должен ответить кодом 101, а в ответе должен быть заголовок sec-websocket-accept, значение которого сервер должен сформировать, используя заголовок sec-websocket-key следующим образом:


  1. Добавить к заголовку sec-websocket-key константу 258EAFA5-E914-47DA-95CA-C5AB0DC85B11
  2. Получить хеш sha-1 полученного объединенного значения
  3. Перевести полученный хеш в строку в кодировке base64

Так же сервер должен передать в заголовках ответа заголовки Upgrade: WebSocket и Connection: Upgrade. Звучит не сложно, давайте реализуем. Для генерации загловка sec-websocket-key нам потребуется встроеный в node.js модуль crypto. Необходимо в начале импортировать его.


import * as crypto from 'crypto';

А затем изменить конструктор класса SocketServer


private HANDSHAKE_CONSTANT = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
constructor(private port: number) {
  http
    .createServer()
    .on('upgrade', (request: http.IncomingMessage, socket: stream.Duplex) => {
      const clientKey = request.headers['sec-websocket-key'];
      const handshakeKey = crypto
        .createHash('sha1')
        .update(clientKey + this.HANDSHAKE_CONSTANT)
        .digest('base64');
      const responseHeaders = [
        'HTTP/1.1 101',
        'upgrade: websocket',
        'connection: upgrade',
        `sec-webSocket-accept: ${handshakeKey}`,
        '\r\n',
      ];
      socket.write(responseHeaders.join('\r\n'));
    })
    .listen(this.port);
  console.log('server start on port: ', this.port);
}

У http сервера Node.js есть специальное событие на upgrade соединения, используем его. Перезапустив сокет сервер с этими изменениями и снова попытавшись создать соединение в браузере, мы получим объект сокета, который будет в состоянии 1. Мы успешно создали соединение с нашим сервером и завершили первый этап. Переёдем ко второму.


Передача данных


Передача данных по сокетам происходит с помощью фреймов. Каждый фрейм — это единица информации с данными и метаинформацией. Фреймы сокетов никак не соотносятся с делением информации на фреймы или пакеты на более низких уровнях сетевой модели. За счет того, что информация передаётся фреймами, появляется возможность фрагментировать сообщения, т.е. пересылать сообщения частями. За счет фрагметации можно по сокетам передвать сообщения неизвестной длины, например, если нам нужно передать в виде сообщения большой файл, который мы читаем из стороннего источника (так тоже можно). Для того, чтобы разобраться с фреймами, нужно понимать, по каким правилам он формируется. В стандарте приведена следующая структура фрейма.



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


Неизменная часть фрейма. Длина этой части 2 байта


0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
FIN RSV1 RSV2 RSV3 OPCODE MASK Длина сообщения

  • FIN Этот бит показывает конечный это фрейм или нет. Если значение его 1, то фрейм конечный, если 0, то этот фрейм принадлежит фрагментированному сообщению и его следует буферизировать. Сообщения могут состоять из одного фрейма.
  • RSV1, RSV2, RSV3 Эти три бита нужны для расширений протокола и используются ими. В нашем примере они будут нулевыми
  • OPCODE Эти 4 бита определяют тип фрейма. Фреймы делятся на два больших типа: управляющие фреймы и фреймы с данными. Всего фреймы с данными могут быть двух типов. Текстовые данный, в кодировке UTF8, и бинарные. Управляющих фреймов всего 3 ping, pong, close. Остальные коды зарезервированны для дальнейшего возможного использования.
    • х0 Обозначает, что это фрейм — продолжение фрагментированного сообщения
    • х1 Фрейм с текстовым сообщением
    • х2 Фрейм с бинарными данными
    • х8 Фрейм инициирующий закрытие подключения
    • х9 Фрейм Ping
    • xA Фрейм Pong
  • MASK Этот бит говорит — замаскированны данные внутри фрейма или нет. Если 0, то данные не замаскированны, если 1, то данные замаскированны. Спецификация протокола требует, чтобы данные с клиента были всегда замаскированны, а с сервера всегда не замаскированны. Сама маска фрейма, если она есть, хранится в следующей части фрейма.
  • Длина сообщения Эти 7 бит определяют, чем будут являться следующие байты фрейма.

Изменяемая часть фрейма. Длина этой части от 0 до 12 байт


  • Если длина сообщения <= 125, то это короткое сообщение и это значение интерпритируется, именно, как длина сообщения. Поэтому в изменяемой части фрейма будет только маска, если это сообщение с клиента
  • Если длина сообщения = 126 то следующие 2 байта хранят его размер
  • Если длина сообщения = 127 то следующие 8 байт хранят его размер

Может быть 0, 2, 8 байт Может быть 0, 4 байта
Размер сообщения Маска

Данные фрейма


Определив какой размер данных, в полученном нами фрейме, мы можем прочитать его. Но данные с клиента всегда должны быть маскированны. Маска — это случайные 4 байта, которые клиент передаёт во фрейме. Отправляя данные, клиент накладывает эту маску на данные с помощью функции XOR. Для того, чтобы сервер мог расшифровать информацию, нужно повторно наложить маску с помощью функции XOR.


Это основные знания, которые потребуются для реализации WebSocket сервера.


Реализуем часть протокола


Что бы в реализации сервера была какая то цель, нужно эту цель придумать. Целью кода данной статьи будет написание WebSocket сервера, который реализует часть протокола сокетов и позволяет переписываться нескольким клиентам из консоли браузера. Для начала нужно реализовать функционал опроса клиента с помощью управляющих фреймов Ping. Нам нужно знать, что клиент еще жив и готов принимать данные с сервера. Фрейм Ping, управляющий фрейм, но он так же может содержать данные. Когда клиент получит такое сообщение по сокету, он должен отправить на сервер фрейм Pong с теми данными, которые были во фрейме Ping. До реализации этого функционала, давайте пропишем в класс сервера необходимые константы


private MASK_LENGTH = 4; // Длина маски. Указана в спецификации
private OPCODE = {
  PING: 0x89, // Первый байт управляющего фрейма Ping
  SHORT_TEXT_MESSAGE: 0x81, // Первый байт фрейма с данными, которые убираются в 125 байт
};
private DATA_LENGTH = {
  MIDDLE: 128, // Нужно, чтобы исключить первый бит из байта с длинной сообщения
  SHORT: 125, // Максимальная длина короткого сообщения
  LONG: 126, // Означает, что следующие 2 байта содержат длину сообщения
  VERY_LONG: 127, // Означает, что следующие 8 байт содержат длину сообщения
};

Далее реализуем наш метод по формированию фрейма Ping


private ping(message?: string) {
  const payload = Buffer.from(message || '');
  const meta = Buffer.alloc(2);
  meta[0] = this.OPCODE.PING;
  meta[1] = payload.length;
  return Buffer.concat([meta, payload]);
}

По большому счету, в данном случае, это не требуется. Нам совершенно не обязательно пересылать какие-то данные клиенту вместе с управляющим фреймом Ping. Поэтому этот метод можно удалить, а вместо него в класс добавить еще одну константу. Также для того, чтобы реализовать функционал чата, нам потребуется хранить объекты подключений. Заведем под это отдельную коллекцию в классе сервера.


private CONTROL_MESSAGES = {
  PING: Buffer.from([this.OPCODE.PING, 0x0]),
};
private connections: Set<stream.Duplex> = new Set();

Модицифируем конструктор, добавим отправку фрейма Ping подключившимся клиентам с интервалом в 5 секунд, а также добавляем новых клиентов в коллекцию.


setInterval(() => socket.write(this.CONTROL_MESSAGES.PING), heartbeatTimeout);
this.connections.add(socket);

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


private decryptMessage(message: Buffer) {
  const length = message[1] ^ this.DATA_LENGTH.MIDDLE; // 1
  if (length <= this.DATA_LENGTH.SHORT) {
    return {
      length,
      mask: message.slice(2, 6), // 2
      data: message.slice(6),
    };
  }
  if (length === this.DATA_LENGTH.LONG) {
    return {
      length: message.slice(2, 4).readInt16BE(), // 3
      mask: message.slice(4, 8),
      data: message.slice(8),
    };
  }
  if (length === this.DATA_LENGTH.VERY_LONG) {
    return {
      payloadLength: message.slice(2, 10).readBigInt64BE(), // 4
      mask: message.slice(10, 14),
      data: message.slice(14),
    };
  }
  throw new Error('Wrong message format');
}

  1. В этой строке нам нужно получить длину данных внутри фрейма. Мы делаем это с помощью операции XOR и констранты, которая представляет число 128 в двоичном виде, которое выглядит как 10000000. В данном случае мы это делаем, исходя из того, что данные от клиента всегда приходят в маскированном виде, а значит первый бит этого байта всегда будет 1.
  2. Согласно спецификации для фреймов с длиной 126, длина сообщения передаётся в двух следующих байтах
  3. Согласно спецификации для фреймов с длиной 127, длина сообщения передаётся в восьми следующих байтах

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


private unmasked(mask: Buffer, data: Buffer) {
  return Buffer.from(data.map((byte, i) => byte ^ mask[i % this.MASK_LENGTH]));
}

Демаскирование происходит путем применения функции XOR к каждому байту данных и соответствующему ему байту маски. Длина маски указана в спецификации и составляет 4 байта. Теперь можно написать метод для отправки коротких сообщений по сокету клиенту.


public sendShortMessage(message: Buffer, socket: stream.Duplex) {
  const meta = Buffer.alloc(2);
  meta[0] = this.OPCODE.SHORT_TEXT_MESSAGE;
  meta[1] = message.length;
  socket.write(Buffer.concat([meta, message]));
}

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


socket.on('data', (data: Buffer) => {
  if (data[0] === this.OPCODE.SHORT_TEXT_MESSAGE) { // Обрабатываем в данном примере только короткие сообщения
    const meta = this.decryptMessage(data);
    const message = this.unmasked(meta.mask, meta.data);
    this.connections.forEach(socket => {
      this.sendShortMessage(message, socket);
    });
  }
});

this.connections.forEach(socket => {
  this.sendShortMessage(
    Buffer.from(`Подключился новый участник чата. Всего в чате ${this.connections.size}`),
    socket,
  );
});

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


const socket = new WebSocket('ws://localhost:8080');
socket.onmessage = ({ data }) => console.log(data);

Затем отправить сообщение в одной из вкладок


socket.send('Hello world!');


Итоги


Конечно, если в вашем приложении нужны WebSocket'ы, а скорее всего они нужны, не стоит реализовывать протокол самостоятельно без существенной необходимости. Всегда можно выбрать подходящее решение из многообразия библиотек в npm. Лучше переиспользовать уже написанный и протестированный код. Но понимание как это работает "под капотом", всегда даст много больше, чем просто использование чужого кода. Приведенный выше пример доступен на github

Теги:nodejstypescriptwebsocket
Хабы: Node.JS TypeScript
+29
6,9k 113
Комментарии 4
Профессия iOS-разработчик
30 ноября 202075 000 ₽SkillFactory
Основы HTML и CSS
30 ноября 2020БесплатноНетология
SMM-менеджер
30 ноября 202059 998 ₽GeekBrains
Frontend-разработчик с нуля
30 ноября 202077 940 ₽Нетология
Курс по аналитике данных
30 ноября 202053 500 ₽SkillFactory
Лучшие публикации за сутки