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

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

    Недавно захотел создать свой сервис проверки номеров 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 значение.

    Средняя зарплата в IT

    110 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 8 813 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 12

      +4
      Хорошее исследование. Автор — молодец!
        +5
        Вот за что люблю Perl, так за множество готовых решений. Небольшое исследование CPAN и всё, что угодно можно сделать за кратчайшее время.
          +1
          Спасибо. С удовольствием еще почитаю про событийное программирование в примерах)
            +1
            Сервис временно недоступен. Попробуйте повторить запрос позднее.

            Хабраэффект
              0
              Скорее всего, был обрыв соединений icq. Они восстанавливаются не сразу, а в течение 5-15 минут — можно посмотреть в исходниках :)
                0
                К тому же, надо сильно постараться чтобы уронить сервис — асинхронная отработка запросов и лимит 1 запрос на проверку в 15 секунд с 1 айпи не дадут загрузить все 10 icq-соединений. После запроса icq-соединение помечается как недоступное на 3 секунды, для предотвращения его использования для следующей проверки (если этого не делать, то при слишком быстрой посылке сообщений сервер icq может отключить за флуд)
                  0
                  :)

                  Вы сказали, что пробовали использовать 3 ICQ модуля из CPAN (я так понимаю это Net::OSCAR, Net::AIM и что-то связанное с AOL, там туча библиотек), у меня как-то стояла такая же задача, тоже ни один из модулей не подошел, что бы вы посоветовали для написания простейшего ICQ бота (прием-отправка сообщений)?
                    0
                    Насколько знаю, Net::Oscar рабочий. В принципе, можно использовать мой модуль — там реализована отправка сообщений, и колбэк на принятое сообщение (но нет разбора этого сообщения и выдирания текста сообщения из пакета). При желании можно допилить до полноценного icq-клиента и выложить на CPAN, я этим вряд ли буду заниматься
                0
                Скажите, как решается у Вас проблема с «автоподъёмом» демона? Решают ли эту задачу используемые библиотеки?
                  0
                  В моем случае — никак, поскольку отсутствует разделение boss — worker, используется только один процесс. Эта задача отлично решается модулем FCGI::ProcManager.
                  0
                  Спасибо, попробую покапать в эту сторону.
                    0
                    О, приятно видеть, что я не зря выкладывал на CPAN модуль FCGI::EV, он уже кому-то пригодился. :)

                    Я тоже экспериментирую сейчас с неблокирующими веб-приложениями. Одна из причин — избежать сложного управления процессами (то, что кое-как пытается делать FCGI::ProcManager — но если почитать APUE Стивенсона, то становится ясно, насколько этот модуль наивно написан… так что я бы его никому не рекомендовал использовать в продакшне). Чтобы избежать блокирования при обработке CGI-запросов часть задач веб-приложения выносится в отдельные сетевые сервисы. Получаем сервис-ориентированную архитектуру, отличное масштабирование, и упрощение разработки сложных систем.

                    По поводу AnyEvent — идея, безусловно, хорошая. Но дело в том, что мы достаточно серьёзно потестировали разные реализации event loop на CPAN, и обнаружили, что реально пользоваться можно только модулем EV — остальные либо глючат, либо грешат утечками памяти, либо тормозят, либо дико переусложнены. А event loop — это сердце приложения, там таких проблем быть не должно! Поэтому я свои модули пишу не на AnyEvent, а на EV.

                    Кстати, в качестве альтернативы AnyEvent::Handle можете глянуть мой IO::Stream. По сути он делает то же самое, но с другим интерфейсом, на мой взгляд более простым и удобным. Плюс поддержка плагинов, с которым можно легко поток I/O зашифровать, перенаправить через цепочку прокси, etc.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое