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

от автора

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

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

Предыстория

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

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

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

Есть правда и обратная сторона медали в избранном подходе:

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

Если на счёт первого я не переживал особо, поскольку моей целевой аудиторией была молодёжь с современными компьютерами и мобильными гаджетами, в которых поддержка WebSockets давно реализована, то как раз на счёт второго возникли в дальнейшем затруднения, о которых я поведаю далее.
Использование же демона имеет ряд особенностей:

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

Те, кто никогда не слышал о том, что такое «резидентная программа», а писал лишь код для 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, где он декомпозирован на контролеры.

Возникшие проблемы и способы решения
  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); 	}  	$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, то по возможностям они ещё не ровня. Будем надеяться, что развитие в этом направлении продолжится с положительным ускорением.

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

ссылка на оригинал статьи http://habrahabr.ru/post/218751/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *