Веб-сокеты на PHP, простые рецепты разработки и отладки

Не совсем ожидаемо для меня, хотя и совершенно закономерно, в моём блоге самой популярной стала тема, которой я посвятил больше всего времени — работа с веб-сокетами (предыдущая статья про чат). Сегодня я опубликую новый усовершенствованный инструмент управления веб-сокетами Downloads, ws server admin panel v.0.3., который будет полностью управляем через веб панель как под Windows, так и под *nix. Расскажу о том, с какими я столкнулся проблемами, как их лечил и предоставлю несколько полезных рецептов посвященных работе с веб-сокетами на PHP.

Небольшой обзор проделанной работы

Я веду небольшой (около 3х тысяч строк) игровой проект Growing Crystals(последняя AJAX-версия до перехода на ws) о нём есть много материалов в моём блоге. Изначально весь обмен данными клиент-сервер был выстроен на технологии AJAX. Однако, по советам друзей, я обратил внимание на технологию веб-сокет что и привело к написанию первой статьи о работе с веб-сокетами на PHP. Затем пришлось решать задачи запуска веб-сокет демона, и закончилось эта история разработкой простых приложений: панели управления веб-сокет сервера и чата на веб-сокете. После получения панели управления и реального работающего чата, я вернулся к разработке проекта Growing Crystals, ради которого изначально и затевалось знакомство с веб-сокетами. Это было огромным удовольствием осознавать на сколько правильно поступил выбрав в качестве транспорта для своего игрового приложения веб-сокет, технология оказалась очень удобна и органична с точки зрения встраивания в проект. В то же время, с момента выхода статьи с чатом, прошло уже более месяца и благодаря вашим комментариям я почувствовал необходимость поделиться опытом разработки гораздо более сложного приложения чем чат, и заодно предоставить вашему вниманию обновленный и более удобный инструмент Downloads, ws server admin panel v.0.3., а также инструкцию по его разворачиванию.

Прежде чем двинуться дальше напомню, что ws это веб-сокет, а pid-файл, это файл содержащий id процесса ws в системе, он создаётся и хранится только для того, чтобы можно было проверить запущен ли ws и если запущен то работает ли он в настоящее время.

ws сервер + панель управления v.0.3.

Прежде чем начать работу с ws, рекомендую скачать вам архив Downloads, ws server admin panel v.0.3., и установить его хотя бы в Денвере, поскольку все дальнейшие приёмы и способы будут разобраны на базе данной системы.

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

Инструкция по разворачиванию системы ws server admin panel v.0.3.

Система пригодна для разворачивая как в корневом каталоге домена так и в каталоге любой степени вложенности, единственное что изменяется, так это пути в конфигах.

Использование в Девере на localhost

Рассмотрю пример конфигурационных файлов для использования на localhost (они уже прописаны в архиве и пригодны для запуска на localhost).

  1. Распаковываем содержимое архива в каталог Денвера w:\home\localhost\www\
  2. Открываем в редакторе файл w:\home\localhost\www\chat\ws\wsadmin.js. Единственное, что нужно в него прописать это адреса скрипта панели управления, лог-файла и файла ошибок. Для localhost они выглядят следующим образом.
    	var srvaddress = 'http://localhost/chat/ws/';//url каталога
    var adminaddress = srvaddress+'wsadmin.php?';
    var logfile = 'http://localhost/chat/log/wslog.html?';
    var errorfile = 'http://localhost/chat/log/wserrors.txt?';

    Такие же нехитрые манипуляции проделываются и с файлами w:\home\localhost\www\chat\chat.js, w:\home\localhost\www\chat\echoclient.js.

  3. Открываем в редакторе файл w:\home\localhost\www\chat\ws\wsadmin.php, и задаём логин-пароль для администратора ws-сервера.
    	$login = 'admin';
    $pass = 'xxxxx';
  4. Открываем в редакторе файл w:\home\localhost\www\chat\cfg\ws.cfg.php, содержащий все основные настройки ws-сервера, по умолчанию менять в нём ничего не требуется, но вы можете обратить внимание что в нём указаны основные параметры работы ws-сервера, адрес, порт, максимальное количество подключений с одного IP-адреса и др.
  5. Для того, чтобы убедиться в правильных настройках, заходим по адресу http://localhost/chat/ws/admin.php панели управления. Если авторизация прошла успешно и вы попали в меню управления ws, то теперь вам следует попробовать запустить и остановить ws-сервер нажав поочередно клавиши start и спустя 8-10 секунд stop.

Что нового в ws server admin panel v.0.3.

Работая над проектом приходится часто перезагружать ws и если процесс завершил работу не корректно то pid-файл остаётся, хотя самого процесса нет. В старой версии программы приходилось вручную осуществлять удаление pid-файла, сейчас если система обнаружила что процесса нет и в панели администратора показалось сообщение красного цвета, то теперь достаточно нажать клавишу start и система принудительно в течение нескольких секунд запустится создав новый pid-файл без дополнительных манипуляций.

Также реализовано получение данных о процессе ws под ОС Windows, пока удалось протестировать только под Windows 7, но, если окажется что не работает под Windows 8, дайте об этом знать и я выпущу обновление. Получение данных о процессе под ОС Windows было необходимо чтобы экономить время разработчика на перезапуск процесса в время отладки.

Исправлена проблема запуска php скриптов из Денвера. Долго не мог разобраться в чём проблема при работе под ОС Windows, оказалось что правильный путь php в Денвере выглядит следующим образом w:\usr\bin\php5.exe. Если использовать другой путь w:\usr\local\php5\php.exe то запуск будет происходить далеко не каждый раз. Однако теперь в процессах появляется не php.exe а сразу несколько процессов php5.exe и php-cgi.exe.

Единственное что я не реализовывал, это проверку занятости портов, и проверку на наличие запущенных процессов при отсутствии pid-файла, поскольку для разработки подразумевается что вы знаете какой порт использовать и не будете удалять pid-файл уже запущенного процесса. Но если вы случайно удалили pid-файл запущенного процесса, то просто перезагрузите веб-сервер целиком. Реализацию принудительно выключения процесса у которого нет pid-файла я пока не рассматриваю.

Отладка приложений на веб-сокетах на PHP

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

  • ошибка интерпретации PHP кода (E_PARSE) при ней выдаётся сообщение о невозможности запуска PHP кода указывая на строку и тип возникшей ошибки в исходном коде;
  • ошибка которая может возникнуть во время выполнения программы (E_ERROR), как правило выдаётся ошибка PHP Fatal error с описанием того, что конкретно привело к этой ошибке.

Всё просто когда вы работаете на прямую с веб-страницей на PHP, ведь для этого вам достаточно воспользоваться директивами error_reporting и display_errors, но как понять какая ошибка произошла когда вы работаете с демоном использующим веб-сокет и который не запускается при нажатии на кнопку start из панели управления ws server admin panel v.0.3.?

Скорее всего ошибка E_PARSE. В таком случае я использую запуск PHP-скрипта через браузер, на примере данного архива запускаю чат по адресу http://localhost/chat/init.php. Если в скрипте имеется проблема интерпретации PHP кода, она сразу становится видна. Если же скрипт запустился, то это хорошо, можно убедиться в этом в панели управления http://localhost/chat/ws/admin.php.

Далее, если в результате работы скрипт упал, вы увидите красную надпись в панели управления. Причину ошибки можно будет посмотреть там же в окне лога ошибок сервера echo ws server errorfile. Вы увидите в конце строку PHP Fatal error с описанием того, что случилось. Чаще всего это может быть обращение к несуществующему элементу массива, обращение к заприваченному свойству объекта или вызов неизвестной функции. Таким образом дописывая исходный код вашей программы, вы сможете проверять его не только на этапе интерпретации но и в процессе выполнения. Если не удаётся разобраться с проблемой во время выполнения программы (E_ERROR). Например, в логиге ошибок (echo ws server errorfile) указано что идёт обращение не по адресу или происходит попытка вызвать неизвестную функцию, в таком случае я предлагаю использовать функции логирования consolemsg и функцию представления содержимого переменной test_var_value в теле программы, чтобы посмотреть как ведёт себя программа во время выполнения и почему она приходит к таким результатам.

Чат на веб-сокетах

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

Автоматический запуск

Для чата автоматический запуск может быть необходим просто для того, чтобы администратору не нужно было постоянно следить за тем, поднялся ws после перезагрузки Apache или нет. Т.к. скрипт ws-сервер чата простой, то достаточно реализовать при запуске чат клиента обращение по AJAX к скрипту сервера который всегда инициирует проверку состояния и осуществляет запуск если ws-сервер чата не запущен. Но это применимо только для простых приложений типа чата. В случае с моим игровым проектом, я не использую автозапуск, т.к. там процесс запуска сложен сам по себе и может занимать до 40 секунд времени, поскольку игровые карты загружаются в память. Также к игровому проекту совершенно другой подход, при котором состояние игры должно мониториться более тщательно администратором и в случае проблем отправляться e-mail уведомление.

Как реализовать автозапуск ws-сервера? Реализуется простая функция на javascript на стороне клиента wsserverrun(), которая каждый раз при загрузке клиента делает AJAX-запрос к серверному скрипту wsstart.php отвечающему за запуск ws, который запускает ws в случае, если он отключен.

    var wsserverrun = function() {
xhttp = new XMLHttpRequest();
xhttp.open('GET',startserveraddress,true);
xhttp.send();
xhttp.onreadystatechange=function(){
if (xhttp.readyState==4){
//Принятое содержимое файла должно быть опубликовано
console.log(xhttp.responseText);
//Принятое содержимое json файла должно быть вначале обработано функцией eval
var json=eval( '('+xhttp.responseText+')' );

if (json.run == 1) return;
else if (json.run == 2){sleep(500); return;}
}
}
};

function sleep(ms) {
ms += new Date().getTime();
while (new Date().getTime() < ms){}
};

Скрипт wsstart.php очень простой, рекомендую ознакомиться с ним самостоятельно.

Хранение логов

В комментариях под прошлой статьёй был вопрос о том, как организовать логирование чата в БД. Реализация этого очень проста, единственное, что вместо запроса INSERT в БД я сохраняю данные в лог-файл. Для этого я написал простую функцию chatlogmsg($msg), которая сохраняет всё в отдельный файл chatlog.html.

<?php
//-----------------------------------------------------------------------------------
/**
* Логирование сообщений чата
*/
function chatlogmsg($msg){
$file = null;
if(!file_exists($GLOBALS['ws']['chatlogfile'])) {
$file = fopen($GLOBALS['ws']['chatlogfile'],"w");
fputs($file, "<!DOCTYPE html>\r\n<html>\r\n<head>\r\n<title>chat log</title>\r\n\r\n<meta charset=\"UTF-8\" />\r\n</head>\r\n<body>\r\n"); //В новый файл записываем заголовок
}else
$file = fopen($GLOBALS['ws']['chatlogfile'],"a");

echo $msg."\r\n";
if( function_exists('memory_get_usage') ) echo "Mem: ".memory_get_usage()." bytes ";

fputs ($file, "[<b>".date("Y.m.d-H:i:s")."</b>]". $msg ."<br />\r\n");
fclose($file);
}
?>

и добавил её во все необходимые места кода. На всякий случай в лог-файл я решил записывать еще и ip-адреса и порты клиентов.

chatlogmsg("(".$this->users[$uid]['ip'].":".$this->users[$uid]['port'].")".$msgtosend);

В итоге лог файл выглядит следующим образом

[2014.08.04-00:29:46](127.0.0.1:50183)Мистер Слива вошел в чат
[2014.08.04-00:30:01](127.0.0.1:50142)[00:30] Мистер Огурец: Всем привет и пока
[2014.08.04-00:30:20](127.0.0.1:50183)Мистер Слива покинул чат

Подсчёт количества пользователей он-лайн

Тоже очень простая задача, для этого в классе websocketserver_class заводим приватную переменную $online, задаём значение 0 в конструкторе.

При подключении пользователя

$this->online ++; 

при отключении соответственно

$this->online --; 

Единственное, что теперь необходимо сообщать количество он-лайн пользователей всем подключенным клиентам, что я и делаю, передавая каждый раз всем сообщение, когда значение $this->online изменяется

fwrite($connect, $this->encode("chat-users:".$this->online));//обновляем количество пользователей он-лайн

На стороне клиента я делаю проверку на наличие маркера и записываю значение после двоеточия в соответствующее поле на html странице клиента.

if( e.data.substr(0,11) === 'chat-users:'){
document.getElementById("chat-users").innerHTML = e.data.substr(11);
return;
}

Немного о разработке большого приложения

Постараюсь в несколько слов рассказать о своём опыте. Ранее у меня уже было приложение которое работало на AJAX. Переход оказалось осуществить достаточно просто. В классе websocketserver_class я завёл экземпляр главного класса игрового проекта game_class. В game_class я заменил получение данных из переменных $_GET на получение данных из переменных передаваемых в него из websocketserver_class в качестве аргументов. Также websocketserver_class передавал еще и id игрока с которым произошло событие, поскольку процесс всегда загружен в памяти и для него не могло существовать сессий игроков. С помощью полученного id game_class опознавал игрока, восстанавливал данные игрока в класс player_class, производил над ним определенные манипуляции в итоге сохраняя данные из player_class в БД или файл или память. Таким образом переход с AJAX на ws оказался действительно очень прост. Вот, кстати, как стала выглядеть диаграмма классов после перехода на ws.

Диаграмма классов UML

Что касается клиентского приложения то переход также оказался весьма прост: вместо XMLHttpRequest() для отправки данных стала использоваться socket.send(); и socket.onmessage для получения соответственно. Вместо GET-запросов, стали отправляться данные по JSON, что фактически перевело всю коммуникацию клиент-сервер исключительно в формат JSON.

Комментарии, как всегда, приветствуются.

upd 2014.11.27: следующая статья, релиз анонимного чата.

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

  • Shone_dz

    Спасибо за статью!

  • Igor

    Благодарю! Отдельный спасибо за админ.панель!

    У меня только один вопрос — почему при работе с socket_select получается использовать только fread с указанным размером буфера
    fgets (построчно) в цикле не проходит
    fread в цикле до feof тоже не проходит

    получается, в любом случае данные для передачи ограничены размером буфера fread.

    • Здравствуйте, Игорь.
      Честно говоря, не вдавался в технические подробности реализации stream_select. Могу сказать лишь то, что при необходимости передавать большие пакеты данных необходимо разбивать отправку на несколько пакетов. Данная реализация настроена на быструю обработку и обмен малыми пакетами.
      И для меня не совсем понятно зачем читать данные из буфера построчно? Что мешает разбить данные на строки уже после fread.

      • Igor

        У вас в реализации размер буфера 100000
        мало ли, вдруг через чат решат отправить главу из войны и мира 🙂

        Но вообще у меня еще клиент на php, помимо браузера, который отправляет некие структуры данных, скажем, json
        Подключение
        к сокету происходит через fsockopen, запись через fwrite, чтение через
        fgets — удобно для разбора заголовков handshake. В конце чтения данных
        из сокета fgets возвращает false. Т.е. теоретически можно читать буфер
        любой длины, клиент ведь тоже не знает сколько данных отправил сервер.
        А вот со stream_select на сервере это не проходит, к сожалению.

  • Николай Приймак

    у вас БАГ в чате!!! если открыть чат в трех вкладках и потом закрыть первую то остальные перестанут работать!

    • Николай Приймак

      как пофиксить?

      • Николай Приймак

        вот что получаем когда закрываем вкладку
        [2014.11.19-17:47:10]Mem: 620136 bytes WebsocketServer start() while(true)

        [2014.11.19-17:47:21]Mem: 620196 bytes WebsocketServer start() while(true)

        [2014.11.19-17:47:21]Mem: 611468 bytes WebsocketServer connection closed

        [2014.11.19-17:47:21]Mem: 611468 bytes WebsocketServer start() while(true)

        [2014.11.19-17:47:21]Mem: 599576 bytes WebsocketServer connection closed

        [2014.11.19-17:47:21]Mem: 599576 bytes WebsocketServer start() while(true)

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

          • Николай Приймак

            ухты класс! а кгда мжно будет скачать новый скрипт? можно мне на майл ?
            если вам будет не трудно то добавьте комнаты для чата)) спасибо вам за проделанный труд!

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

            Решать проблему на уровне фиксации бага как я писал раньше — я не буду.

            Я сделал новую версию чата на базе библиотеки на классах, но пока не готов её опубликовать — нужно потестировать. Напишу.

          • Иван

            буду ждать…

          • http://petukhovsky.com/simple-web-socket-on-php-anonymous-chat/
            реализовано на базе стабильной версии ws-класса используемого в игре.
            Тестируйте.

          • Иван

            Спасибо!

          • Николай Приймак

            я надеюсь вы не будете тянуть с новой версии скрипта ее выложить его в свободный доступ в ближайшее время заранее большое спасибо

          • http://petukhovsky.com/simple-web-socket-on-php-anonymous-chat/
            полностью переведено на классы и добавлено несколько функций.
            Тестируйте.

          • Николай Приймак

            ап жду с нетерпением фикса бага

          • Иван

            жду фикса бага!

  • Спасибо за статью (а точнее за всю серию). Благодаря вам разобрался с сокетами за одну ночь, не смотря на то, что узнал о них два дня назад ) У меня вопрос лишь косвенно касается данной темы, но я так понял, вы человек не глупый, и быть может поможете разрешить мой вопрос:

    Стоит тривиальная задача: модуль комментариев для сайта (это пока, в дальнейшем так же на сокетах хотел реализовать оповещения о прочих событиях).
    Поднял на php websocket сервер, клиент на js тоже прекрасно работает. Сами комментарии хранятся в БД MySQL.

    Как работает сейчас:
    Посетитель заходит на страницу новости/блога, через js подключается к нужному каналу websocket’ov. Если публикует новый комментарий, через ajax+php запись попадает в MySQL. Параллельно отправляет информацию на сервер websocket о том, что добавлен комментарий. Сервер рассылает инфу всем кто подписан на канал. И собственно таким образом все видят новый коммент.

    Проблема:
    Не у всех посетителей браузер поддерживает ws, и как следствие комментарий добавляется в БД, но другие клиенты об этом ничего не знают.

    Вариант решения:
    На сервер ws информацию о новом комментарии должен отправлять не браузер клиента, а некий скрипт, который отследит изменения в БД.

    И, наконец, вопрос:
    Возможно ли в MySQL повесить триггер на событие INSERT/DELETE/UPDATE, который в свою очередь запускал бы скрипт php или любой другой, способный отправить информацию на websocket сервер.

    Если да — то как? Если нет — то как еще можно отследить изменения в БД?

    PS/ И если уж клиент перестает отправлять сокеты, то по сути необходимость в двусторонней связи пропадает, не логичней ли будет использовать SSE вместо websocket?