Как стать автором
Обновить

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

Время на прочтение7 мин
Количество просмотров85K
Собственно, изучая данную тему, было перерыто много сайтов, но нигде толком ничего не объяснялось, либо информация была по устаревшим ныне протоколам. Это и послужило своеобразным пинком для создания этого 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 версией протокола
Теги:
Хабы:
Всего голосов 44: ↑35 и ↓9+26
Комментарии26

Публикации

Истории

Работа

PHP программист
130 вакансий

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область