Глядя на WebSocket из исходного кода Chrome

Node.js браузер Chrome WebSocket

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

Можно рассмотреть, как добиться длинного соединения, если не заниматься WebSocket:

(1) HTTP имеет поле проверки активности.Функция этого поля заключается в мультиплексировании TCP-соединений, позволяя одному TCP-соединению отправлять несколько HTTP-запросов и повторно использовать их, чтобы избежать трехэтапных рукопожатий для новых TCP-соединений. Этот поддерживающий сервер времени похож наApacheВремя 5 с, иnginxЗначение по умолчанию — 75 с.По истечении этого времени сервер будет активно закрывать TCP-соединение, потому что, если оно не будет закрыто, большое количество TCP-соединений займет системные ресурсы. Так что этот keep-alive предназначен не для длинных подключений, а для повышения эффективности http-запросов.Как упоминалось выше, http-запросы односторонние, либо сервер отправляет данные, либо клиент выгружает данные.

(2) Опрос с использованием HTTP также является очень распространенным методом.До вебсокета функция чата веб-страниц была в основном реализована таким образом, отправляя запрос на сервер для получения новых сообщений каждые несколько секунд. Проблема с этим методом заключается в том, что он также должен постоянно устанавливать TCP-соединение, а заголовок HTTP очень большой, что неэффективно.

(3) Установите TCP-соединение напрямую с сервером и не прерывайте соединение. Это невозможно по крайней мере на стороне браузера, потому что нет соответствующего API. Итак, есть WebSocket для установления TCP-соединения напрямую с сервером.

TCP-соединения устанавливаются с помощью сокетов. Если вы написали службы Linux, вы знаете, как использовать базовый API (язык C) системы для установления TCP-соединения, которое представляет собой используемый сокет-сокет. Процесс примерно следующий: service Клиент использует сокет для создания прослушивателя TCP:

// 先创建一个套接字,返回一个句柄,类似于setTimout返回的tId
// AF_INET是指使用IPv4地址,SOCK_STREAM表示建立TCP连接(相对于UDP)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 把这个套接字句柄绑定到一个地址,如localhost:9000
bind(sockfd, servaddr, sizeof(servaddr));
// 开始使用这个套接字监听,最大pending的连接数为100
listen(sockfd, 100);

Клиент также использует сокет для подключения:

// 客户端也是创建一个套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 用这个套接字连接到一个serveraddr
connect(sockfd, servaddr, sizeof(servaddr));
// 向这个套接字发送数据
send(sockfd, sendline, strlen(sendline), 0);
// 关闭连接
close(sockfd);

То есть, как TCP, так и UDP-соединения создаются с использованием сокетов, поэтому название WebSocket происходит от этого. По сути, это сокет и стал стандартом. Разработчики также могут напрямую создавать сокеты для связи с сервером, и вам решать, когда сокет должен быть закрыт, вместо того, чтобы автоматически подключать сокет TCP к браузеру или серверу после запроса http.closed.

Так что WebSocket — это не волшебная штука, это сокет. В то же время WebSocket приходится опираться на существующую сетевую инфраструктуру, и создание с нуля набора стандартов для установления соединений будет очень затратным. Единственное, что может подключиться к службе, прежде чем это будет HTTP-запрос, поэтому он должен установить собственное соединение через сокет с помощью http-запроса, поэтому есть эти вещи для преобразования протокола.

Установить соединение WebSocket с помощью браузера очень просто и требует всего несколько строк кода:

// 创建一个套接字
const socket = new WebSocket('ws://192.168.123.20:9090');
// 连接成功
socket.onopen = function (event) {
    console.log('opened');
    // 发送数据
    socket.send('hello, this is from client');
};

Поскольку браузер уже реализовал это согласно документации, как создать сервер WebSocket? Здесь мы сначала откладываем в сторону исходный код Chrome, сначала изучаем реализацию сервера, а потом по очереди смотрим на реализацию браузерного клиента. Подготовьтесь к внедрению сервера WebSocket с Node.js, чтобы изучить весь процесс установления соединения, получения и отправки данных.

WebSocket уже естьRFC 6455Он стандартизирован внутри. Мы можем подключиться к браузеру, пока мы реализуем его в соответствии с положениями документа. Описание этого документа интересно, особенно первая часть. Заинтересованные читатели могут посмотреть, и мы обнаружили, что Реализация WebSocket очень проста. Читатели могут попробовать реализовать ее самостоятельно, если у них есть время, а затем вернуться и сравнить реализацию этой статьи.

1. Установление соединения

Используйте Node.js для создания службы hello, world http, как показано в следующем коде index.js:

let http = require("http");
const hostname = "192.168.123.20"; // 或者是localhost
const port = "9090";

// 创建一个http服务
let server = http.createServer((req, res) => {
    // 收到请求
    console.log("recv request");
    console.log(req.headers);
    // 进行响应,发送数据
    // res.write('hello, world');
    // res.end();
});

// 开始监听
server.listen(port, hostname, () => {
    // 启动成功
    console.log(`Server running at ${hostname}:${port}`);
});

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

Сохраните файл и выполните node index.js, чтобы запустить службу.

Затем напишите index.html, чтобы запросить услугу, описанную выше:

<!DOCType html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
<script>
!function() {
    const socket = new WebSocket('ws://192.168.123.20:9090');
    socket.onopen = function (event) {
        console.log('opened');
        socket.send('hello, this is from client');
    };
}();
</script>
</body>
</html>

Однако мы обнаружили, что функция обратного вызова запроса-ответа в коде Node.js не будет выполняться.Проверив документацию, мы обнаружили, что это связано с тем, что Node.js имеет другое событие обновления:

// 协议升级
server.on("upgrade", (request, socket, head) => {
    console.log(request.headers);
});

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

{ host: '192.168.123.20:9090',
connection: 'Upgrade',
pragma: 'no-cache',
'cache-control': 'no-cache',
upgrade: 'websocket',
origin: 'http://127.0.0.1:8080',
'sec-websocket-version': '13',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36',
'accept-encoding': 'gzip, deflate',
'accept-language': 'en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7',
'sec-websocket-key': 'KR6cP3rhKGrnmIY2iu04Uw==',
'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits' }

Это первый запрос, который мы получаем для установления соединения, в нем два ключевых поля, одно — соединение: «Upgrade» указывает, что это запрос протокола обновления, а другое — sec-websocket-key, который используется для Случайная строка base64 для подтверждения личности другой стороны, которая будет использоваться ниже.

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

server.on("upgrade", (request, socket, head) => {
    let base64Value = '';
    // 第一行是响应行(Response line),返回状态码101
    socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
        // http响应头部字段用\r\n隔开
        'Upgrade: WebSocket\r\n' +
        'Connection: Upgrade\r\n' +
        // 这是一个给浏览器确认身份的字符串
        `Sec-WebSocket-Accept: ${base64Value}\r\n` +
        '\r\n');
});

Ответное сообщение должно быть в формате, указанном http.Первая строка — это строка ответа, которая включает номер версии http, код состояния 101 и объяснение кода состояния. Каждое поле заголовка разделено символом \r\n. Наиболее важным является Sec-WebSocket-Accept, который необходимо вычислить и вернуть в браузер. Как его рассчитать? В документации указано следующее:

GUID(Globally_Unique_Identifier) = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'

Sec-WebSocket-Accept = base64(sha1(Sec-Websocket-key + GUID))

Используя значение sec-websocket-key, предоставленное мне браузером, введите фиксированную строку, эта строка называется глобальным уникальным идентификатором, затем возьмите ее значение sha1, закодируйте его с помощью base64 и верните в браузер. Если браузер обнаружит, что это значение неверно, он выдаст исключение и отклонит следующую операцию подключения:

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

Чтобы вычислить это значение, необходимо ввести библиотеку sha1, а преобразование base64 может использовать преобразование буфера Node.js, как показано в следующем коде:

let sha1 = require('sha1');
// 协议升级
server.on("upgrade", (request, socket, head) => {
    // 取出浏览器发送的key值
    let secKey = request.headers['sec-websocket-key'];
    // RFC 6455规定的全局标志符(GUID)
    const UNIQUE_IDENTIFIER = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    // 计算sha1和base64值
    let shaValue = sha1(secKey + UNIQUE_IDENTIFIER),
        base64Value = Buffer.from(shaValue, 'hex').toString('base64');
    socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
        'Upgrade: WebSocket\r\n' +
        'Connection: Upgrade\r\n' +
        `Sec-WebSocket-Accept: ${base64Value}\r\n` +
        '\r\n');
});

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

RWMSYL3Zmo91ZR+r39JVM2+PxXc=

Отправьте это значение в браузер, и Chrome не сообщит об ошибке в тесте прямо сейчас, подтвердите глаза и встретите нужного человека. Таким образом устанавливается соединение WebSocket, да, это так просто. Соединение через веб-сокет на панели Chrome Developer Tools Network изменится с состояния ожидания на состояние 101 и состояние 200, если соединение закрыто.

Приведенный выше код браузера также отправляет часть данных после установления соединения:

socket.send('hello, this is from client');

Как прочитать эти данные?

2. Получить данные

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

Не пугайтесь этого, их довольно просто демонтировать по одному. Его можно разделить на две части: поле заголовка кадра и полезная нагрузка или полезная нагрузка (данные полезной нагрузки).Основная функция поля заголовка кадра состоит в том, чтобы объяснить кадр, например, первый бит (бит)FINЕсли он установлен на 1, это означает, что это конечный кадр.Если данные относительно длинные, они будут разделены на несколько кадров и отправлены.Если FIN равен 1, это означает, что это последний кадр текущих данных. поток. с 4-го по 7-й разopcodeОн используется для выполнения некоторого командного управления.Если значение равно 1, это означает, что данные полезной нагрузки представлены в текстовом формате, 2 означает двоичное содержимое, а 8 означает, что соединение закрыто. с 7 по 15 местоPayload LenУказывает количество байтов полезной нагрузки Максимальное 7-битное двоичное число представляет 127. Если количество байтов полезной нагрузки больше 127, необходимо использовать расширенную часть длины полезной нагрузки.

8-йMaskЕсли он установлен в 1, это означает, что содержимое полезной нагрузки этого фрейма было замаскировано, фрейм, отправленный клиентом на сервер, должен быть замаскирован, а фрейм данных, отправленный сервером клиенту, не должен быть замаскирован. в маске. Зачем использовать маску и как работает этот расчет маски? Вычисление маски очень просто.Это XOR данных, которые должны быть отправлены с другим числом, и помещение его в данные полезной нагрузки.Это число находится во фрейме данных выше.Masking-key, который является 32-битным числом. Получатель может получить исходные данные, снова совместив XOR данных полезной нагрузки с этим числом, поскольку двойное XOR одного и того же числа равно исходному числу, а именно:

a ^ b ^ b = a

А требования к Making-key в каждом фрейме случайны и не могут быть предсказаны (прокси) сервисом.Почему так? В документации сказано следующее:

The unpredictability of the masking key is essential to prevent authors of malicious applications from selecting the bytes that appear on the wire

Это объяснение немного расплывчато,StackoverflowНекоторые люди говорят, что это делается для того, чтобы избежать атак с отравлением кеша прокси-сервера.Http Cache Poinsing.

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

Отправка и передача данных зависит от объекта сокета, потому что это не HTTP-запрос, поэтому данные не могут быть получены в функции ответа HTTP.В событии обновления вы можете получить сокет и отслеживать событие данных сокета object. , вы можете получить полученные данные:

socket.on('data', buffer => {
    console.log('buffer len = ', buffer.length);
    console.log(buffer);
});

Возвращаемый тип данных — это объект Buffer в Node.js, и напечатайте этот буфер:

buffer len = 32
<Buffer 81 9a 4c 3f 64 75 24 5a 08 19 23 13 44 01 24 56 17 55 25 4c 44 13 3e 50 09 55 2f 53 0d 10 22 4b>

Этот буфер представляет собой кадр данных, отправленный нам клиентом веб-сокета. Всего байтов 32. Приведенный выше вывод выражен в шестнадцатеричном формате, который можно изменить на двоичный 0101. Сравните с изображением формата кадра данных выше, просто будьте в состоянии объяснить, что означает этот фрейм данных и что он содержит. Распечатайте его как необработанное двоичное представление:

1000000110011010010011000011111101100100011101010010010001011010000010000001100100100011000100110100010000000001001001000101011000010111010101010010010101001100010001000001001100111110010100000000100101010101001011110101001100001101000100000010001001001011

Обратитесь к формату сообщения, как показано на следующем рисунке:

Через код операции вы можете узнать, что это фрейм текстовых данных, а полезная нагрузка len получает длину текста 26 байт, что точно равно длине содержимого, отправленного выше:

В то же время маска Mask включена, а диапазон хранения значения ключа маски составляет [16, 16 + 32].Поскольку здесь нет необходимости использовать поле расширения, ключ Masking непосредственно следует за полезной нагрузкой len, а затем Payload Data, диапазон [48, 48 + 26 * 8].

Это полный фрейм данных, и вам нужно выполнить XOR полезных данных с маской, чтобы восстановить исходные данные. Обработайте его в Node.js. Класс Buffer в Node.js может работать только на уровне байтов, например, читать содержимое n-го байта, но нет возможности напрямую манипулировать битами, например читать n-й бит данных. Итак, была введена дополнительная библиотека, и BitBuffer был найден в Интернете, но, похоже, возникла проблема с его реализацией, поэтому я реализовал его самостоятельно.

Реализуйте BitBuffer, способный считывать произвольные биты, как показано в следующем коде:

class BitBuffer {
    // 构造函数传一个Buffer对象
    constructor (buffer) {
        this.buffer = buffer;
    }
    // 获取第offset个位的内容
    _getBit (offset) {
        let byteIndex = offset / 8 >> 0,
            byteOffset = offset % 8;
        // readUInt8可以读取第n个字节的数据
        // 取出这个数的第m位即可
        let num = this.buffer.readUInt8(byteIndex) & (1 << (7 - byteOffset));
        return num >> (7 - byteOffset);
    }
}

Принцип очень прост, сначала вызовите readUInt8 буфера Node.js, чтобы прочитать n-й байт данных, затем вычислите количество цифр, которые нужно прочитать в этом байте, и извлеките этот бит с помощью операции AND. , больше битовых операций может относиться к:Использование побитовой операции JS.

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

socket.on('data', buffer => {
    let bitBuffer = new BitBuffer(buffer);
    let maskFlag = bitBuffer._getBit(8);
    console.log('maskFlag = ' + maskFlag);
});

распечатать маскуFlag=1. Итак, как вывести последовательные n битов, таких как код операции, с 4-го по 7-й. Это также легко сделать, просто выньте цифры с 4-й по 7-ю и сложите их вместе в число:

getBit (offset, len = 1) {
    let result = 0;
    for (let i = 0; i < len; i++) {
        result += this._getBit(offset + i) << (len - i - 1); 
    }   
    return result;
}

Этот код не очень эффективен, но прост для понимания. Есть небольшой подвох, что вытеснение JS поддерживает только работу с 32-битными целыми числами, а 1

Вы можете использовать эту функцию для извлечения кода операции и полезной нагрузки len:

socket.on('data', buffer => {
    let bitBuffer = new BitBuffer(buffer);
    let maskFlag = bitBuffer.getBit(8),
        opcode = bitBuffer.getBit(4, 4), 
        payloadLen = bitBuffer.getBit(9, 7);
    console.log('maskFlag = ' + maskFlag);
    console.log('opcode = ' + opcode);
    console.log('payloadLen = ' + payloadLen);
});

Он печатается следующим образом:

maskFlag = 1
opcode = 1
payloadLen = 26

Возьмите значение маски и реализуйте его отдельно.Эта маска разделена на 4 числа для использования, и один байт представляет собой число.С помощью приведенной выше функции getBit код выглядит следующим образом:

getMaskingKey (offset) {
    const BYTE_COUNT = 4;
    let masks = []; 
    for (let i = 0; i < BYTE_COUNT; i++) {
        masks.push(this.getBit(offset + i * 8, 8));
    }   
    return masks;
}

Значение маски для этого примера начинается с 16-го бита, поэтому смещение равно 16:

let maskKeys = bitBuffer.getMaskingKey(16);
console.log('maskKey = ' + maskKeys);

Напечатанный maskKey:

maskKeys = 76, 63, 100, 117

Как использовать этот ключ маски для XOR, документ выглядит следующим образом:

j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

То есть выполняется XOR содержимого n-го, n+1, n+2, n+3 байтов в данных полезной нагрузки с 0-м, 1-м, 2-м и 3-м массивом makKey соответственно, поэтому эта реализация относительно проста. , как показано в следующем коде:

getXorString (byteOffset, byteCount, maskingKeys) {
    let text = ''; 
    for (let i = 0; i < byteCount; i++) {
        let j = i % 4;
        // 通过异或得到原始的utf-8编码
        let transformedByte = this.buffer.readUInt8(byteOffset + i)
                                  ^ maskingKeys[j];
        // 把编码值转成对应的字符
        text += String.fromCharCode(transformedByte);
    }   
    return text;
}

После операции XOR можно получить закодированное значение, а затем с помощью String.fromCharCode получить соответствующий текст, например, согласно таблице ASCII, 97 будет восстановлено до буквы «а».

Смещение данных полезной нагрузки этого примера начинается с 6-го байта, здесь мы напрямую пишем до смерти:

let payloadLen = bitBuffer.getBit(9, 7),
    maskKeys = bitBuffer.getMaskingKey(16);
let payloadText = bitBuffer.getXorString(48 / 8, payloadLen, maskKeys);
console.log('payloadText = ' + payloadText);

Содержание печатного текста:

payloadText = hello, this is from client

В этот момент полученные данные восстанавливаются. Если вы хотите отправить данные, просто отмените процесс чтения и отправьте кадр, соответствующий спецификации в соответствии с форматом кадра, и отправьте его другой стороне.Разница в том, что данные кадра сервера не должны быть Маска. Если вы маскируете, Chrome сообщит о кадре. Исключение, говоря, что данные не требуют маски, отказываясь анализировать полученные данные.

Давайте посмотрим на реализацию клиента Websocket из исходного кода Chrome, чтобы добавить некоторые детали.

Код веб-сокетов Chrome находится в src/net/websockets.Например, как Chrome генерирует случайный ключ sec-websocket-key во время рукопожатия? Как показано в следующем коде:

std::string GenerateHandshakeChallenge() {
  std::string raw_challenge(websockets::kRawChallengeLength, '\0');
  crypto::RandBytes(base::string_as_array(&raw_challenge),
                    raw_challenge.length());
  std::string encoded_challenge;
  base::Base64Encode(raw_challenge, &encoded_challenge);
  return encoded_challenge;
}

Он использует crypto::RandBytes для генерации случайных байтов, и тот же метод расчета используется для проверки sec-websocket-accept:

std::string ComputeSecWebSocketAccept(const std::string& key) {
  std::string accept;
  std::string hash = base::SHA1HashString(key + websockets::kWebSocketGuid);
  base::Base64Encode(hash, &accept);
  return accept;
}

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

inline void MaskWebSocketFramePayloadByBytes(
    const WebSocketMaskingKey& masking_key,
    size_t masking_key_offset,
    char* const begin,
    char* const end) {
  for (char* masked = begin; masked != end; ++masked) {
    *masked ^= masking_key.key[masking_key_offset++];
    if (masking_key_offset == WebSocketFrameHeader::kMaskingKeyLength)
      masking_key_offset = 0;
  }
}

Другие включают сжатие deflate, файлы cookie, расширения и т. д., которые не будут обсуждаться в этой статье.

Есть еще одна проблема.Использование WebSocket требует TCP-соединения.Если в сети одновременно находится 1000 пользователей, сервер должен поддерживать 1000 TCP-соединений, а TCP-соединение обычно должно занимать отдельный поток, а накладные расходы потока очень большой, поэтому WebSocket сильно нагружает сервер? На самом деле он не обязательно такой большой, потому что в Linux есть сервисная модель epoll, которая представляет собой управляемый событиями механизм, позволяющий одному ядру поддерживать множество одновременных подключений.

Последний вопрос, поскольку соединение всегда поддерживается, если одна из двух сторон соединения выходит из строя ненормально и не отправляет пакет для закрытия соединения, чтобы уведомить другую сторону, тогда другая сторона будет глупо управлять бесполезным соединением, поэтому Снова представлен WebSocket.Для кадра сообщения ping/pong код операции в заголовке кадра равен 0x9, чтобы указать кадр ping, а 0x10 указывает кадр ответа pong. Следовательно, клиент может пинговаться непрерывно, например, каждые 30 секунд, служба будет знать, что текущий клиент все еще жив после получения пинга, и дать ответ понг.Если сервер не получил пинг слишком долго, например 1 минуту, потом просто подумай что клиент ушел и закрой соединение напрямую. Если клиент не получает ответ pong, он считает, что текущее соединение было разорвано и его необходимо восстановить. API браузерного JS не открывает ping/pong, поэтому вам нужно реализовать тип сообщения самостоятельно.

В этой статье в основном обсуждается значение существования WebSocket, открывается API сокета для браузера и стандартизируется.За исключением браузера, APP и т. д. также могут быть реализованы в соответствии с этим стандартом, что компенсирует недостатки HTTP. односторонняя передача. Также обсуждается формат фрейма сообщения WebSocket и как использовать Node.js для чтения этого фрейма сообщения.Клиент будет маскировать отправляемое им содержимое, и серверу также потребуется восстановить маску. Мы обнаружили, что реализация клиента Chrome во многом похожа.

Как обеспечить стабильность передачи через WebSocket может быть другой темой, включая механизм повторного подключения при ошибках и использование выделенных линий в Китае и США.