СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

| сохранено

H Symfony Realtime: WebSockets в черновиках

Привет, Хабр!
Недавно передо мной встала задача создания 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

комментарии (9)

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

Посмотрел, оказалось GosWebSocketBundle это тот же Ratchet просто обернутый в бандл.
0
php_freelancer ,   * (был изменён)
Да, совершенно верно, это тот же Ratchet.

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

Могли бы вы пояснить, пожалуйста, чем вы считаете SSE хуже веб-сокетов?
0
IncorrecTSW ,  
Сдается автор слабо представляет с чем едят SSE, раз аргументирует тем что «на уровне HTTP». А вообще SSE не дружит с IE от слова совсем.
0
summerwind ,  
А вообще SSE не дружит с IE от слова совсем.

Ну и что? Для убогих браузеров есть полифилл.
0
snnwolf ,  
Мы на пытались пользовать сей бандл. Но решили от него отказаться в пользу чистого Ratchet'a, ибо сообщения тупили безбожно (посчитали, что виновата жирность symfony и убогость php [проект довольно большой], ну возможно ещё кривые руки были тому виной).
0
BoShurik ,  
С помощью этого бандла можно реализовать оповещения, генерируемые, к примеру, по крону? (с ходу в документации не нашел)
Нечто вроде этого
0
Slavenin999 ,  
А еще есть такой бандл. В ближайшее время напишу статью о его интеграции в рамках серии про блог на симфони. Опишу как сделать онлайн добавление комментариев.