Рукописный протокол WebSocket с Node.js

Оглавление

введение

Переключиться с http на websocect

Sec-WebSocket-Key 与 Sec-WebSocket-Accept

Совершенно новый двоичный протокол

Реализовать сервер веб-сокетов самостоятельно

Разобрать полученный буфер в соответствии с форматом протокола

Выньте код операции

Выньте МАСКУ и длину полезной нагрузки

Чтение данных по ключу маски

Обработка данных по типу

рамка рамка

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

полный код

Подведем итог


введение

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

        Некоторые студенты сказали, что http2 не поддерживает сервер? Это только для нажатия ресурсов:

картина

        Например, если браузер запрашивает html, сервер может отправить в браузер вместе css. Браузер может решить, принимать или нет. Для сценариев с высокими требованиями к реальному времени, таких как обмен мгновенными сообщениями, требуется веб-сокет.

Переключиться с http на websocect

        Строго говоря, websocket не имеет ничего общего с http, это другой формат протокола. Но для этого нужен процесс переключения с http на websocekt.

картина

Подробно процесс переключения выглядит следующим образом:

1. При запросе приведите эти заголовки:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Ia3dQjfWrAug/6qm7mTZOg==

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

2. Сервер возвращает этот заголовок:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: JkE58n3uIigYDMvC+KsBbGZsp1A=

        Подобно заголовку запроса, Sec-WebSocket-Accept является результатом обработки Sec-WebSocket-Key, предоставленного запросом.

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

Sec-WebSocket-Key 与 Sec-WebSocket-Accept

        Как можно обработать Sec-WebSocket-Key для получения Sec-WebSocket-Accept? Я реализовал это с помощью узла, и это выглядит так:

const crypto = require('crypto');

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

        То есть использовать ключ, отправленный клиентом, добавить фиксированную строку и преобразовать ее в base64 после шифрования sha1.

Эта строка 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 исправлена, если не верите, поищите по ней:

картина

Просто найдите веб-сайт с веб-сокетами, например Zhihu:

картина

        Отфильтруйте запросы типа ws и посмотрите, являются ли эти заголовки упомянутыми выше. Этот Sec-WebSocket-Key равен wk60yiym2FEwCAMVZE3FgQ==

картина

А ответ Sec-WebSocket-Accept — XRfPnS+8xl11QWZherej/dkHPHM=

картина

Давайте посчитаем:

картина

        Это одно и то же! Это процесс вычисления Sec-WebSocket-Accept, соответствующего Sec-WebSocket-Key, когда веб-сокет обновляет протокол.

После этого шага мы переключимся на протокол websocket, который является совершенно новым протоколом.

Совершенно новый двоичный протокол

Проверьте столбец сообщения, чтобы увидеть переданное сообщение, которое может быть текстовым или двоичным.

картина

Новое соглашение? Что это за соглашение? Такой:

картина

Протокол http, к которому все привыкли, представляет собой заголовок ключ:значение с телом:

картина

        Это текстовый протокол, и каждый заголовок представляет собой удобочитаемый символ. Это легко понять, но передача занимает слишком много места. А websocket — это бинарный протокол, один байт может использоваться для хранения большого количества информации:

картина

        Например, первый байт (8 двоичных битов) протокола хранит такую ​​информацию, как FIN (флаг окончания), код операции (независимо от того, является ли тип содержимого двоичным или текстовым).

        Второй байт хранит маску (независимо от того, зашифрована ли она), полезную нагрузку (длину данных).

        Всего два байта, сколько хранится информации! Здесь бинарный протокол лучше текстового.

        Отправка и получение сообщений weboscket, которые мы видим, на самом деле прописаны в таком формате на нижнем уровне.

картина

Просто браузер парсит нам данные протокола в этом формате.

        Это весь процесс weboscket. На самом деле, это вполне понятно, процесс переключения протоколов, а затем отправка и получение бинарных протоколов weboscket.

Реализовать сервер веб-сокетов самостоятельно

Тогда давайте сами реализуем сервер веб-сокетов с помощью Node.js!

1. Определите класс MyWebsocket:

const { EventEmitter } = require('events');
const http = require('http');

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      
    });
  }
}

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

        В конструкторе мы создали http-сервис, при возникновении события ungrade, то есть при получении заголовка Connection: upgrade, будет возвращен заголовок протокола переключения.

Возвращаемый заголовок был замечен ранее, он связан с ключом sec-websocket.

server.on('upgrade', (req, socket) => {
  this.socket = socket;
  socket.setKeepAlive(true);

  const resHeaders = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
    '',
    ''
  ].join('\r\n');
  socket.write(resHeaders);

  socket.on('data', (data) => {
    console.log(data)
  });
  socket.on('close', (error) => {
      this.emit('close');
  });
});

Получаем сокет и возвращаем приведенный выше заголовок, где обработка ключа — алгоритм, рассмотренный ранее:

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

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

const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });

ws.on('data', (data) => {
  console.log('receive data:' + data);
});

ws.on('close', (code, reason) => {
  console.log('close:', code, reason);
});

картина

Затем создайте новый HTML-код следующим образом:

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

Используйте API-интерфейс WebSocket браузера, чтобы установить соединение и отправить сообщение.

Используйте npx http-сервер Запустите статический сервис. Затем браузер посещает этот html:

Затем откройте devtools и вы обнаружите, что переключение протокола прошло успешно:

картина

Эти 3 заголовка и код состояния 101 возвращаются нами. Вы также можете увидеть отправленное сообщение в сообщении:

картина

Зайдя снова на сервер, я также получил это сообщение:

картина

Это просто Buffer, то есть бинарный.

Разобрать полученный буфер в соответствии с форматом протокола

Выньте код операции

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

Эта часть еще более хлопотная, давайте посмотрим на нее немного.

картина

Нам нужны последние четыре бита первого байта, который является кодом операции.

Напишите это так:

const byte1 = bufferData.readUInt8(0);
let opcode = byte1 & 0x0f; 

Прочитать содержимое 8-битного целого числа без знака, то есть содержимое байта. Параметр представляет собой байт смещения, который здесь равен 0.

Последние четыре бита удаляются битовой операцией, и это код операции.

Выньте МАСКУ и длину полезной нагрузки

Затем обработайте второй байт:

картина

Первый бит — это бит флага маски, а последние 7 бит — длина полезной нагрузки.

Можно принимать так:

const byte2 = bufferData.readUInt8(1);
const str2 = byte2.toString(2);
const MASK = str2[0];
let payloadLength = parseInt(str2.substring(1), 2);

По-прежнему используйте buffer.readUInt8 для чтения содержимого байта.

Сначала преобразуйте его в двоичную строку, затем первая цифра — это маска, затем перехватите подстроку из последних 7 цифр, parseInt в число, это длина полезной нагрузки.

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

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

картина

Это связано с тем, что данные не обязательно имеют большую длину, для них может потребоваться 16-битная длина хранения, а может потребоваться 32-битная.

Таким образом, протокол веб-сокета предусматривает, что если 7-битное содержимое не превышает 125, то это длина полезной нагрузки.

Если 7-битный контент равен 126, не используйте его и используйте следующий 16-битный контент в качестве длины полезной нагрузки.

Если 7-битный контент равен 127, не используйте его и используйте последний 64-битный контент в качестве длины полезной нагрузки.

На самом деле, это легко понять, то есть три, если еще.

Вот как это написано в коде:

let payloadLength = parseInt(str2.substring(1), 2);

let curByteIndex = 2;

if (payloadLength === 126) {
  payloadLength = bufferData.readUInt16BE(2);
  curByteIndex += 2;
} else if (payloadLength === 127) {
  payloadLength = bufferData.readBigUInt64BE(2);
  curByteIndex += 8;
}

Здесь curByteIndex хранит количество обработанных в данный момент байтов.

Если это 126, то начните с третьего байта, прочитайте 2 байта, длина которых составляет 16 бит, и используйте метод buffer.readUInt16BE.

Если это 127, начните с третьего байта, прочитайте 8 байтов, что составляет 64 бита, и используйте метод buffer.readBigUInt64BE.

картина

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

Чтение данных по ключу маски

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

картина

Прочитайте 4 байта, которые являются ключом маски.

Последний можно прочитать по длине полезной нагрузки.

let realData = null;

if (MASK) {
  const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
  curByteIndex += 4;
  const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
  realData = handleMask(maskKey, payloadData);
} else {
  realData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);;
}

Затем используйте ключ маски для расшифровки данных.

Этот алгоритм также исправлен, просто используйте ключ маски каждого байта и каждого бита данных, чтобы выполнить побитовое XOR:

function handleMask(maskBytes, data) {
  const payload = Buffer.alloc(data.length);
  for (let i = 0; i < data.length; i++) {
    payload[i] = maskBytes[i % 4] ^ data[i];
  }
  return payload;
}

Таким образом, у нас есть окончательные данные!

Обработка данных по типу

Но прежде чем передать его в обработчик, его нужно обработать по типу, потому что контент делится на несколько типов, то есть опкод имеет несколько значений:

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1, // 文本
  BINARY: 2, // 二进制
  CLOSE: 8,
  PING: 9,
  PONG: 10,
};

Давайте просто разберемся с текстом и двоичным кодом:

handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        this.emit('data', realDataBuffer.toString('utf8'));
        break;
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer);
        break;
      default:
        this.emit('close');
        break;
    }
}

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

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

Давайте попробуем:

Нам уже удавалось получить буфер содержимого протокола weboscket:

картина

И теперь мы можем правильно разобрать данные:

картина

Пока что анализ нашего протокола веб-сокета проходит успешно!

рамка рамка

Данные в таком формате протокола называются кадром, то есть кадром:

картина

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

Анализ в порядке, и тогда мы осуществим отправку данных.

Отправка также построена в том же формате кадра.

Определите такой метод отправки:

send(data) {
    let opcode;
    let buffer;
    if (Buffer.isBuffer(data)) {
      opcode = OPCODES.BINARY;
      buffer = data;
    } else if (typeof data === 'string') {
      opcode = OPCODES.TEXT;
      buffer = Buffer.from(data, 'utf8');
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer);
}

doSend(opcode, bufferDatafer) {
   this.socket.write(encodeMessage(opcode, bufferDatafer));
}

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

Затем создайте фрейм веб-сокета:

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

Мы обрабатываем только случай, когда длина данных меньше 125.

Первый байт — это код операции, мы устанавливаем первый бит в 1 и записываем его побитовым или.

картина

Серверу не нужна маска для отправки сообщения обратно клиенту, поэтому второй байт — это длина полезной нагрузки.

Запишите первые два байта данных в буфер соответственно и укажите другое смещение:

bufferData.writeUInt8(byte1, 0);
bufferData.writeUInt8(byte2, 1);

Затем поместите данные полезной нагрузки сзади:

 payload.copy(bufferData, 2);

Такой каркас веб-сокета построен.

Давай попробуем:

картина

После получения сообщения от клиента сообщение возвращается каждые две секунды.

картина

Отправка и получение сообщений успешно!

полный код

Таким образом, мы сами реализовали сервер веб-сокетов и реализовали парсинг и генерацию протокола веб-сокетов!

Полный код выглядит следующим образом:

MyWebSocket:

//ws.js
const { EventEmitter } = require('events');
const http = require('http');
const crypto = require('crypto');

function hashKey(key) {
  const sha1 = crypto.createHash('sha1');
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
  return sha1.digest('base64');
}

function handleMask(maskBytes, data) {
  const payload = Buffer.alloc(data.length);
  for (let i = 0; i < data.length; i++) {
    payload[i] = maskBytes[i % 4] ^ data[i];
  }
  return payload;
}

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1,
  BINARY: 2,
  CLOSE: 8,
  PING: 9,
  PONG: 10,
};

function encodeMessage(opcode, payload) {
  //payload.length < 126
  let bufferData = Buffer.alloc(payload.length + 2 + 0);;
  
  let byte1 = parseInt('10000000', 2) | opcode; // 设置 FIN 为 1
  let byte2 = payload.length;

  bufferData.writeUInt8(byte1, 0);
  bufferData.writeUInt8(byte2, 1);

  payload.copy(bufferData, 2);
  
  return bufferData;
}

class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options);

    const server = http.createServer();
    server.listen(options.port || 8080);

    server.on('upgrade', (req, socket) => {
      this.socket = socket;
      socket.setKeepAlive(true);

      const resHeaders = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
        '',
        ''
      ].join('\r\n');
      socket.write(resHeaders);

      socket.on('data', (data) => {
        this.processData(data);
        // console.log(data);
      });
      socket.on('close', (error) => {
          this.emit('close');
      });
    });
  }

  handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        this.emit('data', realDataBuffer.toString('utf8'));
        break;
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer);
        break;
      default:
        this.emit('close');
        break;
    }
  }

  processData(bufferData) {
    const byte1 = bufferData.readUInt8(0);
    let opcode = byte1 & 0x0f; 
    
    const byte2 = bufferData.readUInt8(1);
    const str2 = byte2.toString(2);
    const MASK = str2[0];

    let curByteIndex = 2;
    
    let payloadLength = parseInt(str2.substring(1), 2);
    if (payloadLength === 126) {
      payloadLength = bufferData.readUInt16BE(2);
      curByteIndex += 2;
    } else if (payloadLength === 127) {
      payloadLength = bufferData.readBigUInt64BE(2);
      curByteIndex += 8;
    }

    let realData = null;
    
    if (MASK) {
      const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4);  
      curByteIndex += 4;
      const payloadData = bufferData.slice(curByteIndex, curByteIndex + payloadLength);
      realData = handleMask(maskKey, payloadData);
    } 
    
    this.handleRealData(opcode, realData);
  }

  send(data) {
    let opcode;
    let buffer;
    if (Buffer.isBuffer(data)) {
      opcode = OPCODES.BINARY;
      buffer = data;
    } else if (typeof data === 'string') {
      opcode = OPCODES.TEXT;
      buffer = Buffer.from(data, 'utf8');
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer);
  }

  doSend(opcode, bufferDatafer) {
    this.socket.write(encodeMessage(opcode, bufferDatafer));
  }
}

module.exports = MyWebsocket;

Индекс:

const MyWebSocket = require('./ws');
const ws = new MyWebSocket({ port: 8080 });

ws.on('data', (data) => {
  console.log('receive data:' + data);
  setInterval(() => {
    ws.send(data + ' ' + Date.now());
  }, 2000)
});

ws.on('close', (code, reason) => {
  console.log('close:', code, reason);
});

HTML:

<!DOCTYPE HTML>
<html>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8080");

        ws.onopen = function () {
            ws.send("发送数据");
            setTimeout(() => {
                ws.send("发送数据2");
            }, 3000)
        };

        ws.onmessage = function (evt) {
            console.log(evt)
        };

        ws.onclose = function () {
        };
    </script>
</body>

</html>

Подведем итог

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

websocket не имеет ничего общего с http, но ему нужен процесс переключения с http на websocket.

В дополнение к заголовку обновления, этот процесс переключения также должен принести sec-websocket-key, сервер вычисляет результат на основе этого ключа и возвращает его через sec-websocket-accept. Ответ представляет собой код состояния 101 Switching Protocols.

Этот процесс вычисления является относительно фиксированным, что является результатом ключа + фиксированной строки, зашифрованной с помощью sha1, а затем base64.

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

Затем есть протокол веб-сокета, который является двоичным протоколом.Мы завершили синтаксический анализ и генерацию кадров веб-сокета в соответствии с форматом.

Это реализация полного протокола веб-сокетов.

Мы написали сервис веб-сокетов вручную, вы чувствуете, что у нас есть более глубокое понимание веб-сокетов?

Supongo que te gusta

Origin blog.csdn.net/lambert00001/article/details/131878501
Recomendado
Clasificación