Pull to refresh

Пример построения неблокирующего веб-приложения

Reading time7 min
Views5.6K
За последнее время видел пару хабратопиков (раз, два), в которых описывается использование неблокирующих сокетов и событийно-ориентированного программирования в вебе. Хочу поделиться своим опытом создания веб-приложения на этой технологии.

Недавно захотел создать свой сервис проверки номеров ICQ на невидимость. Алгоритм проверки старый и известный, но до сих пор работающий — отправка специально сформированного служебного сообщения и анализ ответа сервера. Необходимо было держать несколько постоянных подключений к серверу ICQ, а также иметь веб-интерфейс для запросов на проверку. Очевидное решение — создание демона, который создает несколько потоков для ICQ-соединений, и как-либо получает команды от веб-приложения, использующего несколько процессов-воркеров (или на preforked архитектуре) — для возможности обрабатывать http-запросы от нескольких клиентов. Но я решил освоить новую для себя технологию и сделать приложение, поддерживающее несколько соединений и отвечающее клиентам, используя всего лишь один поток.

Event loops


В Perl есть множество реализаций event loops — фреймворков для создания событийно-ориентированных приложений. Это модули, которые предоставляют интерфейс для регистрации обработчиков различных событий (срабатывание таймера, получение сигнала, появление в сокете данных, которые можно считать), а также функцию, после вызова которой происходит блокировка выполнения программы и начинается обработка событий. При наступлении события происходит вызов колбэка, указанного при регистрации. Многие event loops имеют возможность указывать бэкенд для оповещения о событиях, например, высокопроизводительные kqueue и epoll.
На CPAN есть множество модулей, использующих те или иные event loops. Порой бывает, что есть все нужные модули для решения задачи, но они используют разные event loops с несовместимыми интерфейсами. Что же делать в таких случаях?

AnyEvent


AnyEvent — DBI для событийно-ориентированного программирования. AnyEvent предоставляет интерфейс, дающий возможность в любой момент сменить используемый event loop на другой. Также AnyEvent позволяет использовать вместе модули, использующие разные event loops. Именно поэтому я написал реализацию протокола ICQ с использованием AnyEvent.

Протокол ICQ


Первоочередная задача — подключиться к серверу сообщений ICQ и реализовать алгоритм проверки. Три имеющихся на CPAN модуля были довольно старыми и были выполнены на блокирующих сокетах, поэтому не подходили мне. Ведь любая блокировка внутри колбэка вызовет блокировку обработки всех остальных событий! Но все же для простоты я сначала сделал реализацию на блокирующих сокетах, используя многочисленные готовые решения на других языках и эти модули, и потом начал переделывать все на AnyEvent. Ниже приведен код получения одного пакета ICQ — FLAP:

# блокирующий вариант<br/>
sub recv {<br/>
    my ($self) = @_;<br/>
 <br/>
    # прочитать 6 байт - заголовок FLAP<br/>
    sysread $self->socket, my $data, 6;<br/>
    # в последних двух байтах содержится длина пакета<br/>
    my $length = unpack('n', substr($data, -2));<br/>
    # во втором байте - номер канала<br/>
    my $channel = unpack('C', substr($data, 1, 1));<br/>
    # читаем данные, размер которых мы извлекли из заголовка<br/>
    sysread $self->socket, $data, $length;<br/>
    # теперь у нас есть все для создания OC::ICQ::FLAP<br/>
    return new OC::ICQ::FLAP($channel, $data);<br/>
}<br/>
 <br/>
# неблокирующий вариант<br/>
sub connect {<br/>
    my ($self, $host, $port) = @_;<br/>
 <br/>
    # используем AnyEvent::Handle в качестве абстракции над сокетом для более удобной работы<br/>
    $self->{io} = new AnyEvent::Handle (<br/>
        connect => [$host, $port],<br/>
        # регистрируем колбэки на случай разрыва соединения и ошибки<br/>
        on_error => sub {$self->on_error(@_)},<br/>
        on_disconnect => sub {$self->on_disconnect(@_)},<br/>
    );<br/>
 <br/>
    # регистрируем колбэк, который будет вызван после того, как из сокета можно будет прочитать 6 байт - заголовок FLAP<br/>
    $self->{io}->push_read(chunk => 6, sub {$self->on_read_header(@_)});<br/>
}<br/>
 <br/>
# колбэк, вызываемый при получении заголовка FLAP<br/>
sub on_read_header {<br/>
    my ($self, $io, $header) = @_;<br/>
 <br/>
    # читаем и запоминаем номер канала - он будет нужен для создания OC::ICQ::FLAP<br/>
    $self->{flap_channel} = unpack('C', substr($header, 1, 1));<br/>
    # регистрируем другой колбэк, который будет вызван при получении данных указанного в заголовке размера<br/>
    $io->push_read(chunk => unpack('n', substr($header, -2)), sub {$self->on_read_data(@_)});<br/>
}<br/>
 <br/>
# колбэк, вызываемый при получении данных<br/>
sub on_read_data {<br/>
    my ($self, $io, $data) = @_;<br/>
 <br/>
    # мы получили данные, теперь можно создавать OC::ICQ::FLAP, используя заранее сохраненный номер канала<br/>
    $self->_process_flap(new OC::ICQ::FLAP($self->{flap_channel}, $data));<br/>
    # вновь ждем заголовок FLAP<br/>
    $io->push_read(chunk => 6, sub {$self->on_read_header(@_)});<br/>
}


Всю остальную логику, лежащую в _process_flap, переделывать почти не пришлось. Для поддержания соединения нужно посылать пустые FLAP по 5 каналу раз в 2 минуты. Для этого можно использовать предоставляемую AnyEvent функцию timer:

# устанавливаем таймер, вызывающий колбэк каждые 2 минуты<br/>
$self->{keepalive_timer} = AnyEvent->timer(after => 120, interval => 120, cb => sub {$self->send_keepalive});<br/>
 <br/>
sub send_keepalive {<br/>
    my ($self) = @_;<br/>
 <br/>
    if ($self->{state} == ONLINE) {<br/>
        $self->send(new OC::ICQ::FLAP(5, ''));<br/>
    }<br/>
}<br/>
 <br/>
# для удаления таймера нужно удалить все сильные ссылки на него. Единственная сохранена в $self->{keepalive_timer}<br/>
delete $self->{keepalive_timer};


Веб-интерфейс


Отлично, реализация протокола ICQ и алгоритма проверки готова, теперь нужен веб-интерфейс. Для подключения веб-приложений к веб-серверу существует замечательный протокол FastCGI, и на CPAN я нашел две его асинхронные реализации для EV и IO::Async. Выбрал EV из-за его быстродействия. Далее был сделан несложный url-диспатчер на атрибутах и прикручен простой шаблонизатор Text::MicroMason — всё, мини-фреймворк для создания асинхронных веб-приложений готов.
Text::MicroMason хранит шаблоны в скомпилированном виде в памяти, что замечательно сказывается на производительности, но что делать если нужно изменить шаблон? Не останавливать же демона, обрывая соединения всех ICQ-клиентов? AnyEvent и EV предоставляют возможность устанавливать обработчики на сигналы, этим можно воспользоваться.
    my $sigusr1_watcher = EV::signal('USR1', \&restart) unless $^O =~ /MSWin32/;<br/>
    my $sigusr2_watcher = EV::signal('USR2', \&load_templates) unless $^O =~ /MSWin32/;<br/>
 

Теперь при получении SIGUSR1 будет заново подгружен конфиг, удалены все старые ICQ-клиенты и созданы новые, а при получении SIGUSR2 будут перезагружены шаблоны. Как и в случае с таймером, обязательно нужно сохранять возвращаемое EV::signal/AnyEvent->signal значение.
Tags:
Hubs:
+34
Comments12

Articles