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

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

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


    Сейчас я подробно объясню, как работает этот чат. Я использовал Ratchet — библиотеку, позволяющую работать с сокетами на сервере. В базе данных хранятся сущности текущие чаты (Chat) и пользователи (ChatUser).
    Chat Entity
    <?php
    namespace ISMS\ChatBundle\Entity;
    
    use Doctrine\Common\Collections\ArrayCollection;
    use Doctrine\ORM\Mapping as ORM;
    
    /**
     * @ORM\Entity
     * @ORM\Table
     */
    class Chat
    {
        /**
         * @ORM\Id
         * @ORM\Column(type="bigint")
         * @ORM\GeneratedValue(strategy="AUTO")
         *
         * @var int
         */
        private $id;
    
        /**
         * @var bool
         *
         * @ORM\Column(type="boolean")
         */
        protected $isCompleted = false;
    
        /**
         * @ORM\OneToMany(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 ISMS\ChatBundle\Entity;
    
    use Doctrine\ORM\Mapping as ORM;
    
    /**
     * @ORM\Entity
     * @ORM\Table
     */
    class ChatUser
    {
        /**
         * @ORM\Id
         * @ORM\Column(type="bigint")
         * @ORM\GeneratedValue(strategy="AUTO")
         *
         * @var int
         */
        private $id;
    
        /**
         * @ORM\Column(type="integer", unique=true)
         *
         * @var int
         */
        private $rid;
    
        /**
         * @ORM\ManyToOne(targetEntity="Chat", inversedBy="users")
         * @ORM\JoinColumn(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: ISMS\ChatBundle\Manager\ChatManager
    
    services:
        isms_chat.manager:
            class: %isms_chat.manager.class%
            arguments: [ @doctrine.orm.entity_manager ]
    


    ChatManager
    <?php
    namespace ISMS\ChatBundle\Manager;
    
    use Doctrine\Common\Persistence\ObjectManager;
    use ISMS\ChatBundle\Entity\Chat;
    use ISMS\ChatBundle\Entity\ChatUser;
    
    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 \Doctrine\DBAL\Connection $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 ISMS\ChatBundle\Chat;
    
    use ISMS\ChatBundle\Manager\ChatManager;
    use Ratchet\ConnectionInterface;
    use Ratchet\MessageComponentInterface;
    use Ratchet\WebSocket\Version\RFC6455\Connection;
    
    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 ISMS\ChatBundle\Command;
    
    use ISMS\ChatBundle\Chat\Chat;
    use Ratchet\Http\HttpServer;
    use Ratchet\Server\IoServer;
    use Ratchet\WebSocket\WsServer;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\DependencyInjection\ContainerAwareInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    use Wrep\Daemonizable\Command\EndlessCommand;
    
    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. На событие любого перехода можно устанавливать функцию обработчик. На обработчик можно вешать бизнес логику.

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


    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% процессора.
    Люди дольше остаются на сайте, общаются и ставят лайки. Приглашаю и вас пообщаться!

    Спасибо за внимание!
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 10

      0
      После каждой третьей фразы вылетает — становится недоступной кнопка отправить и поле ввода.
      Сообщение уходило 10 секунд.

      >>Демон расходует 50МБ память и 0,2% процессора.
      Я можете результаты эксперимента потом выложить?
        0
        Для проекта используется Digital Ocean инстанс за $10.
        «Хабраэффект» дал до 40 соединений, вот график за час, нагрузка поднималась до 1,2%
          0
          Подскажи, что за софт (который на скриншоте) вы используете для мониторинга нагрузки?
      0
      Действительно изящно получилось.
      А сколько соединений держит такая реализация? Демон не сильно распухает при большом количестве подключений? (самое время помониторить)
      Сейчас, похоже, народ немного понабежал. Минут 10 назад сообщения отправлялись оперативнее, и соединение не рвалось.

      P.S.: если будете использовать EndlessContainerAwareCommand в DaemonCommand, то сэкономите еще десяток строк.
        0
        Спасибо, обновлю. Комментарием выше приложил график.
        0
        Для любопытства — сколько пользователей одновременно нормально держит в Ваших условиях чат на ajax-запросах?
          0
          К счастью сожалению, я не реализовывал такой чат, это же по сути DDOS сервера.
            +1
            Ну, закинуть абстрактную рекламу сервиса в абстрактный хабр — тот же самый натуральный DDOS ;)

            Продолжая вечер глупых вопросов — на мобильных устройствах как себя клиент ведет?
            Как я понял,
            if (websocket.readyState == 1) websocket.close();
            при кратковременном обрыве связи — финиш?
              0
              Зато весь хабр перезнакомил :)
              Не тестил такие обрывы связи. А как лучше поступать?

        Only users with full accounts can post comments. Log in, please.