WebSocket чат на symfony2 в 100 строк

в 13:37, , рубрики: php, symfony2, websockets, метки: , ,

Привет!
Недавно я разработал чат на вебсокетах для своего сервиса http://internetsms.org/chat.
При реализации, я столкнулся с тем, что в интернете большинство чатов сделаны с использованием повторяющихся ajax запросов, которые проверяют новые сообщения по заданному промежутку времени. Такой подход для меня был неприемлем, т.к при наплыве пользователей, нагрузка на сервер вырастет экспоненциально. На самом деле, есть более интересные варианты реализации:
Long polling
Клиент отправляет на сервер «долгий» запрос, и при наличии изменений, сервер отправляет ответ. Таким образом, число запросов снижается. Кстати, эта технология используется в Gmail.
Web sockets
В html5 появилась встроенная возможность использовать WebSocket соединения. Парадигма запрос-ответ здесь вообще не используется. Между клиентом и сервером один раз устанавливается канал связи. На сервере работает один демон, который обрабатывает входящие соединения. Таким образом, нагрузки на сервер практически нет даже при большом количестве пользователей онлайн.

Серверная часть

Сейчас я подробно объясню, как работает этот чат. Я использовал Ratchet — библиотеку, позволяющую работать с сокетами на сервере. В базе данных хранятся сущности текущие чаты (Chat) и пользователи (ChatUser).

Chat Entity
<?php
namespace ISMSChatBundleEntity;

use DoctrineCommonCollectionsArrayCollection;
use DoctrineORMMapping as ORM;

/**
 * @ORMEntity
 * @ORMTable
 */
class Chat
{
    /**
     * @ORMId
     * @ORMColumn(type="bigint")
     * @ORMGeneratedValue(strategy="AUTO")
     *
     * @var int
     */
    private $id;

    /**
     * @var bool
     *
     * @ORMColumn(type="boolean")
     */
    protected $isCompleted = false;

    /**
     * @ORMOneToMany(targetEntity="ChatUser", mappedBy="Chat")
     * @var ArrayCollection
     */
    private $users;

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->users = new ArrayCollection();
    }
    
    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Add users
     *
     * @param ChatUser $user
     * @return Chat
     */
    public function addUser(ChatUser $user)
    {
        $this->users[] = $user;

        return $this;
    }

    /**
     * Remove users
     *
     * @param ChatUser $user
     */
    public function removeUser(ChatUser $user)
    {
        $this->users->removeElement($user);
    }

    /**
     * Get users
     *
     * @return ArrayCollection|ChatUser[]
     */
    public function getUsers()
    {
        return $this->users;
    }

    /**
     * @param boolean $isCompleted
     */
    public function setIsCompleted($isCompleted)
    {
        $this->isCompleted = $isCompleted;
    }

    /**
     * @return boolean
     */
    public function getIsCompleted()
    {
        return $this->isCompleted;
    }
}

ChatUser Entity

<?php
namespace ISMSChatBundleEntity;

use DoctrineORMMapping as ORM;

/**
 * @ORMEntity
 * @ORMTable
 */
class ChatUser
{
    /**
     * @ORMId
     * @ORMColumn(type="bigint")
     * @ORMGeneratedValue(strategy="AUTO")
     *
     * @var int
     */
    private $id;

    /**
     * @ORMColumn(type="integer", unique=true)
     *
     * @var int
     */
    private $rid;

    /**
     * @ORMManyToOne(targetEntity="Chat", inversedBy="users")
     * @ORMJoinColumn(name="chat_id", referencedColumnName="id")
     * @var Chat
     */
    private $Chat;


    /**
     * Get id
     *
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set rid
     *
     * @param integer $rid
     * @return ChatUser
     */
    public function setRid($rid)
    {
        $this->rid = $rid;
    
        return $this;
    }

    /**
     * Get rid
     *
     * @return string 
     */
    public function getRid()
    {
        return $this->rid;
    }

    /**
     * Set Chat
     *
     * @param Chat $chat
     * @return ChatUser
     */
    public function setChat(Chat $chat = null)
    {
        $this->Chat = $chat;
        $chat->addUser($this);

        return $this;
    }

    /**
     * Get Chat
     *
     * @return Chat
     */
    public function getChat()
    {
        return $this->Chat;
    }
}

Тривиальные операции с сущностями вынесены в отдельный менеджер

parameters:
    isms_chat.manager.class: ISMSChatBundleManagerChatManager

services:
    isms_chat.manager:
        class: %isms_chat.manager.class%
        arguments: [ @doctrine.orm.entity_manager ]
ChatManager

<?php
namespace ISMSChatBundleManager;

use DoctrineCommonPersistenceObjectManager;
use ISMSChatBundleEntityChat;
use ISMSChatBundleEntityChatUser;

class ChatManager
{
    /** @var ObjectManager */
    private $em;

    public function __construct(ObjectManager $em)
    {
        $this->em = $em;
    }

    public function removeUserFromChat(ChatUser $user, Chat $chat)
    {
        if ($chat->getIsCompleted()) {
            $chat->removeUser($user);
            $chat->setIsCompleted(false);
        } else {
            $this->em->remove($chat);
        }
        $this->em->remove($user);
        $this->em->flush();
    }

    public function findOrCreateChatForUser($rid)
    {
        $chat_user = new ChatUser();
        $chat_user->setRid($rid);
        $chat = $this->getUncompletedChat();
        if ($chat) {
            $chat->setIsCompleted(true);
        } else {
            $chat = new Chat();
        }
        $chat_user->setChat($chat);
        $this->em->persist($chat);
        $this->em->persist($chat_user);
        $this->em->flush();
        return $chat;
    }

    public function getChatByUser($rid)
    {
        $chat_user = $this->getUserByRid($rid);
        return $chat_user ? $chat_user->getChat() : null;
    }

    public function getUserByRid($rid)
    {
        return $this->em->getRepository('ISMSChatBundle:ChatUser')->findOneBy(['rid' => $rid]);
    }

    public function getUncompletedChat()
    {
        return $this->em->getRepository('ISMSChatBundle:Chat')->findOneBy(['isCompleted' => false]);
    }

    public function truncateChats()
    {
        /** @var DoctrineDBALConnection $conn */
        $conn = $this->em->getConnection();
        $platform = $conn->getDatabasePlatform();
        $conn->query('SET FOREIGN_KEY_CHECKS=0');
        $conn->executeUpdate($platform->getTruncateTableSQL('chat_user'));
        $conn->executeUpdate($platform->getTruncateTableSQL('chat'));
        $conn->query('SET FOREIGN_KEY_CHECKS=1');
    }
} 

Вся обработка входящих соединений и перенаправление сообщений между пользователями происходит в классе Chat.

Chat
<?php
namespace ISMSChatBundleChat;

use ISMSChatBundleManagerChatManager;
use RatchetConnectionInterface;
use RatchetMessageComponentInterface;
use RatchetWebSocketVersionRFC6455Connection;

class Chat implements MessageComponentInterface
{
    /** @var ConnectionInterface[] */
    protected $clients = [];

    /** @var ChatManager */
    protected $chm;

    public function __construct(ChatManager $chm) {
        $this->chm = $chm;
        $this->chm->truncateChats();
    }

    /**
     * @param ConnectionInterface|Connection $conn
     * @return string
     */
    private function getRid(ConnectionInterface $conn)
    {
        return $conn->resourceId;
    }

    /**
     * @param ConnectionInterface|Connection $conn
     */
    function onOpen(ConnectionInterface $conn)
    {
        $this->clients[$this->getRid($conn)] = $conn;
    }

    function onClose(ConnectionInterface $conn)
    {
        $rid = array_search($conn, $this->clients);
        if ($user = $this->chm->getUserByRid($rid)) {
            $chat = $user->getChat();
            $this->chm->removeUserFromChat($user, $chat);
            foreach ($chat->getUsers() as $user) {
                $this->clients[$user->getRid()]->close();
            }
        }
        unset($this->clients[$rid]);
    }

    function onError(ConnectionInterface $conn, Exception $e)
    {
        $conn->close();
    }

    function onMessage(ConnectionInterface $from, $msg)
    {
        $msg = json_decode($msg, true);
        $rid = array_search($from, $this->clients);
        switch ($msg['type']) {
            case 'request':
                $chat = $this->chm->findOrCreateChatForUser($rid);
                if ($chat->getIsCompleted()) {
                    $msg = json_encode(['type' => 'response']);
                    foreach ($chat->getUsers() as $user) {
                        $conn = $this->clients[$user->getRid()];
                        $conn->send($msg);
                    }
                }
                break;
            case 'message':
                if ($chat = $this->chm->getChatByUser($rid)) {
                    foreach ($chat->getUsers() as $user) {
                        $conn = $this->clients[$user->getRid()];
                        $msg['from'] = $conn === $from ? 'me' : 'guest';
                        $conn->send(json_encode($msg));
                    }
                }
                break;
        }
    }
}

Для запуска сервера была использована библиотека для создания демон-команд. Кстати, там описывается как запустить демон, используя стандартный Upstart. Это позволяет запускать процесс чата и следить, чтобы он не упал.

DaemonCommand

<?php
namespace ISMSChatBundleCommand;

use ISMSChatBundleChatChat;
use RatchetHttpHttpServer;
use RatchetServerIoServer;
use RatchetWebSocketWsServer;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleOutputOutputInterface;
use SymfonyComponentDependencyInjectionContainerAwareInterface;
use SymfonyComponentDependencyInjectionContainerInterface;
use WrepDaemonizableCommandEndlessCommand;

class DaemonCommand extends EndlessCommand implements ContainerAwareInterface
{
    /** @var ContainerInterface */
    private $container;

    public function setContainer(ContainerInterface $container = null)
    {
        $this->container = $container;
    }

    protected function configure()
    {
        $this->setName('isms:chat:daemon');
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $chm = $this->container->get('isms_chat.manager');
        $server = IoServer::factory(
            new HttpServer(
                new WsServer(
                    new Chat($chm)
                )
            ),
            8080
        );
        $server->run();
    }
}

Клиентская часть

Движок чата оформлен в виде конечного автомата, представленным набором состояний и переходов. Для этого была использована библиотека Javascript Finite State Machine. На событие любого перехода можно устанавливать функцию обработчик. На обработчик можно вешать бизнес логику.

Состояния и переходы конечного автомата
WebSocket чат на symfony2 в 100 строк

HTML

    <div id="chat_wrapper">
        <div id="template_idle" class="template">
            <div class="row text-center">
                <div>
                    <h3>Добро пожаловать в наш чат!</h3>
                    <p>Чтобы найти собеседника, нажмите кнопку "Начать чат" и подождите, пока система автоматически подберет собеседника</p>
                    <p>Чтобы найти нового собеседника, нажмите кнопку "Закончить разговор" и снова нажмите "Начать чат".</p>
                    <p>История чата не сохраняется. Развлекайтесь!</p>
                </div>
                <a class="btn btn-large btn-primary begin-chat">Начать чат</a>
            </div>
        </div>
        <div id="template_wait" class="template">
            <div class="row text-center">
                <h3><i class="fa fa-spin fa-refresh"></i> Подождите</h3>
                <span class="state"></span>
            </div>
        </div>
        <div id="template_chat" class="template">
            <div class="row">
                <div class="message_box" id="message_box"></div>
            </div>
            <div class="row well">
                <form id="send-msg-form">
                    <div class="input-append">
                        <textarea id="message" rows="2" placeholder="Введите сообщение (Отправка по Ctrl + Enter)" required="required" class="span6"></textarea>
                        <button id="send-btn" type="submit" class="btn btn-primary btn-large has-spinner"><span class="spinner"><i class="fa fa-spin fa-refresh"></i></span>Отправить</button>
                    </div>
                    <div class="text-center">
                        <div class="show-chat"><a href="#" class="btn btn-danger close-chat">Закончить разговор</a></div>
                        <div class="show-closed">Разговор закончен. <a href="#" class="btn btn-primary begin-chat">Начать заново</a></div>
                    </div>
                </form>
            </div>
        </div>
    </div>
    <script type="text/javascript" src="{{ asset('bundles/ismschat/js/chat-widget.js') }}"></script>
    <script type="text/javascript">
        $(document).ready(function(){
            $('#chat_wrapper').chatWidget();
        });
    </script>

chat-widget.js
(function($) {
    $.fn.extend({chatWidget: function(options){
        var o = jQuery.extend({
            wsUri: 'ws://'+location.host+':8080',
            tmplClass: '.template',
            tmplIdle: '#template_idle',
            tmplWait: '#template_wait',
            tmplChat: '#template_chat',
            btnBeginChat: '.begin-chat',
            labelWaitState: '.state',
            messageBox: '#message_box',
            formSend: '#send-msg-form',
            textMessage: '#message',
            btnCloseChat: '.close-chat'
        },options);

        var websocket, fsm;

        var windowNotifier = function(){
            var
                window_active = true,
                new_message = false;

            $(window).blur(function(){
                window_active = false;
            });
            $(window).focus(function(){
                window_active = true;
                new_message = false;
            });

            var original = document.title;
            window.setInterval(function() {
                if (new_message && window_active == false) {
                    document.title = '***СООБЩЕНИЕ***';
                    setTimeout(function(){
                        document.title = original;
                    }, 750);
                }
            }, 1500);

            return {
                setNewMessage: function() {
                    new_message = true;
                }
            };
        } ();

        var initSocket = function() {
            websocket = new WebSocket(o.wsUri);
            websocket.onopen = function(e) {
                fsm.request();
            };
            websocket.onclose 	= function(e){
                fsm.close();
            };
            websocket.onerror	= function(e){
                console.log(e);
                if (websocket.readyState == 1) {
                    websocket.close();
                }
            };
            websocket.onmessage = function(e) {
                var msg = JSON.parse(e.data);
                switch (msg.type) {
                    case 'response':
                        fsm.response();
                        windowNotifier.setNewMessage();
                        break;
                    case 'message':
                        chatController.addMessage(msg);
                        if (msg.from == 'me') {
                            chatController.unspinChat();
                        } else {
                            windowNotifier.setNewMessage();
                        }
                        $(o.textMessage).focus();
                        break;
                }
            }
        };

        var setView = function(tmpl) {
            $(o.tmplClass).removeClass('active');
            $(tmpl).addClass('active');
        };

        var idleController = function() {
            $(o.btnBeginChat).click(function() {
                fsm.open();
            });

            return {
                show: function() {
                    setView(o.tmplIdle);
                }
            };
        } ();

        var waitController = function() {
            return {
                show: function(label) {
                    $(o.labelWaitState).text(label);
                    setView(o.tmplWait);
                }
            };
        } ();

        var chatController = function() {
            $(o.textMessage).keydown(function (e) {
                if (e.ctrlKey && e.keyCode == 13) {
                    $(o.formSend).trigger('submit');
                }
            });

            $(document).on('submit', o.formSend, function(e) {
                e.preventDefault();
                var text = $(o.textMessage).val();
                text = $.trim(text);
                if (!text) {
                    return;
                }
                var msg = {
                    type: 'message',
                    message: text
                };
                websocket.send(JSON.stringify(msg));
                $(o.textMessage).val('');
                chatController.spinChat();
            });

            $(o.btnCloseChat).click(function(e) {
                websocket.close();
            });

            var htmlForTextWithEmbeddedNewlines = function(text) {
                var htmls = [];
                var lines = text.split(/n/);
                var tmpDiv = jQuery(document.createElement('div'));
                for (var i = 0 ; i < lines.length ; i++) {
                    htmls.push(tmpDiv.text(lines[i]).html());
                }
                return htmls.join("<br>");
            };

            return {
                clear: function() {
                    $(o.messageBox).empty();
                },
                lockChat: function() {
                    $(o.formSend).find(':input').attr('disabled', 'disabled');
                },
                unlockChat: function() {
                    $(o.formSend).find(':input').removeAttr('disabled');
                },
                spinChat: function() {
                    chatController.lockChat();
                    $(o.formSend).find('.btn').addClass('active');
                },
                unspinChat: function() {
                    $(o.formSend).find('.btn').removeClass('active');
                    chatController.unlockChat();
                },
                showChat: function() {
                    chatController.unlockChat();
                    $('.show-closed').hide();
                    $('.show-chat').show();
                    setView(o.tmplChat);
                },
                showClosed: function() {
                    chatController.lockChat();
                    $('.show-chat').hide();
                    $('.show-closed').show();
                    setView(o.tmplChat);
                },
                addMessage: function(msg) {
                    var d = new Date();
                    var text = htmlForTextWithEmbeddedNewlines(msg.message);
                    $(o.messageBox).append(
                        '<div>' +
                            '<span class="user_name">'+msg.from+'</span> : <span class="user_message">'+text + '</span>' +
                            '<span class="pull-right">'+d.toLocaleTimeString()+'</span>' +
                            '</div>'
                    );

                    $(o.messageBox).scrollTop($(o.messageBox)[0].scrollHeight);
                },
                addSystemMessage: function(msg) {
                    $(o.messageBox).append('<div class="system_msg">'+msg+'</div>');

                }
            };
        } ();

        fsm = StateMachine.create({
            initial: 'idle',
            events: [
                { name: 'open',  from: ['idle', 'closed'],  to: 'connecting' },
                { name: 'request',  from: 'connecting',  to: 'waiting' },
                { name: 'response',  from: 'waiting',  to: 'chat' },
                { name: 'close',  from: ['connecting', 'waiting'],  to: 'idle' },
                { name: 'close',  from: 'chat',  to: 'closed' }
            ],
            callbacks: {
                onidle: function(event, from, to) { idleController.show(); },
                onconnecting: function(event, from, to) { waitController.show('Подключение к серверу'); },
                onwaiting: function(event, from, to) { waitController.show('Ожидание собеседника'); },
                onchat: function(event, from, to) { chatController.showChat(); },
                onclosed: function(event, from, to) { chatController.showClosed(); },
                onopen:  function(event, from, to) { initSocket(); },
                onrequest: function (event, from, to) {
                    var msg = {
                        type: 'request'
                    };
                    websocket.send(JSON.stringify(msg));
                },
                onresponse: function (event, from, to) {
                    chatController.clear();
                    chatController.addSystemMessage('Собеседник найден - общайтесь');
                },
                onclose: function (event, from, to) {
                    chatController.addSystemMessage('Чат закрыт');
                }
            }
        });
    }})
})(jQuery);

Результат

Чат стабильно работает около двух недель. Демон расходует 50МБ память и 0,2% процессора.
Люди дольше остаются на сайте, общаются и ставят лайки. Приглашаю и вас пообщаться!

Спасибо за внимание!

Автор: karser

Источник


  1. Никита:

    Здравствуйте, прочитал вашу статью. Все реализовал, однако не могу понять как запустить демона… У меня WAMP Server. На клиентской части пишет, что чат закрыт… Как запустить сокет сервер?

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


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