[Из песочницы] Работаем асинхронно в PHP или история ещё одного чата
Меня очень радует, как бурно развивается PHP последние несколько лет. Наверное и вас тоже. Появляются постоянно новые возможности, удерживающие энтузиастов оставаться на данной платформе. Чего только стоит недавняя новость о релизе Hack.Наверняка кто-то прочитав даже заголовок этой статьи ухмыльнется и подумает «мсье знает толк в извращениях!». Споры о крутости того или иного языка никогда не утихают, но как бы там ни было, лично я для себя вижу не так уж и много условий смены языка, поскольку люблю выжимать все возможности, прежде чем радикально сменить весь стек. Недавно была публикация о создании чата на Tornado и мне захотелось рассказать о том, как похожую задачу я решал при помощи PHP.
Предыстория В один прекрасный день решил я познакомиться с WebSockets. Меня заинтриговала технология, хотя не сказать бы, что она появилась только вчера, и это совпало с запуском одного чат-сервиса соционической тематики, которые страдал массой недостатков. Это придало мне азарт принять участие в конкурентной гонке. Использование веб-сокетов выглядело принципиально новым и многообещающим решением.Соединение устанавливается постоянным и двунаправленным, а на стороне клиентской части работа сводится к обработке 4-х событий: onopen, onclose, onerror и конечно же onmessage. Никаких больше запросов через setInterval, избыточного трафика и нагрузки на сервер.Позвольте здесь сделать небольшое отступление для тех, кто не понимает о чём речь.Те, кто знаком с рунетом начала 2000-х может помнят многообразие чат-сервисов, где всё тормозило и неуклюже работало.Чуть позже появился AJAX и стало гораздо лучше, однако в сущности принцип не изменился. Клиентская часть всё так же с некоторой заданной частотой по таймеру опрашивала сервер, разве что теперь можно было отказаться от использования iframe и снизить немного нагрузку на сервер за счёт меньшего объема отдаваемых данных.Собственно, упомянутый чат-сервис был классическим ajax-чатом.
Есть правда и обратная сторона медали в избранном подходе:
отсутствие поддержки на старых браузерах ручное управление поддержанием соединения использование демона в серверной части Если на счёт первого я не переживал особо, поскольку моей целевой аудиторией была молодёжь с современными компьютерами и мобильными гаджетами, в которых поддержка WebSockets давно реализована, то как раз на счёт второго возникли в дальнейшем затруднения, о которых я поведаю далее.Использование же демона имеет ряд особенностей:
Обновить код можно только перезапустив демона — соответственно для «чатлан» это происходит в той или иной степени заметно Фатальные ошибки и необработанные исключения приводят к падению демона — код нужно писать «пуленепробиваемым» Демон должен использовать выделенный свободный порт — это проблема для тех, кто сидит за строгим фаерволом Использовать неблокирующие функции Те, кто никогда не слышал о том, что такое «резидентная программа», а писал лишь код для web-страницы, работающий по принципу «запустился-отработал-умер», испытывают разрыв шаблона при написании демона в первый раз. Например, выясняется, что инстанцированные объекты могут «жить» долго и хранить информацию без использования хранилища типа базы данных, и доступ к которой можно получать из разных подключений к демону. Пожалуй именно при его написании наиболее остро можно натолкнуться на проблему блокирующих функций и просто отсутствия заточенности PHP под асинхронность.
Что есть вообще асинхронность? Если по-простому, то это способность кода «распараллеливаться», выполнять несколько кусков кода независимо друг от друга. Я надеюсь, что читатель знаком хотя бы с азами JavaScript. Большинство хоть раз писали нечто вроде:
var myDomElement.onclick = function () { alert («I’m hit!»); } Элементарно, да? Определяется обработчик события клика на какой-то элемент страницы. А что, если мы попробуем нечто подобное сделать в PHP?
Первый вопрос возникнет «где определить события объекта». Второй «как сделать так, чтобы постоянно происходил опрос объекта на данное событие?». Ну допустим, мы сделаем некий бесконечный цикл, в котором будет опрашиваться данное событие. И тут же столкнемся с рядом серьёзных ограничений. Во-первых, частота опроса не должна быть слишком низкой, чтобы реакция системы была удовлетворительной. И не должна быть слишком высокой, чтобы не создавать проблем с нагрузкой на систему. Во-вторых, когда событий станет несколько, возникнет проблема с тем, что пока первый обработчик не отработает — другой не начнёт свою работу. А если надо обрабатывать тысячи подключений одновременно?
Но на сцене появляется ReactPHP и делает магию.
Ингридиенты Основой серверной части выступил пакет Ratchet, являющийся в сущности надстройкой над ReactPHP для работы с WebSockets. Была мысль использовать javascript-фреймворк, что-нибудь вроде AngularJS, но на тот момент я хотел побыстрее запустить проект и изучение нового фреймворка не вписывалось в плотный график. Так что по началу был голый javascript, потом всё же подключил и jQuery. С вёрсткой и дизайном я не хотел заморачиваться, поэтому обратился к Twitter Bootstrap 3 Посчитал, что достаточно важно будет задействовать HTML5 Notifications, вместо мигания заголовком страницы или звукового оповещения. Получившийся демон требовал своего отдельного порта, поэтому для решения проблемы с фаерволами я воспользовался nginx и настроил проксирование WebSockets. Ради интереса также прикрутил SSL-сертификат Краткая структура Серверная часть состоит из двух ассиметричных по размеру частей кода:
Классические web-страницы (index, восстановление пароля) Чат демон Главная страница решает задачи загрузки клиентского веб-приложения, а также инициализацию сессии.Демон представляет собой в основе реализацию интерфейса MessageComponentInterface из пакета Ratchet в виде класса MyApp\Chat. Реализуемые методы обрабатывают события onOpen, onClose, onError и onMessage.Каждый из обработчиков, за исключением onError, представляет собой шаблон Chain-of-Responsibility. Наиболее объемный кусок кода пришёлся на onMessage, где он декомпозирован на контролеры.
Возникшие проблемы и способы решения Первое, с чем пришлось столкнуться это то, что фаталы, любые ошибки без кастомного обработчика и необработанные исключения убивают демон. С фаталами и исключениями проблема решается только с помощью тестов. К моему стыду, до тестов руки не дошли в силу сильной нехватки времени, но всё же и это будет. Простые ошибки же, наверное и сами знаете, решаются просто с помощью пользовательского ErrorHandler + логгирования. Была выявлена проблема, когда после нескольких дней эксплуатации кто-то дисконнектнулся и чат-демон стал жрать 100% CPU, хотя тормозов в работе чате не появилось. Поправил патчем от автора Ratchet, найденном в GitHub. Однако, почему-то он до сих пор не включён в пакет ReactPHP.Патч diff --git a/vendor/react/stream/React/Stream/Buffer.php b/vendor/react/stream/React/Stream/Buffer.phpindex e516628…4560ad9 100644@@ -83,8 +83,8 @@ class Buffer extends EventEmitter implements WritableStreamInterfacepublic function handleWrite (){— if (! is_resource ($this→stream) || ('generic_socket' === $this→meta['stream_type'] && feof ($this→stream))) {— $this→emit ('error', array (new \RuntimeException ('Tried to write to closed or invalid stream.')));+ if (! is_resource ($this→stream)) {+ $this→emit ('error', array (new \RuntimeException ('Tried to write to invalid stream.'), $this));
return;}@@ -107,6 +107,12 @@ class Buffer extends EventEmitter implements WritableStreamInterfacereturn;}
+ if (0 === $sent && feof ($this→stream)) {+ $this→emit ('error', array (new \RuntimeException ('Tried to write to closed stream.'), $this));++ return;+ }+$len = strlen ($this→data); if ($len >= $this→softLimit && $len — $sent < $this->softLimit) {$this→emit ('drain');
Удержание соединений — пожалуй достаточно важная проблема. На обычных подключениях через проводную сеть или приличный wi-fi всё было хорошо. Однако, при заходе с мобильного интернета было выявлено, что операторы мобильной связи не любят постоянные соединения и обрезают их, судя по всему, в зависимости от нескольких условий. Например, если БС слабо загружена и в чате все молчат, то могло выбросить через 30 секунд. А могло и не выбрасывать даже. Так, что для профилактики я добавил циклическую посылку команды «пинг» на сервер, чтобы создавать активность. Но как оказалось, при большей загруженности БС и это не прокатывало.Вообще, давно напрашивалась реализация алгоритма: отложенное отключение пользователя из массива присутствующих пользователей по истечении таймаута. Очевидно, что это требует использования асинхронной работы кода. Естественно никакой sleep () тут не годился. Я прикидывал всевозможные варианты реализации, включая даже сервер очередей. Решение нашлось и оказалось простым и изящным: ReactPHP позволяет использовать таймеры, вешающиеся на EventLoop. Выглядит это примерно так: private function handleDisconnection (User $user) { $loop = MightyLoop: get ()→fetch (); // получили одиночку EventLoop, на котором также работают сокеты $detacher = function () use ($user) { // обработка удаления пользователя из реестра посетителей в онлайне … };
if ($user→isAsyncDetach ()) { $timer = $loop→addTimer (30, $detacher); // 30 секунд $user→setTimer ($timer); } else { $detacher ($user); }
$user→getConnection ()→close (); } Соединение с БД в режиме демона есть смысл держать открытым из соображений производительности и минимизации захламления логов ошибками соединения. В любом случае пришлось добавить в обёртку для PDO костыльный метод, вызываемый перед каждым запросом, чтобы гарантировать соединение с БД: protected function checkConnection () { try { $this→dbh→query ('select 1'); } catch (\Exception $e) { $this→init (); } } Увы, я не нашёл более изящного решения. Надо всё же поэкспериментировать с Redis, тем более, что есть готовый пакет predis-async Каждая вкладка браузера генерирует новое соединение. А позволять пользователю размножаться клонированием как-то не хотелось. Пришлось запрещать соединения с одинаковой сессией. Это поведения отличается от классических чатов, которые позволяют легко работать одновременно в произвольном количестве окон или вкладок с одной сессией. Что сейчас умеет чат и чему ещё научится Из основных особенностей: Чат-демон занимает в памяти порядка 20 мб и эта цифра стабильна. Это неплохо. Отсутствие обязательной регистрации, пользователь заходит в чат сразу Регистрация, авторизация и восстановление пароля Умеет делать приватные сессии и приватные сообщения (без создания отдельного канала) Персональный чёрный список Чат-рулетка на основе соционического типа Незаметно для пользователя при разрыве соединения делается переподключение Предотвращение дублирования соединений Осуществляется флуд-контроль Что плохо: Нет приличного ORM, самопал Обработчик сессий тоже самопальный Нет тестов Нет многопоточности Что ожидается доработать: Поэкспериментировать с NoSQL БД, например Redis Отдельные комнаты-каналы Загружаемые аватары Настройка различных видов нотификаций Установка личных заметок на пользователей Индикация «сейчас печатает» в приватных каналах Какие выводы можно сделать по прошествии 2-х месяцев разработки проекта? У PHP всё ещё есть потенциал. По крайней мере начало работы с событийно-ориентированной парадигмой положено. Но увы, пока что язык пытается догнать, а не стать во главе движения. Если сравнить Ratchet и Tornado, то по возможностям они ещё не ровня. Будем надеяться, что развитие в этом направлении продолжится с положительным ускорением.
Для любопытных, исходный код проекта можно увидеть здесьКонструктивные комментарии приветствуются.