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

    Собственно, изучая данную тему, было перерыто много сайтов, но нигде толком ничего не объяснялось, либо информация была по устаревшим ныне протоколам. Это и послужило своеобразным пинком для создания этого 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 Protocols\r\n"
    ."Upgrade: websocket\r\n"
    ."Connection: Upgrade\r\n"
    ."Sec-WebSocket-Accept: ".$hash."\r\n"
    ."Sec-WebSocket-Protocol: chat\r\n\r\n"
    И клиент наконец его узрел.

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

    А теперь плавно перейдем к тому, что входит в ответ сервера.
    Первая строка: «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-сокетов для флеша. В моем коде в конец каждого сообщения вставлялся нулевой байт "\0" как требует этого флеш, и соответственно в конец каждого фрейма или заголовка вставлялся этот байт, а браузер читал его уже как начало следующего, так как браузер знает точную длину фрейма или где конец заголовка. Таким образом первый байт следующего заголовка был "\0", а действительный первый сдвигался до второго, что вводило браузер в негодование.

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

    Update
    В комментариях было подмечено, что по спецификации все сообщения посылаются в кодировке utf-8. Это тоже важный момент, но я забыл о нем упомянуть.

    Update 17-05-13
    Столкнулся с еще одной проблемой: браузер может отослать два фрейма подряд, а вышеприведенная функция hybi10Decode() обрабатывает его как один фрейм, так как читает строку не по переданной в фрейме длине payload length, а до конца всего фрейма. После некоторых изменений функция выглядит так:
    Нажмите
    function decode($data){
    	$payloadLength = '';
    	$mask = '';
    	$unmaskedPayload = '';
    	$decodedData = array();
    	// estimate frame type:
    	$firstByteBinary = sprintf('%08b', ord($data[0]));
    	$secondByteBinary = sprintf('%08b', ord($data[1]));
    	$opcode = bindec(substr($firstByteBinary, 4, 4));
    	$isMasked = ($secondByteBinary[0] == '1') ? true : false;
    	$payloadLength = ord($data[1]) & 127;
    	if($isMasked === false)	$this->close(1002);// close connection if unmasked frame is received
    	switch($opcode)	{
    		case 1:		$decodedData['type'] = 'text';	break;// text frame
    		case 8:		$decodedData['type'] = 'close';	break;// connection close frame
    		case 9:		$decodedData['type'] = 'ping';	break;// ping frame
    		case 10:	$decodedData['type'] = 'pong';	break;// pong frame
    		default:	$this->close(1003);	break;// Close connection on unknown opcode
    	}
    	if($payloadLength === 126)	{
    		$mask = substr($data, 4, 4);
    		$payloadOffset = 8;
    		$dataLength = sprintf('%016b', ord($data[2]).ord($data[3]));
    		$dataLength = base_convert($dataLength,2,10);
    	}
    	elseif($payloadLength === 127)	{
    		$mask = substr($data, 10, 4);
    		$payloadOffset = 14;
    		$dataLength = '';
    		for ($i=2;$i<8;$i++) $dataLength .=sprintf('%08b',ord($data[$i]));
    		$dataLength = base_convert($dataLength,2,10);
    	}	
    	else{
    		$mask = substr($data, 2, 4);
    		$payloadOffset = 6;
    		$dataLength = base_convert(sprintf('%08b',ord($data[1]) & 63),2,10);
    	}
    	if($isMasked === true)	{
    		for($i = $payloadOffset; $i < $dataLength+$payloadOffset; $i++){
    			$j = $i - $payloadOffset;
    			$unmaskedPayload .= $data[$i] ^ $mask[$j % 4];
    		}
    		$decodedData['payload'] = $unmaskedPayload;
    	}
    	else{
    		$payloadOffset = $payloadOffset - 4;
    		$decodedData['payload'] = substr($data, $payloadOffset);
    	}
    	$decodedData['offset'] = $payloadOffset;
    	return $decodedData;
    }
    
    // а использую я ее таким образом ($frame - байты которые сервер получил от клиента)
    $recieved = 0;
    while(strlen($frame)> 0) {
    	$msg = decode($frame);
    	$recieved += strlen($msg['payload'])+ $msg['offset'];
    	$frame = substr($frame,$recieved);
    }
    
    Обратите внимание, что в этой фунции нет поддержки фрагментированных фреймов.

    Ссылки
    RFC6455
    Вебсокеты в Javascript
    Проект на GitHub, который работает с 13 версией протокола
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 26
    • +12
      > ну не нашел в документации про это ни слова

      Об этом сказано в спецификации HTTP.
      • 0
        Да, именно там я это и увидел, ну естественно после того как наткнулся на приведенную ссылку. Но изначально я изучал только спецификацию вебсокетов, наивно предполагая, что этого будет достаточно.
      • 0
        В своё время тоже делал свою реализацию протокола WebSocket средствами СУБД Caché, правда для 76 и 07 версии.
        Но с внедрением нативной поддержки RFC 6455 необходимость в велосипеде отпала.
        • 0
          у меня при реализации, сообщения с сервера приходили в юникоде, причём браузер его не распознавал, соответственно понадобилась дополнительная функция на стороне клиента для декодирования.
          • 0
            А разве стандарт не упоминает про необходимость кодировки UTF-8?
          • 0
            не забудьте убрать лидирующие и конечные пробелы
            Лидирующие? Где они лидируют? «Начальные», наверное?
            • +5
              вы ни разу не встречали слово «лидирующий» в таком значении? правда?
              • +2
                Я много чего встречал, например, «котельная трубочка» (вместо «коктейльная») или «паническая атака» (вместо «приступ паники»), это ещё не значит, что у слова «лидирующий» есть такой смысл. На мой взгляд, это калька с английского.
              • 0
                Смысл моей ошибки заключался в том, что браузер принимает заголовок с завершающей пустой строкой, а так как я ее не отправлял (ну не нашел в документации про это ни слова),…

                Протокол Websockets, его первая фаза (http://ru.wikipedia.org/wiki/WebSocket) идет поверх HTTP, а в Спецификации НTTP, ясно сказано, что заголовок и тело разделяется пустой строкой.
                • 0
                  У нас проект на php.
                  К сожалению, особенностей (конкретной реализации)WS серверов так много, что проще взять nodejs и работать с WS через неё. Даже простым exec-ом (если совсем что-то простое).
                  Нет ни одной библиотеки на php такого уровня, как уже довольно давно существующие пакеты для ноды.
                  Важно: все строчки выше применимы, когда вы пишете клиент!
                  Если вы пишите сервер, то «это уже совсем другая история»
                  • +1
                    на PHP принципиально плохо что либо реализовывать для работы с WEBсокетами. Это связано с тем, что РНР предназначен для режима Запрс-Ответ… А работа с WEBсокетами предполагает долгосрочную жизнь процесу. Если работаем через nginx, то он проксирует все WEBсокетами. запросы на демон. т.е. мы должны запустить наш сервер, как отдельный демон.
                    В этом есть конечно свои плюсы, так и минусы. Недавно была переводная статья «РНР рожден, чтобы умереть», спорная конечно, но кое-что в ней подмечено верно. Хотя, в своих демонах я утечек памяти не наблюдал: специально был реализован мониторинг и по памяти в том числе.
                    • +2
                      проблема php не в утечках, а в объеме занимаемом одним процессом. Т.к. php тянет все расширения в статику а не выделяет память по мере необходимости.
                      • 0
                        да, я в курсе всех проблем РНР
                        по этому нам приходится websockets пилить на Сях
                    • 0
                      PhpDaemon на тесте себя вполне нормально показывал. До продакшена, правда, не дошло, но не из-за проблем на сервер-сайде.
                      • 0
                        мы родили своего Демона :).
                        PhpDaemon — хорош, но там много «лишнего кода». Так что наши демоны себя в продакшене показывают довольно-таки не плохо.
                        • 0
                          Ну, он создан как универсальный фреймворк для демонов любых типов. естественно, что решение под конкретные задачи будет лучше.
                    • 0
                      Вот тут конечно они сильно замудрили.
                      Это стандартный спосои кодирование передаваемых данных… Перед данными устанавливается 3 бита длинны данных, далее идет длинна 1-3 байта, потом сами данные.
                      • 0
                        Такие приемы актуальны в очень медленных, либо в сильно завязанных на время каналах. В чем смысл экономии 3 байт в TCP-запросах мне не совсем понятно. И зачем 64-битная длина фрейма, террабайты данных туда-сюда гонять пока никто не планирует.
                      • +1
                        Как-то я тоже реализовал вебсокет-сервер на гипертекстовом препроцессоре, наступив на все упомянутые Вами грабли. Итоговый двухколёсный демон был непредсказуем и очень неудобен в поддержке. После чего для таких вещей я перешёл на python, о чём ни разу не жалею.

                        Связка Tornado + Tornadio + Socket.io чудесно работает с веб-сокетами из коробки, поддерживает кучу других транспортов (graceful degradation), красива, логична, поддерживается огромным коммьюнити. И показательно, кстати, что для Socket.io не существует ни одного php-бэкэнда.
                        • –1
                          О, спасибо за напоминание! Надо бы дописать библиотечку (забросил на стадии интерфейсов fork/pthreads).
                          • 0
                            Wow! Нашелся некто умный, кто уже написал библиотечку, но в силу своей сволочности зажопил выложить ее!
                          • 0
                            Обновил топик. Переработал функцию hybi10Decode(), чтобы она разделяла подрял идущие фреймы.
                            • 0
                              Ставить плюс уже поздно, но большое спасибо!
                              Перерыл весь интернет, реализовывая вебсокеты на php-проекте, но только эта статья (а особенно спойлер!) помогли решить проблему окончательно.
                              Благодарю!
                              • 0
                                У меня проблема с флагами RVS1, RVS2, RVS3 уже очень долго ответ не могу найти. Я реализую свой WebSocket сервер на С++ и всё хорошо работает, но когда время между отправкой сообщений из браузера на сервер маленькое (меньше 50 миллисекунд) то фреймы приходящие сначала начинают приходить вместе то есть в одном принимаемом сообщении 2 и более фреймов умещается но это мелочь, потом браузер присылает фрейм с не нулевыми значениями RVS1, RVS2, RVS3 и я не знаю как такие декодировать, в итоге соединение приходится закрывать соединение.
                                Использую Sec-WebSocket-Version 13
                                • 0
                                  К сожалению с момента публикации я практически не занимался сокетами, и помочь, наверно, не смогу.

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

                                Интересные публикации