Простой веб-сокет на PHP или веб сокеты с абсолютного 0

Или как работать с WebSocket на простом PHP хостинге

Или getting started with WebSocket PHP без phpDaemon

Здравствуйте! Простите за столь длинный заголовок, но, надеюсь, что новичкам вроде меня будет легче найти эту статью, ведь мне ничего подобного найти не удалось. Несколько недель назад я принял решение переработать игровой клиент и сервер своей игры Growing Crystals с AJAX, на WebSocket, но всё оказалось не просто непросто, а очень сложно. Поэтому я и решил написать статью, которая бы помогла самым что ни на есть начинающим разработчикам на WebSocket + PHP сэкономить несколько дней времени, максимально подробно объясняя каждый свой шаг по настройке и запуску первого WebSocket скрипта на PHP.

Что у меня есть: Денвер на локальной машине, на нём я веду разработку проекта и дешевый PHP хостинг, на котором я публикую свой проект для того, чтобы получить обратную связь от Интернет-пользователей.

Что я хочу: Без установки phpDaemon (phpd), NodeJS и прочих вещей на локальную машину и хостинг, продолжить разработку своего проекта, но теперь с WebSocket, в этой статье разберем простой WebSocket эхо сервер.

Чего я не хочу: Говоря о NodeJS, не хочется переписывать серерную логику с PHP на другой язык, тем более устанавливать NodeJS, хотя и люблю JavaScript больше чем PHP.

Важно: не путайте демона написанного на php, с фреймворком асинхронных приложений phpDaemon. Который, конечно же, обязательно потребуется в случае развития проекта и многократного роста нагрузки на хостинг. Но для начала работы с WebSocket на дешевом хостинге можно обойтись и без него.

Проверка поддержки сокетов на хостинге и в Денвере

— Что нужно сделать в первую очередь для начала работы с WebSocket?
— Проверить поддержку сокетов на хостинге и в Денвере.

Для этого создаём простенький файлик sockettest.php

<?
if(extension_loaded('sockets')) echo "WebSockets OK";
else echo "WebSockets UNAVAILABLE";
?>

Загружаем на хостинг и обращаемся к нему через браузер, и, здесь может быть самое неприятное, хостинг выдаст WebSockets UNAVAILABLE. В таком случае нужно искать другой хостинг, либо, если есть возможность поменять в настройках хостинга версию и комплектацию PHP на PHP с sockets, добиваться того, чтобы хотсер волшебным образом это сделал. Если хостер такой возможности не предоставляет, то тогда при выборе нового хостинга, нужно смотреть на php_info(); и по нему определить поддержку сокетов по наличию следующей строки. А для того, чтобы сокеты заработали в Денвере, пришлось немного покопаться.

sockets-enabled

В моём случае хостинг заработал с первой попытки, что в 2014 году вероятно на 80%.

Добавление веб-сокетов в Денвер

Теперь задача настроить Денвер. Мне довелось работать с разными сборками Денвера. Последняя из них Denwer3_Base_2013-06-02_a2.2.22_p5.3.13_m5.5.25_pma3.5.1_xdebug.exe, к сожалению, она и другие имеющиеся в настоящий момент сборки Денвера(дело в PHP) не поддерживают сокеты по умолчанию.

Но эта проблема решается путём поиска и установки подходящей php_sockets.dll. Для того, чтобы всё заработало, достаточно разместить dll файл в каталоге Денвера \usr\local\php5\ext\php_sockets.dll и отредактировать файл \usr\local\php5\php.ini убрав точку с запятой перед строкой

extension=php_sockets.dll

перезагрузить Денвер и всё, или почти всё: в некоторых случаях при перезагрузке Денвера может возникнуть ошибка

waring

это значит что вы используете не подходящую версию файла php_sockets.dll. Чтобы облегчить поиски предлагаю две версии — одна гарантированно подходит для PHP 5.2.12 (скачать), а вторая для гарантированно подходит для PHP 5.3.13 (скачать). Но если вам не подошел ни один из этих файлов, предлагаю скачать полный архив соответствующей версии php для windows с веб-сайта php.net и найти в архиве файл php_sockets.dll, который точно подойдёт.

Теперь можете запустить файлик sockettest.php через браузер и увидеть заветную строку «WebSockets OK», означающую что всё необходимое для работы с Web-Socket установлено и работает хорошо.

От сокета к веб-сокету

Теперь, когда модуль сокетов активирован в PHP, нам предстоит путь от написания простого сокет скрипта, до скрипта способного корректно общаться по протоколу веб-сокет. Дело в том, что PHP умеет только технически работать с сокетами: принимать соедние, заводить очередь, отправлять/принимать данные, находиться в режиме ожидания и другие технические вещи. Но в нём совершенно отсутствуют готовые заголовки и другие метаданные необходимые для веб-сокетов. Обычное сокет-соединение отличается от веб-сокетов тем, что мы можем отправлять любые сообщения в произвольном формате, в то время, как веб-сокет требует обязательной отправки заголовков вида «GET / HTTP/1.1\r\nUpgrade: websocket\r\n» и соблюдение достаточного количества других правил.

Есть еще один важный нюанс, теперь касающийся PHP — скрипт обрабатывающий сокет-соединения отличается от обычного скрипта PHP, который многократно выполняется от начала до конца при разгрузке страницы обычно менее чем за 0,1 секунды. Отличаются скрипты работы с WebSocket-ами тем, что длительность их выполнения должна быть бесконечной. Т.е. мы должны инициировать выполнение PHP-скрипта содержащего бесконечный цикл, в котором происходит получение/отправка сообщений по протоколу веб-сокет. На самом деле с этим не должно быть никаких проблем, т.к. существует большое количество способов снять ограничение по времени выполнения PHP скрипта, помимо этого можно запустить скрипт в бэкграунде пользуясь консолью сервера. На PHP вполне возможно даже создание полноценного демона. Существуют готовые решения, о которых я упоминал выше, например PhpDaemon, но сегодня речь не о них.

Для начала, чтобы убедиться что мне не мешают файрволлы, всё настроено правильно и связь между клиентом и сервером может быть установлена, я решил написать и протестировать небольшой PHP скрипт выполняющий роль сокет-сервера (именно сокет, а не веб-сокет!), устанавливающий соединение и отвечающий всем фразой «Hello, Client!». Но для его тестирования нужно несколько клиентов (веб-сокет клиент, чтобы понять базовое отличие от простого сокета и обычный telnet).

Клиент на html+JavaScript для веб-сокет соединения

Для тестирования скрипта сокет-сервера нам понадобится несколько клиентов, один из них telnet (я использовал Putty), второй, веб-сокет клиент, написанный на html+JavaScript. Вы можете не устанавливать себе Telnet, его я использовал исключительно, чтобы разобраться в том, что отправляет сервер и почему я не могу это увидеть в браузере. Приведу код веб-сокет клиента, написанного на html+JavaScript.

socket.html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Siple Web-Socket Client</title>
</head>
<body>
<br /><br />

<script src="socket.js" type="text/javascript"></script>

Server address:
<input id="sock-addr" type="text" value="ws://echo.websocket.org"><br />
Message:
<input id="sock-msg" type="text">

<input id="sock-send-butt" type="button" value="send">
<br />
<br />
<input id="sock-recon-butt" type="button" value="reconnect"><input id="sock-disc-butt" type="button" value="disconnect">
<br />
<br />

Полученные сообщения от веб-сокета:
<div id="sock-info" style="border: 1px solid"> </div>

</body>
</html>

Веб-сокет клиент должен иметь возможность подключаться/отключаться к веб-сокетам, отправлять сообщения, выводить полученные ответы. По умолчанию, в качестве веб-сокет сервера выступает ws://echo.websocket.org, т.к. это гарантированно работающий ws(веб-сокет, далее везде ws) echo сервер, на котором можно убедиться в работоспособности нашего веб-сокет клиента.

socket.js

"use strict"; //All my JavaScript written in Strict Mode http://ecma262-5.com/ELS5_HTML.htm#Annex_C

(function () {
// ======== private vars ========
var socket;

////////////////////////////////////////////////////////////////////////////
var init = function () {

socket = new WebSocket(document.getElementById("sock-addr").value);

socket.onopen = connectionOpen;
socket.onmessage = messageReceived;
//socket.onerror = errorOccurred;
//socket.onopen = connectionClosed;

document.getElementById("sock-send-butt").onclick = function () {
socket.send(document.getElementById("sock-msg").value);
};


document.getElementById("sock-disc-butt").onclick = function () {
connectionClose();
};

document.getElementById("sock-recon-butt").onclick = function () {
socket = new WebSocket(document.getElementById("sock-addr").value);
socket.onopen = connectionOpen;
socket.onmessage = messageReceived;
};

};


function connectionOpen() {
socket.send("Connection with \""+document.getElementById("sock-addr").value+"\" Подключение установлено обоюдно, отлично!");
}

function messageReceived(e) {
console.log("Ответ сервера: " + e.data);
document.getElementById("sock-info").innerHTML += (e.data+"<br />");
}

function connectionClose() {
socket.close();
document.getElementById("sock-info").innerHTML += "Соединение закрыто <br />";

}


return {
////////////////////////////////////////////////////////////////////////////
// ---- onload event ----
load : function () {
window.addEventListener('load', function () {
init();
}, false);
}
}
})().load();

Логика скрипта JavaScript также максимально проста. При загрузке пытаемся подключиться по адресу ws сервера по умолчанию. Выполняем функции приёма отправки сообщений. Я специально для простоты понимания кода не использую jQuery и другие библиотеки. В скрипте используются команды создания веб-сокета (что означает автоматическое подключение), отправки сообщения и закрытия.

var socket = new WebSocket(address); //Создание и подключение к address
socket.send(msg); //Отправка сообщения msg
socket.close(); //Закрытие соединения

и обработчики событий onopen, onmessage. Которые вызываются при открытии соединения и при получении сообщения соответственно. К сожалению, если вы создадите эти два файла и запустите клиента через Денвер с localhost, то у вас возникнут проблемы с кодировкой т.к. Денвер по умолчанию использует CP1251, не забывайте явно прописать UTF-8 в .htaccess. Или скачайте архив данного ws клиента, содержащий также и сокет-сервер.

Код сокет-сервера на PHP я приводить не буду, т.к. он есть в архиве и снабжен комментариями. Я искусственно ограничил время работы приёмки соединений 100 секундами, чтобы скрипт он не оставался в памяти, закрывал все соединения и не приходилось при внесении в него изменений постоянно перезагружать Денвер. При чем, по прошествии 100 секунд, скрипт не прекратит свою работу, а будет ждать подключения, после которого его работа будет завершена, а все сокеты благополучно закрыты. Также, для тестирования, я использую localhost и порт 889.

	socket_bind($socket, '127.0.0.1', 889);//привязываем его к указанным ip и порту

Файлы из архива можно поместить в корневую папку localhost Денвера. Алгоритм тестирования:

  1. Запускаем http://localhost/socket.html, он автоматически подключится к ws echo серверу ws://echo.websocket.org, можно отправить несколько сообщений, которые сервер должен отправить обратно, ведь это же эхо-сервер. Если это работает — отлично, с клиентом всё в порядке, если нет, проверьте все ли вы файлы разместили в одном каталоге, запущен ли у вас Денвер, и есть ли соединение с сетью Интернет.
  2. Откройте в этой же вкладке где у вас открыт ws-клиент JavaScript консоль (В GoogleChrome 34.8.1847.137 m и в FireFox это делается почти одинаково меню->инструменты->консоль Java Script или Ctrl+Shift+J, но лично я использую для этого консоль FireBug). Отправьте еще несколько сообщений. Вы увидите какие сообщения приходят от сервера. Вы можете нажать disconnect и после этого отправить несколько сообщений, вы убедитесь что сообщения не уходят, т.к. связь с сервером ws://echo.websocket.org разорвана.
  3. Запускаем в новой вкладке браузера наш сокет-сервер http://localhost/simpleworking.php. Желательно чтобы вы сразу могли видеть и вкладку клиента с консолью и вкладку сервера. Должно появиться
    GO() ... 
    socket_create ...OK 
    socket_bind ...OK 
    Listening socket... OK 
    

    Что означает, что сервер готов к входящим соединениям.

  4. Во вкладке клиента в поле Server address вводим ws://127.0.0.1:889 и нажимаем reconnect, мы видим что на клиенте ничего не происходит, а на сервере появляются сообщения вида
    Client "Resource id #3" has connected
    Send to client "Hello, Client!"... OK 
    Waiting... OK 
    

    Что говорит нам о том, что технически соединение с сокетом на сервере установлено, а на клиенте ожидается соединение по протоколу веб-сокет, и оно не установлено, т.к. браузером не получены соответствующие заголовки протокола ws. При попытке отправить сообщение с клиента, в консоли должна появиться ошибка о том, что соедние с ws не установлено. К сожалению, скрипт в GoolgeChrome остановится и для новых попыток подключения придётся перезагружать страницу с веб-клиентом. В FireFox скрипт продолжает выполняться. Обязательно сделайте reconnect спустя 100 секунд, после запуска скрипта сервера, чтобы дать ему благополучно завершиться.

    Client "Resource id #4" has connected
    Send to client "Hello, Client!"... OK 
    time = 309.8900001049return 
    go() ended
    Closing connection... OK 
    
  5. Чтобы окончательно убедиться в том, что сервер отвечает, что его сообщения не блокируются файрволом, запустите скрипт сервера http://localhost/simpleworking.php запустите telnet и попытайтесь подключиться к 127.0.0.1:889, только это нужно сделать не позднее 100 секунд, с момента запуска сервера, пока он не закрыл соединения и не завершил скрипт.

По telnet должен придти ответ «Hello, Client!», что свидетельствует о том, что всё работает в штатном режиме и связь с сервером двухсторонняя.

Ответ сокет сервера при попытке подключения по Telnet

Ответ сокет сервера при попытке подключения по Telnet

При тестировании на локальном компьютере используя Денвер, всегда убеждайтесь что выполнения скрипта PHP завершено, в противном случае перезагружайте Денвер, чтобы избежать коллизий и занятых портов.

Протокол веб-сокет

Теперь остаётся научить наш сокет-сервер общаться как веб-сокет сервер, корректно работать в фоне (демон), и, самое главное, на дешевом хостинге. Немного теории: разрешив серверу выводить на экран сообщение, которое получаем от клиента при попытке подключения, добавив перед строкой $msg = «Hello, Client!»; код

	echo "Client \"".$accept."\" says:<br /><pre style=\"border:1px solid;\">";
echo socket_read($accept,512);
echo "</pre>";

можно увидеть, что попытка установить соединение по протоколу веб-сокет всегда сопровождается отправлением клиентом заголовка с обязательным соблюдением формата протокола WebScoket. Тег pre для вывода заголовков выбран не случайно, т.к. в заголовках большое значение играют переносы строк. Такие заголовки я получаю от браузеров, когда пытаюсь подключиться к нашему ws://127.0.0.1:889.

FireFox

GET / HTTP/1.1
Host: localhost:889
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:29.0) Gecko/20100101 Firefox/29.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://localhost
Sec-WebSocket-Key: ndHtpnSPk2H0qOeP6ry46A==
Cookie: vc=3; __gads=ID=9eabc58c385071c7:T=1400699204:S=ALNI_Ma_g9PZBXpi_MLKDBsao3LQiGx-EA
Connection: keep-alive, Upgrade
Pragma:

GoogleChrome

GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: localhost:889
Origin: http://localhost
Pragma: no-cache
Cache-Control: no-cache
Sec-WebSocket-Key: zNMTfmc+C9UK6Ztmv4cE5g==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits, x-webkit-deflate-frame
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/35.0.1916.114 Safari/537.36

Еще до того, как я начал изучать веб-сокеты, мне казалось, что работа с веб-сокетами будет напоминать работу с AJAX, когда мы отправляли запросы серверу используя JavaScript XMLHttpRequest(), но в реальности задача оказалась намного сложнее, и в первую очередь потому, что WebSocket это протокол находящийся на одном уровне с протоколом HTTP (но с некоторыми нюансами) в сетевой модели OSI, что означает, нам на стороне сервера, нужно реализовывать функционал похожий на обработку HTTP (который всегда выполнял Apache). Продолжая сравнения с AJAX, в AJAX нам не нужно обеспечивать работу протокола HTTP, т.к. это всё делает браузер и веб-сервер Apache. Мы просто отправляем и получаем сообщения. Но, использование ws хоть и накладывает на нас достаточно большой объём работы связанный с реализацией сервера на PHP, оно также даёт и преимущества: сокращение объёма передаваемых данных и рост производительности серверного ПО. Для успешного общения с клиентами по протоколу WebSocket сервер должен строго выполнять требования стандарта RFC6455, подробно описывающего форматы заголовков ответов. Все сообщения отправляемые по протоколу WebSocket можно разделить на несколько видов: handsnake (рукопожатие при установлении связи), ping-pong(проверка связи) и data-transfer(передача данных). Также есть более краткое описание протокола в общих чертах на русском. Для того, чтобы обеспечить полноценное корректное общение сервера и клиента по протоколу веб-сокет, необходима реализация функций на PHP получения и разбора заголовков от клиента, составление заголовков сервером, составление ключа и ряд других. Изучив материалы представленные на хабре и других ресурсах, удалось найти реализованные годные функции, единственным смущающим различием явлилось то, что большинство авторов используют потоковые функции работы с сокетами, они считаются более компактными, но, при этом, более ресурсоёмкими в связи с использованием буфера. Для перехода на потоковые функции работы с сокетами, не требуется установка никаких дополнительных модулей кроме уже установленных, т.к. потоки встроены в PHP 5 по умолчанию. Потоковые функции работы с сокетами всегда начинаются с stream_socket_*. При использовании потоковых функций, желательно убедиться в phpinfo() в поддержке необходимого транспорта для потоков.

Registered Stream Socket Transports: tcp, udp. 

В случае если такой информации в phpinfo() нет или в случае возникновения других проблем, обратитесь к документации, но проблема решается банальным обновлением Денвера на актуальную версию.
Еще важным является тот факт, что большинство систем ограничивает возможность создания сокета на порту ниже чем 1024, поэтому во всех дальнейших программах для сокета будет использоваться порт 8889.
Взяв за основу исходные коды функций кодирования и декодирования заголовков протокола WebSocket, удалось реализовать полноценный ws echo сервер. Алгоритм его работы также прост как и в предыдущем случае: создаём ws сервер используя функцию stream_socket_server, запускаем бесконечный цикл в котором проверяем наличие новых соединений и при получении нового соединения размещаем его в массиве $connects, также запускаем второй цикл, который пробегает по всем соединениям и закрывает отвалившиеся и получает сообщения от открытых соединений. Также в скрипт PHP мною добавлено три функции, которые я оформил как обработчиков событий.

function onOpen($connect, $info) {
echo "open OK<br />\n";
}

function onClose($connect) {
echo "close OK<br />\n";
}

function onMessage($connect, $data) {
$f = decode($data);
echo "Message:";
echo $f['payload'] . "<br />\n";
fwrite($connect, encode($f['payload']));
}

Которые ничего не делают, за исключением вывода сообщений о событиях на экран. И при получении сообщения от клиента, раскодируем его и отправляем его текст, предварительно закодировав, обратно. Архив ws клиента и ws echo сервера, ws клиент не изменился. Можно протестировать ws echo server скачав архив, и разместив его в корневой папке localhost-а Денвера. Подключиться клиентом к адресу ws://127.0.0.1:8889.
Тонкости связанные с запуском ws сервера в фоне (демон), и запуск на хостинге мы разберем в следующей статье. Буду рад комментариям и отзывам.

Специально для тех, кто ищет хостинг для запуска своего проекта на веб-сокетах обсуждение по ссылке.

Мои статьи про PHP демонов и веб-сокеты