Pull to refresh

Пишем Comet-чат

Reading time20 min
Views16K
Хочу поделиться своим опытом создания простого Comet-чата. Периодически читал про эту технологию, и сейчас решил попробовать сделать что-либо сам. Получился небольшой чат, интерфейс которого я старался сделать похожим на интерфейс irc-клиента mIRC. Так как подобную вещь пишу первый раз, просьба прокомментировать возможные ошибки в программе и статье и описать более оптимальные пути решения задач. Посмотреть на работающий чат можно здесь: http://94.127.68.84:6884/

Как я это представил


Отличительной особенностью 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-секундного таймаута и разрыва соединения сервером шанс потери сообщения очень маленький, но он есть.
  • Отсутствие синхронизации исходящих запросов. Вполне может так получиться, что второе сообщение придет на сервер раньше первого.
Tags:
Hubs:
+36
Comments81

Articles

Change theme settings