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 сопровождает любой свой запрос+ответ двумя «большими» заголовками.

Namespace на пальцах

Всем привет и здравствуйте! Сегодня речь пойдет об пространстве имен(nаmespase). Эта фича чисто объектно-ориентированного программирования; несмотря на то, что пространство имен есть во многих языках программирования(C++, python, java и т.д.), код, приведенный в примерах, будет на php.

Содержание

Теория о nаmespase

Пространство имён (англ. namespace) — некоторое множество, под которым подразумевается модель, абстрактное хранилище или окружение, созданное для логической группировки уникальных идентификаторов (то есть имён).

Идентификатор, определённый в пространстве имён, ассоциируется с этим пространством. Один и тот же идентификатор может быть независимо определён в нескольких пространствах. Таким образом, значение, связанное с идентификатором, определённым в одном пространстве имён, может иметь (или не иметь) такое же значение, как и такой же идентификатор, определённый в другом пространстве. Языки с поддержкой пространств имён определяют правила, указывающие, к какому пространству имён принадлежит идентификатор (то есть его определение).

Такое определение дает нам википедия, однако оно, как по мне, достаточно сложное для понимания. Я же определю более просто — это дополнительная координата для обращения к классу. Рассмотрим примеры для большего понимания…

Примеры

Для начала, приведу жизненный пример. Допустим есть группа людей в которой есть несколько человек с одним и тем же именем(Иван). Третий, из этой группы, обращается к Ивану и те не понимают к какому. Так вот, их фамилия и отчество(или еще какие-то координаты) как раз и будут их пространством имен.

Теперь применительно к коду: 2 программиста написали по одинаково названному классу

<?php class ClassName { // some methods }
Code language: HTML, XML (xml)

и отправили третьему. Тот, должен использовать эти классы для сборки приложения — но как к ним обращаться? Вариант №1 — на берегу договориться о наименовании классов — это подойдет для небольшого их количества, но если их десятки и сотни? Вариант №2 -правильный вариант — договориться об использовании пространств имен

<?php namespace developer1; class ClassName { // some methods }
Code language: HTML, XML (xml)
<?php namespace developer2; class ClassName { // some methods }
Code language: HTML, XML (xml)

вот так это будет выглядеть в php. Далее, третьему нужно использовать эти классы в своем коде. Чтоб обратиться к такому классу, нужно сначала указать пространство имен, а затем имя класса

<?php $var1 = new developer1\ClassName(); $var2 = new developer2\ClassName();
Code language: HTML, XML (xml)

это годится при единичном вызове каждого класса, но что если ссылок на каждый класс множество? каждый раз писать пространство имени класса? По меньшей мере, это не удобно. Для этого есть директива use as (для php, в других языках есть аналоги)

<?php use developer1\ClassName as ClassName1; use developer2\ClassName as ClassName2; $var1 = new ClassName1(); $var2 = new ClassName2();
Code language: HTML, XML (xml)

По сути, это работает как «локальное переименование», т.е классы ClassName1 и ClassName2 будут существовать только в пределах файла с этими директивами. В случаи если начальные классы имеют разные названия, директиву use as можно сократить до use

<?php use developer1\ClassName; $var = new ClassName();
Code language: HTML, XML (xml)

Работать это будет так: при обращении к классу ClassName будет проверены все директивы use и если есть та, которая на конце имеет такое же имя класса, будет обращение к пространству этого имени.

Автозагрузка классов

Обычно, чтоб получить доступ к файлу, используют функцию на подобие require(), применяя ее к каждому файлу. Когда проект большой и требует подключения множества файлов, а также написан с соблюдением принципов ООП(что в нынешних реалиях маст хэв,

пьяная плесень - монгольский

т.е. обязательно) это неудобно. В этом случаи удобно использовать «автозагрузку классов». Дело в том, что при обращении к незнакомому классу, возбуждается функция, которой передается название требуемого класса(если известно его имя пространства — будет передано и оно).

Таким образом, объявив эту функцию раньше вызова неизвестного класса, можно подключить ее файл. Реализую это на php.

Сначала приведем структуру директорий и названий классов в соответствие с namespace и именем класса. Есть две функции для подобной работы — __autoload()(устаревшая с php 7.2) и spl_autoload_register()(появившаяся в php 5.0). Также, с php 5.3 добавлены анонимные функции и код автозагрузчика будет выглядеть так

<?php spl_autoload_register(function($class) { require($class . '.php'); });
Code language: HTML, XML (xml)

Использование анонимной функции необязательно, можно, как аргумент, указать имя функции-загрузчика.

Заключение

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