WebSocket (Sec-WebSocket-Version: 13) — тонкости реализации, в частности на PHP

в 6:50, , рубрики: php, websocket server, Веб-разработка, метки: ,

Собственно, изучая данную тему, было перерыто много сайтов, но нигде толком ничего не объяснялось, либо информация была по устаревшим ныне протоколам. Это и послужило своеобразным пинком для создания этого HowTo. Это будет не детальный разбор всех возможных проблем, но немного теории и описание некоторых вещей которые для кого-то являются банальщиной, а у кого-то (вроде меня) вызвали трудности и потерю времени на поиск решения. Сразу предупрежу — здесь не рассматривается как поднять сокет-сервер на PHP, подобной информации в интернете навалом. Буду исходить из того, что сокет-сервер уже существует и надо лишь научить его общаться через вебсокеты.
Итак, хватит лирики, теперь к делу!

Немного теории.

Handshake

При подключении посредством вебсокетов происходит обмен заголовками наподобие заголовков HTTP, так называемый handshake или по-нашему «рукопожатие».
Клиент отправляет заголовок подобного содержания:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13

На что сервер должен ему ответить:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

Так написано в литературе (The WebSocket Protocol RFC 6455). Казалось бы что сложного: получил — ответил. Но здесь у меня возникли перые проблемы. Сервер получал заголовок от клиента, отвечал на него, но клиент не реагировал, вне зависимости от клиента (в данном случае — браузера). Пробовал все, на что хватило мозга, ничего не помогало. Подсказка была найдена здесь. Смысл моей ошибки заключался в том, что браузер принимает заголовок с завершающей пустой строкой, а так как я ее не отправлял (ну не нашел в документации про это ни слова), то браузер продолжал ждать заголовок, и событие «вебсокет подключен (WebSocket.onopen)» в браузере не происходило. В итоге мой ответ выглядел следующим образом:

$answer = "HTTP/1.1 101 Switching Protocolsrn"
."Upgrade: websocketrn"
."Connection: Upgradern"
."Sec-WebSocket-Accept: ".$hash."rn"
."Sec-WebSocket-Protocol: chatrnrn"

И клиент наконец его узрел.

Заголовок сервера

А теперь плавно перейдем к тому, что входит в ответ сервера.
Первая строка: «HTTP/1.1 101 Switching Protocols». Здесь менять ничего не надо. Любой код статуса, отличный от 101 будет означать что «рукопожатие» не завершено.

В строчках Upgrade и Connection если не ввести с соблюдением регистра «websocket» и «Upgrade» соответственно, то клиент должен разорвать соединение. То есть, тоже оставляем как было. Хотя, например, огнелис присылал в заголовке «Connection: keep-alive, Upgrade», возможно ему можно ответить темже, но пока необходимости я в этом не нашел.

Далее идет, пожалуй, единственная строка, где нам надо приложить руку: «Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=». Данная строка объявляет о том, что сервер принимает подключение, и сообщает специальным образом вычисленный хэш от ключа, переданного клиентом в Sec-WebSocket-Key.
Чтобы вычислить хэш нужны:

  1. Конкатенация ключа клиента и предустановленного GUID. По документации GUID является следующей строкой: «258EAFA5-E914-47DA-95CA-C5AB0DC85B11». Предположим, что ключ клиента мы уже извлекли и храним в переменной $key (не забудьте убрать лидирующие и конечные пробелы, если они каким-то образом попали в переменную)
    $hash = $key.'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; 
  2. Вычисление sha1 от полученной строки, причем результат должен быть в виде двоичной строки из 20 символов.
    $hash = sha1($hash,true);
  3. И последнее — кодирование хеша методом base64
    $hash = base64_encode($hash); 

Перейдем к следующей строке заголовка сервера, который я отправляю клиенту. «Sec-WebSocket-Protocol: chat» — это необязательный параметр, и он сообщает клиенту по какому подпротоколу сервер будет с ним общаться. Этот подпротокол должен поддерживаться клиентом, и он должен приходить от клиента в одноименном параметре, хотя огнелис и хром при подключении у меня таких параметров в заголовке не отправляли.

Есть еще один вкусный момент, который мне попался в документации. Сервер может указать клиенту какую версию протокола он поддерживает.
К примеру, клиент шлет следующее:

GET /chat HTTP/1.1
...
Sec-WebSocket-Version: 25

Сервер ему отвечает:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13, 8, 7

Можно и так:

HTTP/1.1 400 Bad Request
...
Sec-WebSocket-Version: 13
Sec-WebSocket-Version: 8, 7

После чего клиент повторяет рукопожатие, но уже с версией протокола которую ему сообщил сервер.

GET /chat HTTP/1.1
...
Sec-WebSocket-Version: 13

Заголовок клиента

Про заголовок клиента говорить особо нечего, разве что остановлюсь на параметрах Origin и Host.
Host содержит адрес сервера и порт, к которому подключается вебсокет.
Origin — опциональное поле, используется как правило браузерами. Содержит имя вебсервера, со страницы которого запущен javascript для подключения к серверу (имхо, не проверял).

Обмен пакетами

Вот тут конечно они сильно замудрили. Фрейм выглядит в документации так:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

Как увидел, так прям сразу лень как-то стало разбираться… но всеже пришлось. Кстати, для тех кто хочет детально разобраться в этом на этой странице есть внятное рускоязычное описание, хотя полностью разбираться, конечно, лучше в исходной документации. Для декодирования и кодирования фреймов я нашел здесь готовые функции hybi10Decode() и hybi10Encode(), которые показали себя, как исправно работающие. Тамже в функции handshake() описан метод получения параметров заголовка клиента.
Учтите также, что после рукопожатий, клиент отправляет серверу только маскированные фреймы, а сервер клиенту только немаскированные, то есть где бит MASK = 0.

В процессе я столкнулся еще с одной проблемой, после рукопожатий и ответа сервера на «hello» клиента, хром выдал следующее:

WebSocket connection to 'ws://example.com:10001/test' failed: A server must not mask any frames that it sends to the client.

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

На этом пока все. В завершение хотелось бы сказать, что вебсокеты, как и в целом HTML5, на мой взгляд замечательный инструмент, который позволит браузеру самостоятельно делать то, что раньше ему было не по силам, разумеется без Flash-костылей.

Ссылки
RFC6455
Вебсокеты в Javascript
Проект на GitHub, который работает с 13 версией протокола

Автор: metis

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js