WebSocket chat on symfony2 in 100 lines

Hello Habr!
Recently I have developed a chat websocket for your service http://internetsms.org/chat.
In the implementation, I was faced with the fact that most Internet chats made with use of repetitive ajax requests that checks for new messages for a given period of time. This approach for me was unacceptable, because when the influx of users, the server load will increase exponentially. In fact, there are more interesting ways of implementation:
Long polling
The client sends to the server a "long" request, and if there are changes, the server sends a response. Thus, the number of queries is reduced. By the way, this technology is used in Gmail.
Web sockets
Html5 adds built-in ability to use a WebSocket connection. Paradigm request-response here is not used. Between the client and the server once installed. The server runs a single daemon that handles incoming connections. Thus, the load on the server virtually no even with a large number of users online.

backend


Now I will explain in detail how to use this chat. I used Ratchet library for working with sockets on the server. The database stores the entities current chats (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 in a separate entity Manager
the
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 incoming connections and forwarding messages between users happens in the classroom 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 has been used library to create a daemon commands. By the way, there described as start the demon using the standard Upstart. This allows you to start the process of chat and make sure he didn't 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;

DaemonCommand EndlessCommand class extends 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


Engine chat feature in the form of a finite state machine, represented by a set of States and transitions. This was done using the library Javascript Finite State Machine. In the event of any transition you can set the function handler. The handler can hang the business logic.

The States and transitions of a finite state machine

HTML
 <div id="chat_wrapper">
<div id="template_idle" class="template">
<div class="row text-center">
<div>
<h3>Welcome to our chat!</h3>
<p>to find the contact, click the button "Start chat" and wait until the system will automatically select a source</p>
<p>to find a new contact, click the "End conversation" and then hit "Start chat".</p>
<p>chat History is not saved. Have fun!</p>
</div>
<a class="btn btn-large btn-primary begin-chat">Start a chat</a>
</div>
</div>
<div id="template_wait" class="template">
<div class="row text-center">
<h3><i class="fa fa-spin fa-refresh"></i> Wait</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="Enter message (sent by 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>Send</button>
</div>
<div class="text-center">
<div class="show-chat"><a href="#" class="btn btn-danger close-chat">End of conversation</a></div>
<div class="show-closed">this Conversation is over. <a href="#" class="btn btn-primary begin-chat">Start over</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 = '***MESSAGE***';
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.as the ctrlkey property && 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) {

}
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('Connecting to server'); },
onwaiting: function(event, from, to) { waitController.show('waiting for a source'); },
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('Interlocutor found - communicate');
},
onclose: function (event, from, to) {
chatController.addSystemMessage('Chat closed');
}
}
});
}})
})(jQuery);



Result


Chat is working steadily for about two weeks. The demon consumes 50MB of memory and 0.2% of CPU.
People stay longer on the website, communicate and put huskies. Please chat!

Thank you for your attention!
Article based on information from habrahabr.ru

Популярные сообщения из этого блога

Approval of WSUS updates: import, export, copy

The Hilbert curve vs. Z-order

Configuring a C++ project in Eclipse for example SFML application