WebSocket'ы - что и как? реализация на PHP

WebSocket’ы — что и как? реализация на PHP

Приветствую всех, кто заглянул ко мне на огонек! Сегодня речь пойдет о вебсокетах. Если ты пользовались мессенджерами, чатами, играли в онлайн-игры или смотрели прямые трансляции — ты однозначно были клиентом вебсокет-соединения.

Содержание

Теория

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
Code language: HTTP (http)

с предложением сменить протокол на websocket, вместе с этим присылает ключ. На основании ключа, сервер строит свой ключ и отсылает его в виде заголовков клиенту. Если полученный клиентом заголовок

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat
Code language: HTTP (http)

содержит код ответа 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

Пусть верхний ряд цифр это битовая маска, а средний — скрытая информация. Будем рассматривать столбцы цыфр. Следуя операции 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);
Code language: HTML, XML (xml)

далее, создадим вебсокет, к которому и будут присоединятся клиенты

<?php if (($sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP))) { echo 'Сокет не создан. Причина: ' . socket_strerror(socket_last_error()); }
Code language: HTML, XML (xml)

здесь сокет создается функцией 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);
Code language: HTML, XML (xml)

здесь первый — сокет созданный ранее, ‘localhost’ — домен или IP адрес сервера, 8080 порт, через который будет открыт доступ. В socket_listen() второй параметр необязателен, будет указывать максимальное количество подключенных к нему клиентов.

На этой стадии, я имею сокет; чтоб превратить его в вебсокет — нужно запустить его как службу, т.е. в демон(цикл событий). В самом простом случаи сгодится такой код

<?php while(true){ // дальнейшая работа }
Code language: HTML, XML (xml)

Итак, первым, что нужно сделать — проверить наличие новых подключений(дальнейший код в этой главе должен быть расположен в цикле)

if ($connection = socket_accept($sock)) { $headers = socket_read($connection,1024); }
Code language: PHP (php)

При их наличии, прочтем их(получив при этом заголовки от клиента) и, исходя из ранее изложенных правил, сформируем серверные заголовки

$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("\r\n",$answer) . "\r\n\r\n");
Code language: PHP (php)

которые запишем в сокет(здесь замечание — если заголовки отправить без пустой строки, клиент не поймет, что это конец и будет ожидать продолжения заголовков). Таким образом мы совешим «рукопожатие» — с этого момента установлено полноценное вебсокет-соединение между сервером и клиентом(ах-да — чтоб ему в дальнейшем отправлять сообщения — запишем его коннект в массив). Ну, и необязательная

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 ];
Code language: PHP (php)

программа — поприветствуем новенького, оповестим остальных о нем и запишем «контактные данные»(функции кодирования и декодирования рассмотрим отдельно). Малость не забыл, при передачи параметров методом 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 = ''; } } }
Code language: PHP (php)

и отправим сообщение всем участникам. Поскольку цикл «живет» пока работает сервер сделаем возможность принудительной его(цикла) остановки. После выхода из цикла событий закроем сокет

socket_close($sock);
Code language: PHP (php)

Декодирование фрейма

Здесь все просто — просто следуем правилам. Вначале, из полученной строки(в том, что это именно строка можно убедится распечатав ее var_dump-ом) фрейма извлечем первый и второй байты в двоичном виде(побитово)

$firstByteToBits = sprintf('%08b', ord($frame[0])); $secondByteToBits = sprintf('%08b', ord($frame[1]));
Code language: PHP (php)

извлечем информацию о типе полезной информации и ее длине

$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); }
Code language: PHP (php)

если ее длинна хранится в следующих 16-ти или 64-х битах — извлечем следующие 2 или 8 байта(8 бит = 1 байт, кто забыл, а 1 байт = 1 символ строки) в битовой форме

Проверим кадр на целостность(фрагментирование), на тип информации и наличие маскировки(я буду пропускать фрагментированные, не маскированные и кары с нетекстовой информацией)

if ((int)$secondByteToBits[0] === 0 || $firstByteToBits[0] === 0 || $opcod !== 1) { return ''; }
Code language: PHP (php)

О снятии маски — фрейм содержит 32-битный ключ и имеет смысл разбивать полезные данные данные участками по 32 бита, и уже их демаскировать(склеить конкатенацией)

$i = 0; $unmaskedBody = ''; while ($i < $bodyLenght/4) { $unmaskedBody .= substr($body,4*$i,4) ^ $maskKey; $i++; }
Code language: PHP (php)

Если все же нужно обрабатывать и фрагментированный фреймы — стоит взглянуть в сторону глобальных переменных и конкатенации демаскированных строк.

Кодирование в фрейм

Эта часть оказалась немного сложнее, по крайней мере для меня — у меня образование не связанное с ЕОМ, а в документации об этом я ничего не нашел. Дело в том, что для шифрования длины — я должен понимать откуда 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));
Code language: PHP (php)

Несмотря на то, что протокол говорит о равных правах клиента и сервера, сервер требует маскированных данных, клиент — открытых.

Реализация клиентской части

Создадим простое текстовое поле и поле для сообщений

<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>
Code language: HTML, XML (xml)

а далее, javascript-обьект для общения с серверным сокетом

var socket = new WebSocket('ws://localhost:8080?name=Bogdan');
Code language: JavaScript (javascript)

он имеет 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>'; }
Code language: JavaScript (javascript)

Первый метод сработает при успешном обмене заголовками, второй — ошибка создания объекта связи или ошибка «рукопожатия». Следующий — это закрытие соединения и последний — принятие сообщения на сокете.

В отличии от PHP javascript-обьект уже содержит методы и автоматически кодирует/декодирует фреймы.

Заключение

Этой статьей я попытался объяснить технологию вебсокетов максимально просто. Увесь код с этой статьи лежит здесь. Данный протокол обмена сообщениями можно использовать не только для real-time приложений, но и как замена ajax(если нужно экономить трафик). Обьяснение простое — вебсокет обменивается «большим» заголовком только при установлении связи, в свою очередь, ajax сопровождает любой свой запрос+ответ двумя «большими» заголовками.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

*

code