Работаем асинхронно в PHP или история ещё одного чата

Меня очень радует, как бурно развивается PHP последние несколько лет. Наверное и вас тоже. Появляются постоянно новые возможности, удерживающие энтузиастов оставаться на данной платформе. Чего только стоит недавняя новость о релизе Hack.

Наверняка кто-то прочитав даже заголовок этой статьи ухмыльнется и подумает: «Мсье знает толк в извращениях!». Споры о крутости того или иного языка никогда не утихают, но как бы там ни было, лично я для себя вижу не так уж и много условий смены языка, поскольку люблю выжимать все возможности, прежде чем радикально сменить весь стек. Недавно была публикация о создании чата на Tornado и мне захотелось рассказать о том, как похожую задачу я решал при помощи PHP.

Предыстория

В один прекрасный день решил я познакомиться с WebSockets. Меня заинтриговала технология, хотя не сказать бы, что она появилась только вчера, и это совпало с запуском одного чат-сервиса соционической тематики, который страдал массой недостатков. Это придало мне азарт принять участие в конкурентной гонке. Использование веб-сокетов выглядело принципиально новым и многообещающим решением.

Соединение устанавливается постоянным и двунаправленным, а на стороне клиентской части работа сводится к обработке 4-х событий: onopen, onclose, onerror и конечно же onmessage. Никаких больше запросов через setInterval, избыточного трафика и нагрузки на сервер.

Позвольте здесь сделать небольшое отступление для тех, кто не понимает о чём речь.
Те, кто знаком с рунетом начала 2000-х может помнят многообразие чат-сервисов, где всё тормозило и неуклюже работало.
Чуть позже появился AJAX и стало гораздо лучше, однако в сущности принцип не изменился. Клиентская часть всё так же с некоторой заданной частотой по таймеру опрашивала сервер, разве что теперь можно было отказаться от использования iframe и снизить немного нагрузку на сервер за счёт меньшего объема отдаваемых данных.
Собственно, упомянутый чат-сервис был классическим ajax-чатом.

Есть правда и обратная сторона медали в избранном подходе:
  • отсутствие поддержки на старых браузерах
  • ручное управление поддержанием соединения
  • использование демона в серверной части


Если на счёт первого я не переживал особо, поскольку моей целевой аудиторией была молодёжь с современными компьютерами и мобильными гаджетами, в которых поддержка WebSockets давно реализована, то как раз на счёт второго возникли в дальнейшем затруднения, о которых я поведаю далее.
Использование же демона имеет ряд особенностей:
  1. Обновить код можно только перезапустив демона — соответственно для «чатлан» это происходит в той или иной степени заметно
  2. Фатальные ошибки и необработанные исключения приводят к падению демона — код нужно писать «пуленепробиваемым»
  3. Демон должен использовать выделенный свободный порт — это проблема для тех, кто сидит за строгим фаерволом
  4. Использовать неблокирующие функции


Те, кто никогда не слышал о том, что такое «резидентная программа», а писал лишь код для web-страницы, работающий по принципу «запустился-отработал-умер», испытывают разрыв шаблона при написании демона в первый раз. Например, выясняется, что инстанцированные объекты могут «жить» долго и хранить информацию без использования хранилища типа базы данных, и доступ к которой можно получать из разных подключений к демону. Пожалуй именно при его написании наиболее остро можно натолкнуться на проблему блокирующих функций и просто отсутствия заточенности PHP под асинхронность.

Что есть вообще асинхронность? Если по-простому, то это способность кода «распараллеливаться», выполнять несколько кусков кода независимо друг от друга.
UPD: alekciy справедливо заметил:
Не нужно путать. Асинхронный != параллельный. Классический JS может работать асинхронное, но не параллельно (всилу однопоточности VM). Полезное чтение: Как работают таймеры в JavaScript.

Асинхронность — возможность непоследовательного выполнения кода. Параллельность — возможность выполнения одного и того же кода в одновременно.


Я надеюсь, что читатель знаком хотя бы с азами 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, где он декомпозирован на контролеры.

Возникшие проблемы и способы решения


  1. Первое, с чем пришлось столкнуться это то, что фаталы, любые ошибки без кастомного обработчика и необработанные исключения убивают демон. С фаталами и исключениями проблема решается только с помощью тестов. К моему стыду, до тестов руки не дошли в силу сильной нехватки времени, но всё же и это будет. Простые ошибки же, наверное и сами знаете, решаются просто с помощью пользовательского ErrorHandler + логгирования.
  2. Была выявлена проблема, когда после нескольких дней эксплуатации кто-то дисконнектнулся и чат-демон стал жрать 100% CPU, хотя тормозов в работе чате не появилось. Поправил патчем от автора Ratchet, найденном в GitHub. Однако, почему-то он до сих пор не включён в пакет ReactPHP.
    Патч
    diff --git a/vendor/react/stream/React/Stream/Buffer.php b/vendor/react/stream/React/Stream/Buffer.php
    index e516628..4560ad9 100644
    @@ -83,8 +83,8 @@ class Buffer extends EventEmitter implements WritableStreamInterface

    public 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 WritableStreamInterface
    return;
    }

    + 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');

  3. Удержание соединений — пожалуй достаточно важная проблема. На обычных подключениях через проводную сеть или приличный 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->getConnection()->close();
    }
    

  4. Соединение с БД в режиме демона есть смысл держать открытым из соображений производительности и минимизации захламления логов ошибками соединения. В любом случае пришлось добавить в обёртку для PDO костыльный метод, вызываемый перед каждым запросом, чтобы гарантировать соединение с БД:
    protected function checkConnection()
    {
    	try {
    		$this->dbh->query('select 1');
            } catch (\Exception $e) {
    		$this->init(); 
    	}
    }
    

    Увы, я не нашёл более изящного решения. Надо всё же поэкспериментировать с Redis, тем более, что есть готовый пакет predis-async.
  5. Каждая вкладка браузера генерирует новое соединение. А позволять пользователю размножаться клонированием как-то не хотелось. Пришлось запрещать соединения с одинаковой сессией. Это поведения отличается от классических чатов, которые позволяют легко работать одновременно в произвольном количестве окон или вкладок с одной сессией.


Что сейчас умеет чат и чему ещё научится

Из основных особенностей:
  • чат-демон занимает в памяти порядка 20мб и эта цифра стабильна. Это неплохо;
  • отсутствие обязательной регистрации, пользователь заходит в чат сразу;
  • регистрация, авторизация и восстановление пароля;
  • умеет делать приватные сессии и приватные сообщения (без создания отдельного канала);
  • персональный чёрный список;
  • чат-рулетка на основе соционического типа;
  • незаметно для пользователя при разрыве соединения делается переподключение;
  • предотвращение дублирования соединений;
  • флуд-контроль.

Что плохо:
  • нет приличного ORM, самопал;
  • обработчик сессий тоже самопальный;
  • нет тестов;
  • нет многопоточности.

Что ожидается доработать:
  • поэкспериментировать с NoSQL БД, например Redis;
  • отдельные комнаты-каналы;
  • загружаемые аватары;
  • настройка различных видов нотификаций;
  • установка личных заметок на пользователей;
  • индикация «сейчас печатает» в приватных каналах.


Какие выводы можно сделать по прошествии 2-х месяцев разработки проекта? У PHP всё ещё есть потенциал. По крайней мере начало работы с событийно-ориентированной парадигмой положено. Но увы, пока что язык пытается догнать, а не стать во главе движения. Если сравнить Ratchet и Tornado, то по возможностям они ещё не ровня. Будем надеяться, что развитие в этом направлении продолжится с положительным ускорением.

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

P.S.
Статья о сравнении производительности Node.js vs ReactPHP.
Пример прокси socket2http.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 19

    0
    Интересная статья. Но почему же вы так поллинг унижаете? Я согласен насчёт перспективности вебсокетов, но есть же ещё вполне неплохой и кросбраузерный long poll с тем же select/epoll на серверной стороне — не обязательно таймеры использовать.
      0
      Я интересовался long-polling. В какой-то момент была мысль для лучшей совместимости использовать его, но я не смог найти вменяемых инструментов, которые были бы удобнее, чем Ratchet. Всё же использование готовой обертки над libevent дает больше возможностей для написания сложного приложения.
      +7
      Что есть вообще асинхронность? Если по-простому, то это способность кода «распараллеливаться», выполнять несколько кусков кода независимо друг от друга.

      Не нужно путать. Асинхронный != параллельный. Классический JS может работать асинхронное, но не параллельно (всилу однопоточности VM). Полезное чтение: Как работают таймеры в JavaScript.

      Асинхронность — возможность непоследовательного выполнения кода. Параллельность — возможность выполнения одного и того же кода в одновременно.
        0
        Думаю вы правы, моя формулировка в строгом смысле слова не верна. Говоря о распараллеливании следовало бы подразумевать многопоточность. Хотя я не зря подчеркнутое слово заключил в кавычки. Спасибо за уточнение.
        0
        Один из минусов predis-async, это зависимость от «ext-phpiredis»: "*", который в свою очередь зависит от hiredis и эти зависимости ставятся через классику: make && make install;
        Особых трудностей при установке не возикает, но всетаки, хочетя автоматической уставновки, хотябы через тот-же apt-get.
        О пользователях винды я вообще молчу, что им будет стоить подянять у себя этот зоопарк, разве что в VirtualBox.
          +1
          Кстати интересная тема.
          Я не проверял насколько работоспособен Ratchet под Windows. Есть ли под ней libevent? Плюс конечно понадобится и расширение php для работы с ним. Есть правда режим работы через stream_select, который не использует никаких сторонних зависимостей, по крайней мере для разработки сойдёт.
          +1
          Недавно тоже написал статью о созданни чата на ReactPHP с Ratchet WebSocket:
          elfet.ru/create-chat-on-php/
          github.com/elfet/chat
            0
            Да ладно бросьте, вы свой чат по моему третий год все обещаете народу выпустить, на вашем же форуме уже люди смеются над вашими обещаниями.
            Писать можно долго и красиво, но реальный работающий продукт куда лучше многих букв. Сделайте наконец плагины и потом пилите дальше ваш идеальный продукт, ну смешно же, выпускать версию с плагинами как минимум два года.
              0
              Спасибо за комментарий =)) Да, пилю уже третий год, хотел слишком многое реализовать сразу, несколько раз переписывал заново, прокрастинировал, наступил на кучу грабель и т.п.
              Но все же я ещё раз пообещаю выпустить чат в ближайшие сроки! Отказался от всех запланированных фич, будет базовая функциональность.
            +1
            Зашел в чат и застрял там на час.
              +1
              А я для своего чата остановился на Node.js & Socket.io. Но и проект у меня занял пару дней.
              Конечно, скрещивание AngularJs, websockets & Nodejs вообще впечатляет. Немножко приходится повозиться с middleware для передачи сессии.
                0
                Поддерживаю. Подобный стек позволяет делать realtime-приложения, простор для деятельности огромный. Чат — лишь относительно простое частное применение.
                  0
                  Сейчас тоже делаем реалтайм-приложение с бэкендом на PHP и в поиске решения действовали, в общем, похожим образом. Изначально была идея сделать всё просто и изящно — PHP для обработки сообщений от клиента + Redis (в первую очередь из-за Publish/Subscribe из коробки) + Webdis в качестве транспорта сервер-клиент, но, увы, вебдис (пока еще?) не годится для продакшена и пришлось заменить его на Node.js+Sock.js.
                    0
                    Интересно, что вы скажите, когда попробуете meteorjs или derbyjs?
                  0
                  Вообще отличный проект, я бы поучаствовал даже, если лень-матушка не съест. А почему Вы решили не использовать PDO?
                    0
                    Спасибо :)
                    PDO используется. Другое дело, что на момент старта хотелось побыстрее всё запустить и были некоторые классы из собственного микрофреймворка под рукой. Из их числа в данный проект попала и обёртка для PDO и простейшая реализация DAO.
                    Но как я уже отмечал выше, по-хорошему либо надо смотреть в сторону Redis и исследовать область применимости, либо задействовать известный ORM, типа Doctrine, если оставаться на RDBMS
                      0
                      1. Пулл-реквесты принимаете?
                      2. Сайт упал, да?
                        0
                        1. Конечно!
                        2. Похоже, что с DigitalOcean что-то приключилось, уже бывало. По крайней мере шелл у меня не отвалился и нагрузки никакой особой не увидел.
                    0
                    Спасибо за интересный пост!
                    Теперь тоже возникло желание поближе познакомиться с WebSockets.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое