Pull to refresh

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

Reading time 8 min
Views 37K
Меня очень радует, как бурно развивается 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.
Tags:
Hubs:
+19
Comments 19
Comments Comments 19

Articles