Привет, Хабр!
Недавно передо мной встала задача создания realtime чата для уже действующего сайта на Symfony 2.8

От LongPolling и SSE я сразу отказался по причинам, что они работают на уровне HTTP, а realtime с HTTP Symfony мне показался не лучшей затеей, поэтому смотреть имело смысл только в сторону протокола WebSocket. Прежде чем начать писать велосипед, я начал искать уже готовые пакеты для любимого фреймворка, и в конце поиска, который оказался достаточно коротким, я получил не так уж и много полезных библиотек.
Единственная, которую имеет смысл опубликовать и о которой я и буду рассказывать — GosWebSocketBundle


Установка:
$ composer require composer require gos/web-socket-bundle


public function registerBundles()
{
    $bundles = array(
        // ...
        new Gos\Bundle\WebSocketBundle\GosWebSocketBundle(),
        new Gos\Bundle\PubSubRouterBundle\GosPubSubRouterBundle(),
    );
}


Давайте потихоньку начинать.
Рассмотрим стандартную конфигурацию к бандлу:
# Web Socket Configuration
gos_web_socket:
    server:
        port: 3000        #The port the socket server will listen on
        host: 127.0.0.1   #The host ip to bind to


Думаю тут и без объяснений все понятно. После того как мы сконфигурировали все, что нужно для первого запуска имеет смысл запустить сервер и увидеть успешное уведомление о том, что наш WS сервер работает и доступен по такому-то адресу.
$ app/console gos:websocket:server

Супер! Теперь мы можем подключиться к нашему серверу. Но тут есть нюанс, из документации нам предложено 2 варианта подключения JS библиотек для работы с бандлом.

1 cпособ:
Вставить следующий код перед тегом body:
{{ ws_client() }} 

Но тут оказался один минус, данный способ не работает в Symfony 2.8, т.к. AsseticBundle был уже удален в этой версии.
2 способ:
Просто подключить соответстующие библиотеки:
 <script type="text/javascript" src="{{ asset('bundles/goswebsocket/js/gos_web_socket_client.js') }}"></script>
 <script type="text/javascript" src="{{ asset('bundles/goswebsocket/js/vendor/autobahn.min.js') }}"></script>

Да, бандл, как вы можете видеть, использует autobahn.js, подробнее о нем вы можете прочитать на официальном сайте

Итак! Необходимые библиотеки мы подключили, теперь давайте законектимся к нашему серверу:
var webSocket = WS.connect("ws://127.0.0.1:3000");

webSocket.on("socket/connect", function(session){
    //session is an Autobahn JS WAMP session.
    console.log("Successfully Connected!");
});

webSocket.on("socket/disconnect", function(error){
    //error provides us with some insight into the disconnection: error.reason and error.code
    console.log("Disconnected for " + error.reason + " with code " + error.code);
});


Нам также по-умолчанию доступен такой способ подключения к серверу:
var _WS_URI = "ws://{{ gos_web_socket_server_host }}:{{ gos_web_socket_server_port }}";
var myWs = WS.connect(_WS_URI);

Смотрится куда более правильнее, не так ли?

Естественно, мы так же можем получить данные и из PHP:
//Host
$container->getParameter('web_socket_server.host');
//Port
$container->getParameter('web_socket_server.port');


Далее я буду реализовывать все вещи, связанные с Symfony, в AppBundle, чтобы вы спокойно смогли скопировать безо всяких затруднений и исправлений имен бандла.

Итак, приступим.
Нам нужно создать сервис для обработки наших первых ws соединений:
namespace AppBundle\Topic;

use Gos\Bundle\WebSocketBundle\Topic\TopicInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\Topic;
use Gos\Bundle\WebSocketBundle\Router\WampRequest;

class ChatTopic implements TopicInterface
{
    
    public function onSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request)
    {
        //this will broadcast the message to ALL subscribers of this topic.
        $topic->broadcast(['msg' => $connection->resourceId . " has joined " . $topic->getId()]);
    }

    public function onUnSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request)
    {
        //this will broadcast the message to ALL subscribers of this topic.
        $topic->broadcast(['msg' => $connection->resourceId . " has left " . $topic->getId()]);
    }

    public function onPublish(ConnectionInterface $connection, Topic $topic, WampRequest $request, $event, array $exclude, array $eligible)
    {
       $topic->broadcast([
            'msg' => $event,
        ]);
    }

    public function getName()
    {
        return 'app.topic.chat';
    }
}


Далее нам необходимо «пометить» данный класс как топик для нашего приложения, сделать это можно двумя способами:
Способ первый:
1.Просто создаем сервис
services:
    app.topic.chat:
        class: App\Topic\ChatTopic


2.Добавляем его в основную конфигурацию бандла
gos_web_socket:
    topics:
        - @app.topic.chat


Способ второй:
1.Определим класс сервис и добавим ему тег
services:
    app.topic.chat:
        class: App\Topic\ChatTopic
        tags:
            - { name: gos_web_socket.topic }


Сам класс представляет из себя что-то вроде EventListener-а, что очень удобно для того чтобы отлавливать действия пользователей.
Я думаю тут понятно, что каждый метод срабатывает когда:
onSubscribe — пользователь «подписался на данный канал»
onUnSubscribe — пользователь отписался от данного канала
onPublish — пользователь нам что-то отправил

Далее у нас еще есть функция getName, она отвечает за идентификацию данного топика\канала класса-сервиса, для того чтобы после Compiler смог его зарегистрировать и чтобы уже далее иметь возможность идентифицировать, какой конкретно класс подключать при том или ином запросе от клиента.
Сейчас вы поймете как это происходит.

Далее нам необходимо создать routing.yml файл для наших запросов на уровне ws:
#AppBundle/Resources/config/pubsub/routing.yml
app_topic_chat:
    channel: app/chat/{room}/{user_id}
    handler:
        callback: 'app.topic.chat' #Относится к getName, а не к имени сервиса
    requirements:
        room:
            pattern: "[a-z]+" #accept all valid regex, don't put delimiters !
        user_id:
            pattern: "\d+"


Теперь самое сладкое! Давайте приступать!
webSocket.on("socket/connect", function(session){

    session.subscribe("app/chat/habrchat/2", function(uri, payload){
        console.log("Received message", payload.msg);
    });

    session.publish("app/chat/habrchat/2", "Привет, я пришел от клиента!!!");
})


Здесь мы подписываемся на топик\канал\сервис, который недавно создали и отправляем туда сообщение «Привет, я пришел от клиента!!!», при этом мы задаем: room = habrachat, user_id = 1
Даже сейчас мы можем запустить наш пример и увидеть в консоли:
image

Т.е. что мы видим?! Сначала отработал метод onSubscribe, а после — onPublish, подредактируем их слегка:
/**
     * This will receive any Subscription requests for this topic.
     *
     * @param ConnectionInterface $connection
     * @param Topic $topic
     * @param WampRequest $request
     * @return void
     */
    public function onSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request)
    {
        $room = $request->getAttributes()->get('room');
        $userId = $request->getAttributes()->get('user_id');

        //this will broadcast the message to ALL subscribers of this topic.
        $topic->broadcast(['msg' => 'Новый пользователь зашел в комнату ' . $room . ' в личку к пользователю ' . $userId]);
    }

    /**
     * This will receive any UnSubscription requests for this topic.
     *
     * @param ConnectionInterface $connection
     * @param Topic $topic
     * @param WampRequest $request
     * @return void
     */
    public function onUnSubscribe(ConnectionInterface $connection, Topic $topic, WampRequest $request)
    {
        $room = $request->getAttributes()->get('room');
        $userId = $request->getAttributes()->get('user_id');
        //this will broadcast the message to ALL subscribers of this topic.
        $topic->broadcast(['msg' => 'Новый пользователь вышел из комнаты ' . $room . ' лички с пользователем ' . $userId]);
    }


    /**
     * This will receive any Publish requests for this topic.
     *
     * @param ConnectionInterface $connection
     * @param Topic $topic
     * @param WampRequest $request
     * @param $event
     * @param array $exclude
     * @param array $eligible
     * @return mixed|void
     */
    public function onPublish(ConnectionInterface $connection, Topic $topic, WampRequest $request, $event, array $exclude, array $eligible)
    {

        $room = $request->getAttributes()->get('room');
        $userId = $request->getAttributes()->get('user_id');

        $topic->broadcast([
            'msg' => 'В комнату ' . $room . 'пользователю ' . $userId . ' поступило сообщение: ' . $event,
        ]);
    }


Далее перезапускаем наш WS-сервер и видим:
image

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

Для продакшена нам остается только установить supervisor:
1.Устанавливаем supervisor:
$ apt-get install supervisor

2.Конфигурация:
Создаем файл /etc/supervisor/conf.d/websocket.conf:
command: /usr/bin/php /var/www/html/app/console gos:websocket:server 
autorestart: true
autostart:true
stderr_logfile=/var/log/websocket.err.log
stdout_logfile=/var/log/websocket.out.log

Теперь наш сервер будет автоматически перезапускаться в случае креша и запускаться при старте системы
3.Управление процессом:
$ sudo supervisorctl start websocket
$ sudo supervisorctl restart websocket
$ sudo supervisorctl stop websocket
Поделиться с друзьями
-->

Комментарии (8)


  1. Evgeny42
    01.06.2016 22:21

    Вчера как раз занимался похожими вещами. Использовал Ratchet вместе с phalcon, без проблем подружились. Единственное над чем пришлось повозиться это сессии. К которым phalcon не дает обращаться по id, пришлось самому делать запрос в базу сессий и парсить данные, которые там хранятся в странном формате.

    Посмотрел, оказалось GosWebSocketBundle это тот же Ratchet просто обернутый в бандл.


    1. php_freelancer
      01.06.2016 22:33

      Да, совершенно верно, это тот же Ratchet.

      Но тут с сессиями надо будет немного меньше возиться, в принципе там всё описано в документации, но я долго с этим разбирался и все-таки немного подводных камней да нащупал…
      Даже решил отдельную статью под это дело выпустить скоро, она как раз готовится :)
      Так вообще благо можно использовать общую конфигурацию (файрволы), что для http, что для ws с данным бандлом.
      А учитывая что можно запустить вебсокет сервер из под любой (кастомной, под ws например) среды, как и любую команду в SF, то сконфигурировать всё гибко в приложении практически не составляет особого труда. Обожаю Симфони.
      Но это всё уже в следующей статье.


      1. Evgeny42
        01.06.2016 22:41

        Насколько я знаю Ratchet из коробки поддерживает сессии Symfony.


  1. summerwind
    02.06.2016 01:34

    От LongPolling и SSE я сразу отказался по причинам, что они работают на уровне HTTP, а realtime с HTTP Symfony мне показался не лучшей затеей

    Могли бы вы пояснить, пожалуйста, чем вы считаете SSE хуже веб-сокетов?


    1. IncorrecTSW
      02.06.2016 11:20

      Сдается автор слабо представляет с чем едят SSE, раз аргументирует тем что «на уровне HTTP». А вообще SSE не дружит с IE от слова совсем.


      1. summerwind
        02.06.2016 14:07

        А вообще SSE не дружит с IE от слова совсем.

        Ну и что? Для убогих браузеров есть полифилл.


  1. snnwolf
    05.06.2016 16:54

    Мы на пытались пользовать сей бандл. Но решили от него отказаться в пользу чистого Ratchet'a, ибо сообщения тупили безбожно (посчитали, что виновата жирность symfony и убогость php [проект довольно большой], ну возможно ещё кривые руки были тому виной).


  1. BoShurik
    06.06.2016 19:36

    С помощью этого бандла можно реализовать оповещения, генерируемые, к примеру, по крону? (с ходу в документации не нашел)
    Нечто вроде этого