Хочу поделиться своим опытом создания простого Comet-чата. Периодически читал про эту технологию, и сейчас решил попробовать сделать что-либо сам. Получился небольшой чат, интерфейс которого я старался сделать похожим на интерфейс irc-клиента mIRC. Так как подобную вещь пишу первый раз, просьба прокомментировать возможные ошибки в программе и статье и описать более оптимальные пути решения задач. Посмотреть на работающий чат можно здесь: http://94.127.68.84:6884/
Отличительной особенностью comet-приложений является нахождение в состоянии постоянного опроса сервера, который на запросы от клиента отвечать не спешит и удерживает соединение. Такой подход называется long-polling и делает возможным server push — пересылку данных от сервера клиенту ровно в тот момент, когда на сервере произошло событие (в чат вошел новый участник, отправлено сообщение).
Таким образом, в чате придется использовать минимум 2 соединения клиента с сервером, одно из которых отвечает только за получение данных, постоянно опрашивает сервер, и переустанавливается в случае получения данных, таймаута или разрыва, а второе — только за передачу данных на сервер. Данные будут передаваться в формате JSON и будут представлять собой массив хешей — действий, которые нужно выполнить клиенту или серверу (например, отобразить пришедшее сообщение или обработать запрос на авторизацию).
Между сервером чата и клиентом находится веб-сервер nginx. Клиенту чата можно, конечно, напрямую общаться с серверной частью, но nginx я решил вставить по нескольким причинам:
Для создания подобных вещей прекрасно подходит событийно-ориентированная архитектура, её и было решено использовать. Исходный код серверной части, хорошо сдобренный комментариями, прилагается. Про event loops и AnyEvent можно почитать в моем предыдущем топике.
Извиняюсь за отсутствие подсветки кода — хабр никак не хочет добавлять в пост кучу тегов <font>, подсвечены только комментарии.
Клиентская часть довольно сильно похожа на серверную — существует такой же набор обработчиков действий, запросы на который приходят от сервера (добавить сообщение, установить список участников). Все запросы на сервер отправляются с помощью функции jQuery $.ajax. Выкладывать весь код в статье не буду, посмотреть его можно здесь.
Получился простой, но вполне юзабельный чат. В нем вижу только 2 недостатка:
Как я это представил
Отличительной особенностью comet-приложений является нахождение в состоянии постоянного опроса сервера, который на запросы от клиента отвечать не спешит и удерживает соединение. Такой подход называется long-polling и делает возможным server push — пересылку данных от сервера клиенту ровно в тот момент, когда на сервере произошло событие (в чат вошел новый участник, отправлено сообщение).
Таким образом, в чате придется использовать минимум 2 соединения клиента с сервером, одно из которых отвечает только за получение данных, постоянно опрашивает сервер, и переустанавливается в случае получения данных, таймаута или разрыва, а второе — только за передачу данных на сервер. Данные будут передаваться в формате JSON и будут представлять собой массив хешей — действий, которые нужно выполнить клиенту или серверу (например, отобразить пришедшее сообщение или обработать запрос на авторизацию).
Как я это реализовал
Конфигурация nginx
Между сервером чата и клиентом находится веб-сервер nginx. Клиенту чата можно, конечно, напрямую общаться с серверной частью, но nginx я решил вставить по нескольким причинам:
- nginx берет на себя отдачу статики, поддержание соединений и вообще все общение по протоколу http
- Не нужно светить лишним портом наружу
- Защита от флуда малой кровью
limit_req_zone $binary_remote_addr zone=one:2m rate=1r/s;
server {
listen 6884;
location / {
root /home/vk/CometChat/htdocs/;
}
# сервер чата будет доступен по урлу /chat, подключение через протокол FastCGI
location /chat {
# этих двух параметров достаточно для работы приложения
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_intercept_errors on;
fastcgi_connect_timeout 3;
# разрывать соединение с клиентом и FastCGI-сервером чата после 40 секунд
fastcgi_read_timeout 40;
fastcgi_pass unix:/home/vk/chat.socket;
# защита от флуда, ограничение на 1 запрос в секунду
limit_req zone=one burst=5 nodelay;
}
}
Серверная часть
Для создания подобных вещей прекрасно подходит событийно-ориентированная архитектура, её и было решено использовать. Исходный код серверной части, хорошо сдобренный комментариями, прилагается. Про event loops и AnyEvent можно почитать в моем предыдущем топике.
#!/usr/bin/perl
use strict;
use warnings;
use utf8;
# подключаем все необходимые модули
use AnyEvent;
use AnyEvent::FCGI;
use JSON;
use Digest::MD5 qw/md5_hex/;
use URI::Escape;
# константа для уведомления клиента о выходе из чата - используется довольно часто
use constant LOGOUT => [{action => 'logout'}], 'Set-Cookie' => 'session=; path=/; expires=Thu, 01-Jan-70 00:00:01 GMT';
# пустой ответ клиенту
use constant NOTHING => [];
# таймаут в секундах, после истечения которого участник считается вышедшим из чата
use constant TIMEOUT => 100;
# количество последних сообщений, сохраняемых для отправки вновь пришедшему участнику
use constant MAX_MESSAGES_COUNT => 20;
# хэш для хранения данных участников, находящихся в чате
my %users;
# последние сообщения
my @messages;
# здесь находятся функции-обработчики для всех возможных действий, которые может запросить выполнить клиент
my %actions = (
requestLogin => sub {
# запрос на вход в чат
my $params = shift;
if (($params->{nick} && $params->{session} && $users{$params->{nick}} && $users{$params->{nick}}->{session} eq $params->{session})) {
# обработка повторного запроса на вход в чат
# в случае ошибки входа ответ происходит на этот же запрос, так как long-polling запроса ещё нет
return [{action => 'loginError', message => 'Вы уже зашли в чат под другим ником'}];
} elsif (!defined $params->{nickname} || !length $params->{nickname}) {
# валидация ника
return [{action => 'loginError', message => 'Ник не может быть пустым'}];
} elsif (exists $users{$params->{nickname}}) {
return [{action => 'loginError', message => 'Пользователь с таким ником уже находится в чате'}];
} elsif (length $params->{nickname} < 2 || length $params->{nickname} > 20) {
return [{action => 'loginError', message => 'Длина ника должна составлять от 2 до 20 символов'}];
} elsif ($params->{nickname} !~ /^[\w\d\-]+$/) {
return [{action => 'loginError', message => 'Ник содержит недопустимые символы'}];
} else {
# если все хорошо, вычисляем случайный идентификатор сессии - по нему мы будем идентифицировать пользователя
my $session = md5_hex($params->{request}->param('REMOTE_ADDR') . time . rand);
foreach my $nick (keys %users) {
# рассылка всем участникам чата уведомления...
push_actions(
$nick,
# ... о приходе нового участника (добавляет уведомление в список сообщений) ...
{action => 'join', nick => $params->{nickname}},
# ... и установлении нового списка пользователей в правой колонке
{action => 'setUserList', users => [sort {$a cmp $b} ($params->{nickname}, keys %users)]},
);
}
# добавляем нового участника в %users
$users{$params->{nickname}} = {
session => $session,
# тут будем хранить объект long-polling запроса
polling_request => undef,
# а тут - очередь сообщений для отправки, если long-polling запроса от клиента нет в момент наступления события
queue => [],
};
# устанавливаем таймаут
update_timeout($params->{nickname});
# и отвечаем клиенту на этот же запрос, так как long-polling запроса ещё нет
return (
[
# уведомление об успешном входе в чат
{action => 'loginOk'},
# список участников чата
{action => 'setUserList', users => [sort {$a cmp $b} keys %users]},
# последние MAX_MESSAGES_COUNT сообщений в чате
{action => 'setMessageList', messages => [@messages]},
# команда на подачу long-polling запроса, теперь только по нему будут уходить данные клиенту
{action => 'startPolling'},
],
# установка кук с ником и идентификатором сессии
'Set-Cookie' => 'nick=' . uri_escape_utf8($params->{nickname}) . '; path=/',
'Set-Cookie' => 'session=' . $session . '; path=/',
);
}
},
restoreSession => sub {
# эта команда приходит от клиента, если он обнаружил в куках идентификатор сессии
# производится попытка восстановить сессию (может понадобиться при обновлении страницы с чатом)
my $params = shift;
# убиваем куку с идентификатором сессии еслии клиент прислал неверные данные
return LOGOUT unless ($params->{nick} && $params->{session} && $users{$params->{nick}} && $users{$params->{nick}}->{session} eq $params->{session});
# далее аналогично функции входа в чат
update_timeout($params->{nick});
return [
{action => 'setUserList', users => [sort {$a cmp $b} keys %users]},
{action => 'setMessageList', messages => [@messages]},
{action => 'startPolling'},
];
},
sendMessage => sub {
# запрос на отправку сообщения
my $params = shift;
return LOGOUT unless ($params->{nick} && $params->{session} && $users{$params->{nick}} && $users{$params->{nick}}->{session} eq $params->{session});
# проверяем длину сообщения
if (defined $params->{text} && length $params->{text} > 0 && length $params->{text} <= 300) {
if ($params->{text} =~ /^\/quit\s*$/) {
# если пользователь ввел команду /quit
# если для пользователя сохранен объект long-polling запроса
if ($users{$params->{nick}}->{polling_request} && $users{$params->{nick}}->{polling_request}->is_active) {
# ответить на запрос о выходе из чата
respond($users{$params->{nick}}->{polling_request}, LOGOUT);
}
# удаление участника из списка
delete $users{$params->{nick}};
# и рассылка всем оставшимся уведомления о выходе
foreach my $nick (keys %users) {
push_actions(
$nick,
{action => 'leave', nick => $params->{nick}},
{action => 'setUserList', users => [sort {$a cmp $b} keys %users]},
);
}
return LOGOUT;
} elsif ($params->{text} =~ /^\/me\s+(.+)$/) {
# обработка команды вида /me действие
my $action = {
action => 'me',
nick => $params->{nick},
text => $1,
};
# сохранение сообщения для отображения вновь пришедшим участникам
store_message($action);
# и рассылка всем участниками чата
foreach my $nick (keys %users) {
push_actions($nick, $action);
}
} else {
# обычное сообщение, аналогично команде /me
my $action = {
action => 'message',
nick => $params->{nick},
text => $params->{text},
};
store_message($action);
foreach my $nick (keys %users) {
push_actions($nick, $action);
}
}
}
# на соединение с запросом на отпарвку сообщения отвечаем пустым списком команд
return NOTHING;
},
poll => sub {
# обработка long-polling запроса
my $params = shift;
return LOGOUT unless ($params->{nick} && $params->{session} && $users{$params->{nick}} && $users{$params->{nick}}->{session} eq $params->{session});
# если для участника уже сохранен активный long-polling запрос...
if ($users{$params->{nick}}->{polling_request} && $users{$params->{nick}}->{polling_request}->is_active) {
# ...ответить по нему о прекращении старой сессии, ибо такое может произойти только в случае входа вторым окном браузера
respond($users{$params->{nick}}->{polling_request}, [
{action => 'logout'},
{action => 'loginError', message => 'Вы зашли в чат из другого окна браузера'},
]);
}
# сохраняем объект запроса
$users{$params->{nick}}->{polling_request} = $params->{request};
# отправляем клиенту все накопившиеся действия за время отсутствия соединения
push_actions($params->{nick}) if scalar @{$users{$params->{nick}}->{queue}};
# и обновляем таймаут
update_timeout($params->{nick});
# не отвечаем на запрос!
return undef;
},
# обработчик неизвестного действия
default => sub {return LOGOUT}
);
sub process_request {
# вызывается при получении запроса от http-сервера
my ($request) = @_;
# разбор параметров запроса и извлечение значений кук - для этих простых операции подключать CGI.pm не стоит
my %params;
foreach (
split(/;\s*/, $request->param('HTTP_COOKIE') || ''),
split('&', $request->param('QUERY_STRING') || ''),
) {
next unless $_;
my ($key, $value) = split '=';
if (defined $key && defined $value) {
$value = uri_unescape($value);
$value =~ tr/+/ /;
utf8::decode($value) unless utf8::is_utf8($value);
$params{$key} = $value;
}
}
$params{request} = $request;
# вызываем запрошенное действие, или default, если такого действия мы не знаем
my ($response, @headers) = $actions{$params{action} && $actions{$params{action}} ? $params{action} : 'default'}->(\%params);
# отвечаем клиенту, если нужно
respond($request, $response, @headers) if $response;
}
sub respond {
# функция ответа на запрос, преобразует входные данные в JSON и посылает клиенту
my ($request, $response, @headers) = @_;
my $output = "Content-Type: text/plain; charset=utf-8\n";
while (scalar @headers) {
$output .= shift(@headers) . ': ' . shift(@headers) . "\n";
}
$output .= "\n" . to_json($response);
utf8::encode($output) if utf8::is_utf8($output);
$request->print_stdout($output);
$request->finish;
}
sub push_actions {
# функция добавления действий в очередь на отправку
# если определено активное long-polling соединение, отправить действия
my ($nick, @actions) = @_;
push @{$users{$nick}->{queue}}, @actions;
if ($users{$nick}->{polling_request} && $users{$nick}->{polling_request}->is_active) {
respond($users{$nick}->{polling_request}, $users{$nick}->{queue});
$users{$nick}->{queue} = [];
}
}
sub store_message {
# сохранение сообщения для отображения вновь пришедшим участникам
my ($action) = @_;
push @messages, $action;
shift @messages if scalar @messages > MAX_MESSAGES_COUNT;
}
sub update_timeout {
my ($nick) = @_;
# установка таймаута. если функцию не вызывать TIMEOUT секунд для пользователя...
$users{$nick}->{timeout} = AnyEvent->timer(
after => TIMEOUT,
interval => 0,
cb => sub {
# ...то он будет считаться покинувшим чат
delete $users{$nick};
foreach my $user (keys %users) {
push_actions(
$user,
{action => 'leave', nick => $nick},
{action => 'setUserList', users => [sort {$a cmp $b} keys %users]},
);
}
},
);
}
# основная программа - создание FastCGI-сервера
umask(0);
my $fcgi = new AnyEvent::FCGI(on_request => \&process_request, unix => '/home/vk/chat.socket');
AnyEvent->loop;
Извиняюсь за отсутствие подсветки кода — хабр никак не хочет добавлять в пост кучу тегов <font>, подсвечены только комментарии.
Клиентская часть
Клиентская часть довольно сильно похожа на серверную — существует такой же набор обработчиков действий, запросы на который приходят от сервера (добавить сообщение, установить список участников). Все запросы на сервер отправляются с помощью функции jQuery $.ajax. Выкладывать весь код в статье не буду, посмотреть его можно здесь.
Что получилось
Получился простой, но вполне юзабельный чат. В нем вижу только 2 недостатка:
- Отсутствие уведомлений о доставке сообщений клиенту — из-за 40-секундного таймаута и разрыва соединения сервером шанс потери сообщения очень маленький, но он есть.
- Отсутствие синхронизации исходящих запросов. Вполне может так получиться, что второе сообщение придет на сервер раньше первого.