Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

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

Anna | 31.05.2014 | нет комментариев

Незадолго я разработал чат на вебсокетах для своего обслуживания 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">
            <div>
                <div>
                    <h3>Благо пожаловать в наш чат!</h3>
                    <p>Дабы обнаружить собеседника, нажмите кнопку "Начать чат" и подождите, пока система механически подберет собеседника</p>
                    <p>Дабы обнаружить нового собеседника, нажмите кнопку "Завершить разговор" и вновь нажмите "Начать чат".</p>
                    <p>История чата не сохраняется. Развлекайтесь!</p>
                </div>
                <a>Начать чат</a>
            </div>
        </div>
        <div id="template_wait">
            <div>
                <h3><i></i> Подождите</h3>
                <span></span>
            </div>
        </div>
        <div id="template_chat">
            <div>
                <div id="message_box"></div>
            </div>
            <div>
                <form id="send-msg-form">
                    <div>
                        <textarea id="message" rows="2" placeholder="Введите сообщение (Отправка по Ctrl   Enter)" required="required"></textarea>
                        <button id="send-btn" type="submit"><span><i></i></span>Отправить</button>
                    </div>
                    <div>
                        <div><a href="#">Завершить разговор</a></div>
                        <div>Разговор завершен. <a href="#">Начать снова</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>' msg.from '</span> : <span>' text   '</span>'  
                            '<span>' d.toLocaleTimeString() '</span>'  
                            '</div>'
                    );

                    $(o.messageBox).scrollTop($(o.messageBox)[0].scrollHeight);
                },
                addSystemMessage: function(msg) {
                    $(o.messageBox).append('<div>' 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);

Итог

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

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

Источник: programmingmaster.ru

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB