Приветствую всех, кто заглянул ко мне на огонек! Сегодня речь пойдет о вебсокетах. Если ты пользовались мессенджерами, чатами, играли в онлайн-игры или смотрели прямые трансляции – ты однозначно были клиентом вебсокет-соединения.
Содержание
Теория
WebSocket — протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером в режиме реального времени. Протокол управления передачей(TCP) основной протокол передачи данных в интернете; он является подложкой для остальных протоколов, среди которых сравним http/https и ws/wss. Обычно, “общаясь” с сайтом, браузер использует http/https протоколы, которые работают в режиме “вопрос-ответ”, т.е. без запроса от браузера никакой информации от сервера не будет. Вебсокет протокол(ws и wss, защищенный, по аналогии с http/https), в свою очередь, держит “коридор” обмена данными постоянно открытым, т.е. сервер может по “своей” инициативе послать информацию.
“Рукопожатие”
Это, пожалуй, основное событие в вебсокет-общении. И несмотря на это, оно незаметно и никак не зависит от клиента(имеется ввиду – от человека в чате, либо другом приложении). Происходит оно так: клиент отправляет заголовки серверу
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
с предложением сменить протокол на websocket, вместе с этим присылает ключ. На основании ключа, сервер строит свой ключ и отсылает его в виде заголовков клиенту. Если полученный клиентом заголовок
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat
содержит код ответа 101 и правильный ключ – ответ воспринимается как “да”. Сэтого момента соединение установлено и общение может быть начато, но если сервер не пришлет заголовков или будет неверен ключ – соединение будет разорвано.
Правила построения ответного ключа:
- взять строковое значение из заголовка Sec-WebSocket-Key и объединить со строкой 258EAFA5-E914-47DA-95CA-C5AB0DC85B11;
- вычислить бинарный хеш SHA-1 (бинарная строка из 20 символов) от полученной в первом пункте строки;
- закодировать хеш в Base64.
Немного о передаваемых данных
Согласно спецификации RFC 6455 обмен данными происходит в виде фреймов. Вот блок-схема каждого фрейма
С первого взгляда вспомнился мем “Ну нахер…”, ну да ладно! Из этой схемы и ее описания можно сделать заключение:
- каждая “порция” информации передаётся фреймами, она может быть в одном или нескольких подряд;
- каждый фрейм начинается с информации о том, как извлечь целевую информацию из него.
Теперь о самих правилах построения фреймов…
“Ингредиенты” фреймов
Каждый фрейм строится по следующих правилах:
- Первый байт указывает на то, полная ли в нем информация(1), или будет продолжение(0). В случаи если фрейм обладает не полной инфой – нужно ждать закрывающего фрейма, в котором первый бит будет равен 1;
- следующие 3 бита(обычно по 0) это расширение для протокола;
- следующие 4 бита определяют тип полезных данных фрейма:
- 0х1 – текстовые данные;
- 0х2 – бинарные(файл);
- 0х3-7- окно возможностей для полезной информации на будущее(сейчас таких данных нет);
- 0х8 – фрейм с приказом закрыть соединение;
- 0х9 – фрейм PING на проверку состояния соединения;
- 0хА – фрейм PONG(ответ на PING, говорящий “все ОК”);
- 0хB-F – окно возможностей для управления соединением на будущее(сейчас таких данных нет);
- 0х0 – фрагментированный фрейм, являющийся продолжением предыдущего.
- следующий бит(маска) указывает замаскирована ли инфомация фрейма;
- следующие 7 бит или 7 бит + 2 или 8 байта(сейчас объясню) это длинна тела сообщения. Если эти 7 бит перевести в значение, то правила следующие:
- если значение между 0 и 125 – это и есть длинна тела сообщения;
- когда значение 126 – на длину тела указывают следующие 2 байта(16 бит);
- значение строго больше 126 – на длину тела указывают следующие 8 байта(64 бит);
- если маска установлена, 4 байта после длинны тела будут ее ключом – в ином случаи(маска неустановленная, т.е. 0) – этого слоя в фрейме не будет;
- и в конце, в оставшемся будет содержатся полезная информация фрейма.
Эту информацию нужно знать для того, чтоб правильно составить функцию кодирования/декодирования информации сокета. Далее, я построю и эти функции.
Немного о битовых масках
Битмаски – это последовательность битов, предназначенных для маскирования целевой информации. Не буду излагать всю теорию о них(если интересно – загуглите или напишите в комментах), меня, в контексте вебсокетов, интересует лишъ одна операция – раз маскирование целевой информации методом xor(именно он указан в спецификации на протокол вебсокет)
Пусть верхний ряд цифр это битовая маска, а средний – скрытая информация. Будем рассматривать столбцы цыфр. Следуя операции xor нужно сравнить бит маски с соответствующим битом скрытой инфы, и если они совпадают, то результат false(0 у выражении состояния бита) и наоборот. Проделав эту операцию со всеми битами(длина маски = длине полезной информации) будет получена строка с исходными данными. Ключом маски называется наименьший повторяющийся участок маски – именно его хранит каждый фрейм.
Балабольство о практике
Как следует из ранее сказанного – для соединения нужно иметь сервер и клиент. На сервере будет запущен демон(никакого отношения к мифологии, всего-лишъ – вечно работающий скрипт), а клиент будет будет веб-страницей.
В идеале, сервер должен быть написан на языке программирования, поддерживающем асинхронность, например серверный javascript(node.js). Но, допустим, это пристройка к проекту на PHP, среди доступных разработчиков нет(я о тебе, т.к .полагаю, что ты разраб) обладающих знаниями или не желающий изучить новое(что странно); или еще какая-то ведомая лишъ тебе причина. Как известно, пых пока что полностью синхронен, но для низко нагруженных проектов – этого достаточно – выльется только в задержки работы; для нагруженных проектов есть фреймворки, обеспечивающие асинхронность – reactPHP, amPHP, для вебсокет-серверов с асинхронностью – workerman, ratchet и т.д.
По ходу статьи я планирую реализовать следующее(на нативном php):
- websocket-сервер на PHP;
- HTML/JS-клиент для общения с сервером.
На деле и пых может быть клиентом и слушать сокет…
Реализация серверной части
Придумываем задачу. Сервер будет принимать сокет-соединение, приветствовать нового пользователя и сообщать о нем ранее присоединившихся. Любой может “убить” сокет-сервер.
Начнем реализацию, а именно под случай, когда нет доступа к серверу по SSH и запустить скрипт можно вызвав его в браузере. Для этого вначале снимем ограничение времени работы скрипта и запретим завершать работу после закрытия браузера
<?php ignore_user_abort(true); set_time_limit(0);
далее, создадим вебсокет, к которому и будут присоединятся клиенты
<?php if (($sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))) { echo 'Сокет не создан. Причина: ' . socket_strerror(socket_last_error()); }
здесь сокет создается функцией socket_create()
, которая при создании сокета возвращает сокет ресурса, иначе false; конструкция socket_strerror(socket_last_error())
всего получает текст ошибки, параметры это семейство протоколов, тип передачи данных и используемый протокол. Кста, забыл главное – проверь конфигурацию пыха – чтоб --enable-sockets
был true. Сейчас нужно привязать сокет и выставить его на прослушку, а также, сделать его неблокирующим
<?php if (socket_bind($sock, 'localhost', 8080)) { echo 'Сокет не привязан. Причина: ' . socket_strerror(socket_last_error()); } if (socket_listen($sock, 10)) { echo 'Сокет не прослушивается. Причина: ' . socket_strerror(socket_last_error()); } socket_set_nonblock($sock);
здесь первый – сокет созданный ранее, ‘localhost’ – домен или IP адрес сервера, 8080 порт, через который будет открыт доступ. В socket_listen()
второй параметр необязателен, будет указывать максимальное количество подключенных к нему клиентов.
На этой стадии, я имею сокет; чтоб превратить его в вебсокет – нужно запустить его как службу, т.е. в демон(цикл событий). В самом простом случаи сгодится такой код
<?php while(true){ // дальнейшая работа }
Итак, первым, что нужно сделать – проверить наличие новых подключений(дальнейший код в этой главе должен быть расположен в цикле)
<?php if ($connection = socket_accept($sock)) { $headers = socket_read($connection,1024); }
При их наличии, прочтем их(получив при этом заголовки от клиента) и, исходя из ранее изложенных правил, сформируем серверные заголовки
$parts = explode('Sec-WebSocket-Key:',$headers); $secWSKey = trim(explode(PHP_EOL,$parts[1])[0]); $secWSAccept = base64_encode(sha1($secWSKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',true)); $answer = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' . $secWSAccept, 'Sec-WebSocket-Version: 13' ]; if (stripos($headers,'name=') !== false) { $parts = explode('name=',$headers,2); $name = explode(' ',$parts[1])[0]; } $name = isset($name) ? $name : 'Anonymous' . count($connections); socket_write($connection, implode("rn",$answer) . "rnrn");
которые запишем в сокет(здесь замечание – если заголовки отправить без пустой строки, клиент не поймет, что это конец и будет ожидать продолжения заголовков). Таким образом мы совешим “рукопожатие” – с этого момента установлено полноценное вебсокет-соединение между сервером и клиентом(ах-да – чтоб ему в дальнейшем отправлять сообщения – запишем его коннект в массив). Ну, и необязательная
socket_write($connection,encodeToFrame('Server: Hello! Welcome to Chat, ' . $name)); if (!empty($connections)) { foreach ($connections as $connect) { socket_write($connect->connection,encodeToFrame('Server: New User(' . $name . ') in Chat!')); } } $connections[] = (object) [ 'connection' => $connection, 'name' => $name ];
программа – поприветствуем новенького, оповестим остальных о нем и запишем “контактные данные”(функции кодирования и декодирования рассмотрим отдельно). Малость не забыл, при передачи параметров методом GET, они будут доступны только в заголовке.
После проверки на новых – проверим каждый сокет на наличие фреймов, если они есть – декодируем их
if (!empty($connections)) { foreach ($connections as $connect) { $message = frameDecode(socket_read($connect->connection,1024000)); if ($message === 'break') { break 2; } if (!empty($message)) { foreach ($connections as $c) { socket_write($c->connection,encodeToFrame($connect->name . ': ' . $message)); } $message = ''; } } }
и отправим сообщение всем участникам. Поскольку цикл “живет” пока работает сервер сделаем возможность принудительной его(цикла) остановки. После выхода из цикла событий закроем сокет
socket_close($sock);
Декодирование фрейма
Здесь все просто – просто следуем правилам. Вначале, из полученной строки(в том, что это именно строка можно убедится распечатав ее var_dump
-ом) фрейма извлечем первый и второй байты в двоичном виде(побитово)
$firstByteToBits = sprintf('%08b', ord($frame[0])); $secondByteToBits = sprintf('%08b', ord($frame[1]));
извлечем информацию о типе полезной информации и ее длине
$opcod = bindec(substr($firstByteToBits,4)); $bodyLenght = bindec(substr($secondByteToBits,1)); if ($bodyLenght < 126) { $bodyLenght = $bodyLenght; $maskKey = substr($frame,2,4); $body = substr($frame,6,$bodyLenght); } elseif ($bodyLenght === 126) { $bodyLenght = sprintf('%16b',substr($frame,2,2)); $maskKey = substr($frame,4,4); $body = substr($frame,8,$bodyLenght); } else { $bodyLenght = sprintf('%64b',substr($frame,2,8)); $maskKey = substr($frame,10,4); $body = substr($frame,14,$bodyLenght); }
если ее длинна хранится в следующих 16-ти или 64-х битах – извлечем следующие 2 или 8 байта(8 бит = 1 байт, кто забыл, а 1 байт = 1 символ строки) в битовой форме
Проверим кадр на целостность(фрагментирование), на тип информации и наличие маскировки(я буду пропускать фрагментированные, не маскированные и кары с нетекстовой информацией)
if ((int)$secondByteToBits[0] === 0 || $firstByteToBits[0] === 0 || $opcod !== 1) { return ''; }
О снятии маски – фрейм содержит 32-битный ключ и имеет смысл разбивать полезные данные данные участками по 32 бита, и уже их демаскировать(склеить конкатенацией)
$i = 0; $unmaskedBody = ''; while ($i < $bodyLenght/4) { $unmaskedBody .= substr($body,4*$i,4) ^ $maskKey; $i++; }
Если все же нужно обрабатывать и фрагментированный фреймы – стоит взглянуть в сторону глобальных переменных и конкатенации демаскированных строк.
Кодирование в фрейм
Эта часть оказалась немного сложнее, по крайней мере для меня – у меня образование не связанное с ЕОМ, а в документации об этом я ничего не нашел. Дело в том, что для шифрования длины – я должен понимать откуда 125, 126 и 127 ? Тут я сделал предположение
Имеется 7 бит, которые могут иметь 128(27) положений. Первое – это 0 символов(байт), 2 положения(126 и 127) это для информирования о том, какому правилу следовать. Исходя из этого, 126-ое положение свидетельствует о длине сообщения = 125 байт. Из этого можно сделать вывод – максимальная длина сообщения, содержащаяся в 16-ти битах = 216 – 1, в 64 = 264 – 1.
Подытожим все это в коде: определим значение 7-ми последних битов второго байта и, по необходимости, 2-ох или 8-ми последующих байтов
$opcodInBits = sprintf('%04b', 1); $bodyLenght = strlen($content); $bodyLenghtInSecondByte = sprintf('%07b',$bodyLenght); $extendedLenght = ''; if ($bodyLenght > 125) { if ($bodyLenght < 65536) { $bodyLenghtInSecondByte = sprintf('%07b',126); $extendedLenght = sprintf('%32b',$bodyLenght); } elseif ($bodyLenght > 65535 && $bodyLenght < 4294967296) { $bodyLenghtInSecondByte = sprintf('%07b',127); $extendedLenght = sprintf('%64b',$bodyLenght); } else { return ''; } } $firstByte = chr(bindec('1000' . $opcodInBits)); $secondByte = chr(bindec('0' . $bodyLenghtInSecondByte));
Несмотря на то, что протокол говорит о равных правах клиента и сервера, сервер требует маскированных данных, клиент – открытых.
Реализация клиентской части
Создадим простое текстовое поле и поле для сообщений
<table> <tr> <td> <textarea name="name" rows="8" cols="80" id="input"></textarea><br> <button type="button" name="button" id="sendToServer">Send</button> </td> </tr> <tr> <td id="messages"></td> </tr> </table>
а далее, javascript-обьект для общения с серверным сокетом
var socket = new WebSocket('ws://localhost:8080?name=Bogdan');
он имеет 4 метода, которые срабатывают при разных событиях
socket.onopen = function() { console.log('connected open!'); } socket.onerror = function() { console.log('connection error!'); } socket.onclose = function() { console.log('connection closed!'); } socket.onmessage = function(e) { document.getElementById('messages').innerHTML += '<p>' + e.data + '</p>'; }
Первый метод сработает при успешном обмене заголовками, второй – ошибка создания объекта связи или ошибка “рукопожатия”. Следующий – это закрытие соединения и последний – принятие сообщения на сокете.
В отличии от PHP javascript-обьект уже содержит методы и автоматически кодирует/декодирует фреймы.
Заключение
Этой статьей я попытался объяснить технологию вебсокетов максимально просто. Увесь код с этой статьи лежит здесь. Данный протокол обмена сообщениями можно использовать не только для real-time приложений, но и как замена ajax(если нужно экономить трафик). Обьяснение простое – вебсокет обменивается “большим” заголовком только при установлении связи, в свою очередь, ajax сопровождает любой свой запрос+ответ двумя “большими” заголовками.
Очень полезная инфа для новичков (я сам новичок), одна из немногих статей, где все подробно рассказано, для меня был темный лес, когда смотрел на схему из документации, в тех же статьях на хабре изложено не очень понятно + используют везде одни и те же готовые функции для кодирования и декодирования. В целом тут основы даны, а дальше уже самому чего придумать можно и двигаться дальше.
Единственное, встречаются моменты где неточности, например, говорится что маска = длине полезной инфы, а в блоке с декодированием уже говорится что маска равна 32бит и полезную инфу есть смысл дробить по 32бит и прочее, а так полезно, спасибо
Битовая маска и ее ключ это разные вещи
“Ключом маски называется наименьший повторяющийся участок маски – именно его хранит каждый фрейм”
для демаскировки участок зашифрованнной информации должен быть равен длине ключа, а ключ всегда равен 32-ом битам(в этом протоколе)
Извиняюсь за неточности (или корявое пояснение)
Спасибо за пояснение, не посоветуете что почитать чтоб в этом разобраться? В масках, почему там XOR и тд, пока что на уровне заучивания все у меня, но представления расплывчатые об этом всем, о работе с битами, маскировкой и тд