Pull to refresh

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

Reading time 7 min
Views 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 версией протокола
Tags:
Hubs:
+26
Comments 26
Comments Comments 26

Articles