Привет Хабр!
Недавно я разработал чат на вебсокетах для своего сервиса http://internetsms.org/chat.
При реализации, я столкнулся с тем, что в интернете большинство чатов сделаны с использованием повторяющихся ajax запросов, которые проверяют новые сообщения по заданному промежутку времени. Такой подход для меня был неприемлем, т.к при наплыве пользователей, нагрузка на сервер вырастет экспоненциально. На самом деле, есть более интересные варианты реализации:
Long polling
Клиент отправляет на сервер «долгий» запрос, и при наличии изменений, сервер отправляет ответ. Таким образом, число запросов снижается. Кстати, эта технология используется в Gmail.
Web sockets
В html5 появилась встроенная возможность использовать WebSocket соединения. Парадигма запрос-ответ здесь вообще не используется. Между клиентом и сервером один раз устанавливается канал связи. На сервере работает один демон, который обрабатывает входящие соединения. Таким образом, нагрузки на сервер практически нет даже при большом количестве пользователей онлайн.
Сейчас я подробно объясню, как работает этот чат. Я использовал Ratchet — библиотеку, позволяющую работать с сокетами на сервере. В базе данных хранятся сущности текущие чаты (Chat) и пользователи (ChatUser).
Тривиальные операции с сущностями вынесены в отдельный менеджер
Вся обработка входящих соединений и перенаправление сообщений между пользователями происходит в классе Chat.
Для запуска сервера была использована библиотека для создания демон-команд. Кстати, там описывается как запустить демон, используя стандартный Upstart. Это позволяет запускать процесс чата и следить, чтобы он не упал.
Движок чата оформлен в виде конечного автомата, представленным набором состояний и переходов. Для этого была использована библиотека Javascript Finite State Machine. На событие любого перехода можно устанавливать функцию обработчик. На обработчик можно вешать бизнес логику.
Состояния и переходы конечного автомата
![](https://habrastorage.org/r/w1560/getpro/habr/post_images/db5/8c4/e85/db58c4e853060a318d8ef14012b8802d.png)
Чат стабильно работает около двух недель. Демон расходует 50МБ память и 0,2% процессора.
Люди дольше остаются на сайте, общаются и ставят лайки. Приглашаю и вас пообщаться!
Спасибо за внимание!
Недавно я разработал чат на вебсокетах для своего сервиса 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. На событие любого перехода можно устанавливать функцию обработчик. На обработчик можно вешать бизнес логику.
Состояния и переходы конечного автомата
![](https://habrastorage.org/getpro/habr/post_images/db5/8c4/e85/db58c4e853060a318d8ef14012b8802d.png)
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% процессора.
Люди дольше остаются на сайте, общаются и ставят лайки. Приглашаю и вас пообщаться!
Спасибо за внимание!