Собственно, изучая данную тему, было перерыто много сайтов, но нигде толком ничего не объяснялось, либо информация была по устаревшим ныне протоколам. Это и послужило своеобразным пинком для создания этого HowTo. Это будет не детальный разбор всех возможных проблем, но немного теории и описание некоторых вещей которые для кого-то являются банальщиной, а у кого-то (вроде меня) вызвали трудности и потерю времени на поиск решения. Сразу предупрежу — здесь не рассматривается как поднять сокет-сервер на PHP, подобной информации в интернете навалом. Буду исходить из того, что сокет-сервер уже существует и надо лишь научить его общаться через вебсокеты.
Итак, хватит лирики, теперь к делу!
При подключении посредством вебсокетов происходит обмен заголовками наподобие заголовков HTTP, так называемый handshake или по-нашему «рукопожатие».
Клиент отправляет заголовок подобного содержания:
Так написано в литературе (The WebSocket Protocol RFC 6455). Казалось бы что сложного: получил — ответил. Но здесь у меня возникли первые проблемы. Сервер получал заголовок от клиента, отвечал на него, но клиент не реагировал, вне зависимости от клиента (в данном случае — браузера). Пробовал все, на что хватило мозга, ничего не помогало. Подсказка была найдена здесь. Смысл моей ошибки заключался в том, что браузер принимает заголовок с завершающей пустой строкой, а так как я ее не отправлял (ну не нашел в документации про это ни слова), то браузер продолжал ждать заголовок, и событие «вебсокет подключен (WebSocket.onopen)» в браузере не происходило. В итоге мой ответ выглядел следующим образом:
А теперь плавно перейдем к тому, что входит в ответ сервера.
Первая строка: «HTTP/1.1 101 Switching Protocols». Здесь менять ничего не надо. Любой код статуса, отличный от 101 будет означать что «рукопожатие» не завершено.
В строчках Upgrade и Connection если не ввести с соблюдением регистра «websocket» и «Upgrade» соответственно, то клиент должен разорвать соединение. То есть, тоже оставляем как было. Хотя, например, огнелис присылал в заголовке «Connection: keep-alive, Upgrade», возможно ему можно ответить темже, но пока необходимости я в этом не нашел.
Далее идет, пожалуй, единственная строка, где нам надо приложить руку: «Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=». Данная строка объявляет о том, что сервер принимает подключение, и сообщает специальным образом вычисленный хэш от ключа, переданного клиентом в Sec-WebSocket-Key.
Чтобы вычислить хэш нужны:
Перейдем к следующей строке заголовка сервера, который я отправляю клиенту. «Sec-WebSocket-Protocol: chat» — это необязательный параметр, и он сообщает клиенту по какому подпротоколу сервер будет с ним общаться. Этот подпротокол должен поддерживаться клиентом, и он должен приходить от клиента в одноименном параметре, хотя огнелис и хром при подключении у меня таких параметров в заголовке не отправляли.
Есть еще один вкусный момент, который мне попался в документации. Сервер может указать клиенту какую версию протокола он поддерживает.
К примеру, клиент шлет следующее:
Сервер ему отвечает:
После чего клиент повторяет рукопожатие, но уже с версией протокола которую ему сообщил сервер.
Про заголовок клиента говорить особо нечего, разве что остановлюсь на параметрах Origin и Host.
Host содержит адрес сервера и порт, к которому подключается вебсокет.
Origin — опциональное поле, используется как правило браузерами. Содержит имя вебсервера, со страницы которого запущен javascript для подключения к серверу (имхо, не проверял).
Вот тут конечно они сильно замудрили. Фрейм выглядит в документации так:
Как увидел, так прям сразу лень как-то стало разбираться… но всеже пришлось. Кстати, для тех кто хочет детально разобраться в этом на этой странице есть внятное рускоязычное описание, хотя полностью разбираться, конечно, лучше в исходной документации. Для декодирования и кодирования фреймов я нашел здесь готовые функции hybi10Decode() и hybi10Encode(), которые показали себя, как исправно работающие. Тамже в функции handshake() описан метод получения параметров заголовка клиента.
Учтите также, что после рукопожатий, клиент отправляет серверу только маскированные фреймы, а сервер клиенту только немаскированные, то есть где бит MASK = 0.
В процессе я столкнулся еще с одной проблемой, после рукопожатий и ответа сервера на «hello» клиента, хром выдал следующее:
На этом пока все. В завершение хотелось бы сказать, что вебсокеты, как и в целом HTML5, на мой взгляд замечательный инструмент, который позволит браузеру самостоятельно делать то, что раньше ему было не по силам, разумеется без Flash-костылей.
Update
В комментариях было подмечено, что по спецификации все сообщения посылаются в кодировке utf-8. Это тоже важный момент, но я забыл о нем упомянуть.
Update 17-05-13
Столкнулся с еще одной проблемой: браузер может отослать два фрейма подряд, а вышеприведенная функция hybi10Decode() обрабатывает его как один фрейм, так как читает строку не по переданной в фрейме длине payload length, а до конца всего фрейма. После некоторых изменений функция выглядит так:
Ссылки
RFC6455
Вебсокеты в Javascript
Проект на GitHub, который работает с 13 версией протокола
Итак, хватит лирики, теперь к делу!
Немного теории.
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.
Чтобы вычислить хэш нужны:
- Конкатенация ключа клиента и предустановленного GUID. По документации GUID является следующей строкой: «258EAFA5-E914-47DA-95CA-C5AB0DC85B11». Предположим, что ключ клиента мы уже извлекли и храним в переменной $key (не забудьте убрать лидирующие и конечные пробелы, если они каким-то образом попали в переменную)
$hash = $key.'258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
- Вычисление sha1 от полученной строки, причем результат должен быть в виде двоичной строки из 20 символов.
$hash = sha1($hash,true);
- И последнее — кодирование хеша методом 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 версией протокола