📜 ⬆️ ⬇️

WebSocket chat on symfony2 in 100 lines

Hi Habr!
Recently, I developed a chat on the web socket for my service http://internetsms.org/chat .
When implemented, I was faced with the fact that on the Internet most of the chats are made using repeated ajax requests that check for new messages over a specified period of time. This approach was unacceptable for me, because with the influx of users, the load on the server will increase exponentially. In fact, there are more interesting implementation options:
Long polling
The client sends a “long” request to the server, and if there are changes, the server sends a response. Thus, the number of requests is reduced. By the way, this technology is used in Gmail.
Web sockets
Html5 has a built-in ability to use WebSocket connections. The request-response paradigm is not used here at all. A communication channel is established once between the client and the server. There is one daemon on the server that handles incoming connections. Thus, there is practically no load on the server even with a large number of users online.

Server part


Now I will explain in detail how this chat works. I used Ratchet , a library that allows you to work with sockets on the server. The database stores the entities current chat (Chat) and users (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; } } 



Trivial operations with entities made in a separate manager
 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'); } } 


All processing of incoming connections and redirection of messages between users occurs in the class 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; } } } 


')
To start the server, a library was used to create daemon commands. By the way, it describes how to start the daemon using the standard Upstart. This allows you to start the chat process and make sure that it does not fall.

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(); } } 



Client part


The chat engine is designed as a finite state machine, represented by a set of states and transitions. The Javascript Finite State Machine library was used for this. You can set a handler function for an event of any transition. You can hang business logic on the handler.

States and state transitions


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); 



Result


Chat stably works for about two weeks. The daemon consumes 50MB of memory and 0.2% of the processor.
People stay longer on the site, chat and post likes. I invite you to chat !

Thanks for attention!

Source: https://habr.com/ru/post/205934/


All Articles