Как я интегрировал WebSockets в существующую систему на PHP

в 15:30, , рубрики: async, cleverstyle, cmf, cms, node.js, php, ratchet, React, websockets, Веб-разработка, метки:

Статья будет о том, как нехарактерная для PHP вещь вроде веб-сокетов может быть интегрирована в существую систему на примере CleverStyle CMS, и какие нюансы при этом могут возникнуть.

Библиотеки

Написать сервер и клиент для веб-сокетов весьма сложно, к счастью есть практически безальтернативная библиотека Ratchet, которая предоставляет сервер для веб-сокетов. Под капотом она использует несколько частей ReactPHP и Guzzle (зависит так же от Symfony компонентов, но в данном случае они оказались совершенно лишними). Так же будем использовать Pawl от автора Ratchet, это клиент для веб-сокетов.

Архитектура

Так как CleverStyle CMS может работать с несколькими серверами, то было решено поддерживать эту функциональность и тут. Для этого создается пул серверов. Он являет собой MEMORY табличку в MySQL с одной колонкой, публичным адресом для подключения вида wss://web.site/WebSockets (хранить в MySQL или ещё где-то не принципиально, главное чтобы все сервера имели доступ к одной и той же информации). Каждый запускаемый сервер добавляет себя в пул, и если он там не один — подключается к первому как мастеру (если один — сам становится мастером). Если мастер не отвечает — он удаляется из пула и следующий становится мастером, все подключатся к нему. Таким образом обеспечивается устойчивость от падений и полная децентрализация.

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

Отправка и прием сообщений

Формат сообщений был выбран простой:

{
	"action"  : "some/action",
	"details" : []
}

Действие это просто строка, в качестве деталей может быть что-то скалярное, тогда преобразуется в массив с одним элементом.

Формат общий для отправки с клиента на сервер, и с сервера на клиент. Единственное отличие — действие для клиента может оканчиваться суффиксом :error, для удобства на клиенте можно указать два коллбека — success и error.

На клиенте прием и отправка сообщений производится пачкой методов объекта cs.WebSockets (который устанавливает и поддерживает соединение с сервером автоматически, также сам производит аутентификацию с помощью текущей сессии):

  • on(action, callback, error)
  • off(action, callback, error)
  • once(action, callback, error)
  • send(action, details)

Это на столько просто, что проще уже, наверное, некуда. Так как переданные детали являются массивом — каждый элемент будет передан как отдельный аргумент.

Всё немного сложнее на сервере. Отправка сообщения производится методом csmodulesWebSockets::send_to_clients($action, $details, $send_to, $target) (отправлять можно не только из-под сервера, но и на рядовых страницах, в этом случае будет установлено соединение с одним из серверов, и сообщение будет передано во внутреннем формате, после чего дойдет до клиента).

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

Для приема используется стандартная система событий, нужно подписаться на событие WebSockets/{action} (подписка совершается по возникновению события WebSockets/register_action), где {action} — это то, что получено от клиента, так же в событие передается текущий пользователь (отправитель), его сессия и язык.

Пример hello-сервиса:

<?php
use
	csEvent,
	csmodulesWebSocketsServer;
// Register actions
Event::instance()->on('WebSockets/register_action', function () {
	// If `hello` action from user
	Event::instance()->on('WebSockets/hello', function ($data) {
		$Server = Server::instance();
		// Send `hello` action back to the same user with the same content
		if ($data['details']) {
			$Server->send_to_clients(
				'hello',
				$data['details'],
				Server::SEND_TO_SPECIFIC_USERS,
				$data['user']
			);
		} else {
			$Server->send_to_clients(
				'hello:error',
				$Server->compose_error(
					400,
					'No hello message:('
				),
				Server::SEND_TO_SPECIFIC_USERS,
				$data['user']
			);
		}
	});
});
?>

// Since everything is asynchronous - lets add handler first
cs.WebSockets.once('hello', function (message) {
	alert(message);
});
// Now send request to server
cs.WebSockets.send('hello', 'Hello, world!');

Все очень просто.

Запуск сервера

Вот тут уже немного сложнее. Было решено поддерживать запуск с веб-интерфейса (даже есть кнопочка в админке), а так же из командной строки. При чем если выполнение консольных команд в PHP доступно, то веб-версия всё равно под капотом запустит консольный вариант сервера. Сервер слушает на указанном в настройках порту на 0.0.0.0 или 127.0.0.1 на выбор (потом Nginx, или что стоит вместо него, на этот порт отправляет все соединения с wss://web.site/WebSockets, то есть для пользователя используется тот же 80 или 443 порт, другие порты могут быть заблокированы во многих ситуациях подобных публичному Wi-Fi).

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

Движок устроен так, что запускается ядро, потом выполняется определённых модуль, соответствующий странице, потом делается вывод. Так вот сервер веб-сокетов — обычный модуль, вот только до вывода дело не доходит, event-loop запускается и слушает соединения.

Немного подводных камней

  1. Event-loop может быть только один. В связи с этим и клиент и сервер (так как оба могут и отправлять и принимать сообщения при общении мастер/рядовой сервер) используют один и тот же event-loop
  2. Все подготовительные действия нужно сделать до запуска сервера, после старта event-loop весь линейный код блокируется до остановки (пересекается с первым пунктом, но всё же)
  3. Нужно беречь память; если есть какие-то кэши в объектах — их нужно отключить, иначе через несколько дней процесс может скушать слишком много памяти, ситуацию можно проверить нагрузив сервер с помощью, например, Siege
  4. Нужно беречь время; так как вы скорее всего не используете для всего-всего неблокирующие альтернативные варианты встроенных функций, то следующий клиент не будет обслужен до выполнении блокирующей операции, так что минимизируйте работу чего либо под сервером
  5. Подготовьте ваш код для длительного выполнения (в моем случае в некоторых местах использовалась константа с временем запуска скрипта, которая переставала быть актуальной в случае долгоиграющего процесса)

Вот и всё

Хотя веб-сокеты и подобные асинхронные штуки не являются «родными» для PHP и при их упоминании часто вспоминают и Node.js, а так же то, как его совместить с PHP, последний сам может легко работать с той же парадигмой и не умирать после каждого запроса (при определённых навыках можно утилизировать React для создания HTTP-сервера на чистом PHP).

Попробовать

Если есть желание поиграть — Docker контейнер ждет вас:

docker run --rm -p 8888:8888 -v /some_dir:/web nazarpc/cleverstyle-cms

Потом заходите по адресу localhost:8888 в браузере, включаете модуль веб-сокетов, стартуете сервер. В папке /some_dir добавляете любые модули, экспериментируете, пробуете, всё сразу попадает в демку. После остановки останется только удалить /some_dir (--rm ключ удалит сам контейнер автоматически).

Исходный код

Автор: nazarpc

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js