Это продолжение серии статей о веб-фреймворке для Perl — Mojolicious: первая часть.
Этот цикл статей предполагает, что читатель уже поверхностно знаком с фреймворком, и у него возникла потребность разобраться в деталях, которые либо не описаны в документации, либо описаны недостаточно подробно и понятно. Для начального ознакомления идеально подходит официальная документация (на английском).
Mojo::IOLoop::Delay предоставляет механизм, обеспечивающий для асинхронно выполняющихся callback-ов:
Используемые термины:
Это альтернативный подход к проблеме, обычно решаемой с помощью Promise/Deferred или Future. Вот приблизительное сравнение со спецификацией Promises/A+:
По этим отличиям виден типичный для Mojo подход: всё что можно упрощено и предоставлены удобные «ленивчики» для типичных задач.
Я не буду описывать работу
Кроме того, есть синонимы/альтернативы:
Это ключевая функция, без неё использовать Mojo::IOLoop::Delay не получится. Каждый вызов
Есть два способа использования
В первом варианте функция возвращённая
Во втором варианте функция возвращённая
В обоих вариантах если определить для
В данном примере есть проблема: во втором варианте не выполняется
В таких, редких, ситуациях необходимо использовать первый «ручной» вариант работы с
Функции
Например,
При этом все три
В принципе с
Альтернативой является использование клозур, что выглядит более лениво, привычно и читабельно:
Но здесь вас поджидает неприятный сюрприз. Клозуры живут пока кто-то на них ссылается. А по мере выполнения шагов Mojo удаляет их из памяти. Таким образом, когда будет выполнен последний шаг, ссылавшийся на заклозуренную переменную — она тоже будет удалена. Что приводит к неприятному эффекту, если эта переменная была, например, объектом Mojo::UserAgent:
Как только первый шаг запустит неблокирующие операции выкачки url, завершится, и будет удалён из памяти — вместе с ним будет удалена и переменная
Один из вариантов решения этой проблемы — использовать
Устанавливать обработчик события «finish» не обязательно, но во многих случаях очень удобно последний шаг указать не после остальных шагов, а обработчиком события «finish». Это вам даст следующие возможности:
ВНИМАНИЕ! Делать
Очень часто шаг запускает операции условно — внутри
Эта команда просимулирует запуск одной операции, которая тут же завершилась и вернула пустой список в качестве результата. Поскольку она вернула пустой список, то этот её «запуск» никак не скажется на параметрах, которые получит следующий шаг.
Дело в том, что если шаг не запустит ни одной операции вообще, то он будет считаться последним шагом (что логично — следующему шагу уже нечего «ожидать» так что в нём пропадает смысл). Иногда такой способ завершить выполнение задачи подходит, но если вы установили обработчик «finish», то он будет вызван после этого шага, причём получит параметрами параметры этого шага — что, как правило, не то, чего вы хотели.
Давайте рассмотрим пример, в котором используется почти всё вышеописанное. Предположим, что нам нужно скачать данные с сайта. Сначала нужно залогиниться (
Немного не очевидным моментом является способ обработки ошибок. Поскольку результаты работы передавать между шагами не требуется (они накапливаются в заклозуренном
Если кто-то знает, как можно проще и/или нагляднее решить такую задачу - пишите. Пример аналогичного решения на Promises тоже был бы кстати.
______________________
Этот цикл статей предполагает, что читатель уже поверхностно знаком с фреймворком, и у него возникла потребность разобраться в деталях, которые либо не описаны в документации, либо описаны недостаточно подробно и понятно. Для начального ознакомления идеально подходит официальная документация (на английском).
Асинхронность: синхронизируем с помощью Mojo::IOLoop::Delay
Mojo::IOLoop::Delay предоставляет механизм, обеспечивающий для асинхронно выполняющихся callback-ов:
- описание последовательно выполняющихся операций без «лапши» callback-ов
- передачу результатов из callback-а(ов) текущего шага на следующий
- общие данные для callback-ов, объединённых в одну задачу
- синхронизацию групп callback-ов
- перехват и обработку исключений в callback-ах
Используемые термины:
- (асинхронная) операция — обычно это вызов асинхронной функции вроде таймера или выкачивания url, которой необходимо передать callback
- шаг — callback, который анализирует данные полученные с предыдущего шага (если это не первый шаг), и запускает одну или несколько новых операций, либо возвращает финальный результат (если это последний шаг)
- задача — список шагов, которые должны выполняться последовательно (т.е. следующий шаг вызывается только после того, как все операции запущенные на предыдущем шаге завершаются)
Альтернатива Promises
Это альтернативный подход к проблеме, обычно решаемой с помощью Promise/Deferred или Future. Вот приблизительное сравнение со спецификацией Promises/A+:
- Вместо цепочки ->then(\&cb1)->then(\&cb2)->…используется один вызов->steps(\&cb1, \&cb2, …).
- Вместо передачи обработчика ошибки вторым параметром в ->then()он устанавливается через->catch(). Следствие: на все шаги этой задачи может быть только один обработчик ошибок.
- Результат возвращается через ->pass(), но в отличие от->resolve()в большинстве случаев он вызывается неявно — асинхронной операции в качестве callback передаётся результат вызова генератора анонимных функций->begin, и возвращённая им функция автоматически делает->pass(), передавая срез своих параметров (т.е. результата работы асинхронной операции) на следующий шаг. Следствие: не нужно писать для каждой асинхронной функции callback, который будет возвращённый ею результат преобразовывать в->resolve()и->reject().
- Ошибки возвращаются только через исключения, аналога ->reject()нет.
- Есть дополнительный шаг выполняемый в самом конце ->on(finish=>\&cb), на который также можно перейти из обработчика ошибок.
- Есть поддержка групп асинхронных операций: если на текущем шаге запустить несколько операций, то следующий шаг будет вызван когда все они завершатся.
- Есть хранилище пользовательский данных, доступное всем шагам текущей задачи.
По этим отличиям виден типичный для Mojo подход: всё что можно упрощено и предоставлены удобные «ленивчики» для типичных задач.
Что осталось за кадром
Я не буду описывать работу
->wait, с ним всё просто и понятно из официальной документации.Кроме того, есть синонимы/альтернативы:
Mojo::IOLoop->delay(@params)
# это полный аналог более длинного:
Mojo::IOLoop::Delay->new->steps(@params)$delay->catch(\&cb)
# это более удобный (т.к. возвращает $delay, а не \&cb,
# что позволяет продолжить цепочку вызовов) аналог:
$delay->on(error=>\&cb)$delay→begin
Это ключевая функция, без неё использовать Mojo::IOLoop::Delay не получится. Каждый вызов
->begin увеличивает счётчик запущенных (обычно асинхронных) операций и возвращает ссылку на новую анонимную функцию. Эту возвращённую функцию необходимо однократно вызвать по завершению операции — она уменьшит счётчик запущенных операций и позволит передать результаты операции на следующий шаг (который будет запущен когда счётчик дойдёт до нуля).Есть два способа использования
->begin: вручную и автоматически.В первом варианте функция возвращённая
->begin запоминается во временной переменной и по завершению операции вызывается вручную:my $delay = Mojo::IOLoop->delay;
for my $i (1 .. 10) {
    my $end = $delay->begin;
    Mojo::IOLoop->timer($i => sub {
        say 10 - $i;
        $end->();
    });
}Во втором варианте функция возвращённая
->begin используется в качестве callback для операции:my $delay = Mojo::IOLoop->delay;
for my $i (1 .. 10) {
    Mojo::IOLoop->timer($i => $delay->begin);
}В обоих вариантах если определить для
$delay следующий (в данном случае он же первый и единственный) шаг, то он будет вызван после завершения всех 10-ти операций:$delay->steps(sub{ say "all timers done" });В данном примере есть проблема: во втором варианте не выполняется
say 10 - $i т.к. таймер не передаёт никаких параметров своему callback, и мы не можем узнать значение $i в callback если только не заклозурим его как в первом варианте. Но даже если бы таймер передавал $i параметром в callback вам бы это всё-равно не сильно помогло, т.к. шанс выполнить все десять say 10 - $i мы бы получили только на следующем шаге, а он запустится только после завершения всех таймеров — т.е. пропадёт эффект обратного отсчёта, когда say выполнялся раз в секунду.В таких, редких, ситуациях необходимо использовать первый «ручной» вариант работы с
->begin. Но во всех остальных намного лучше использовать второй вариант: это избавит от временной переменной, «лапши» callback-ов, и даст возможность использовать (точнее, перехватывать) исключения в callback-ах (исключение в обычном callback-е — не «шаге» — попадёт не в $delay->catch а в обработчик исключений event loop и, по умолчанию, будет проигнорировано).Функции
->begin можно передать параметры, и на первый взгляд (в официальную документацию) они могут выглядеть не очень понятно. Суть в том, что когда функция возвращаемая ->begin используется не в ручном варианте (когда вы сами её вызываете и контролируете с какими параметрами она будет вызвана), а в качестве непосредственного callback для операции, то она будет вызвана с теми параметрами, с которыми её вызовет эта операция. И все эти параметры вы получите как результат этой операции в параметрах следующего шага.Например,
$ua->get($url,\&cb) передаёт в callback два параметра: ($ua,$tx), и если на одном шаге запустить выкачку 3-х url, то следующий шаг получит 6 параметров (каждый шаг получает первым обязательным параметром объект $delay, а зачем в этом примере используется ->begin(0) я скоро объясню):Mojo::IOLoop->delay(
    sub {
        my ($delay) = @_;
        $ua->get($url1, $delay->begin(0));
        $ua->get($url2, $delay->begin(0));
        $ua->get($url3, $delay->begin(0));
    },
    sub {
        my ($delay, $ua1,$tx1, $ua2,$tx2, $ua3,$tx3) = @_;
    },
);При этом все три
$ua полученные вторым шагом будут одинаковыми. Поскольку это типичная ситуация, ->begin даёт вам возможность контролировать, какие именно из переданных операцией параметров он должен передать на следующий шаг. Для этого он принимает два параметра: индекс первого параметра и их количество — чтобы передать на следующий шаг срез. По умолчанию ->begin работает как ->begin(1) — т.е. передаёт на следующий шаг все параметры переданные операцией кроме первого:Mojo::IOLoop->delay(
    sub {
        my ($delay) = @_;
        $ua->get($url1, $delay->begin);
        $ua->get($url2, $delay->begin);
        $ua->get($url3, $delay->begin);
    },
    sub {
        my ($delay, $tx1, $tx2, $tx3) = @_;
    },
);$delay→data
В принципе с
->data всё банально: хеш, доступный всем шагам - альтернатива передаче данных с одного шага на другой через параметры.Mojo::IOLoop->delay(
    sub {
        my ($delay) = @_;
        $delay->data->{key} = 'value';
        ...
    },
    sub {
        my ($delay) = @_;
        say $delay->data->{key};
    },
);Альтернативой является использование клозур, что выглядит более лениво, привычно и читабельно:
sub do_task {
    my $key;
    Mojo::IOLoop->delay(
        sub {
            $key = 'value';
            ...
        },
        sub {
            say $key;
        },
    );
}Но здесь вас поджидает неприятный сюрприз. Клозуры живут пока кто-то на них ссылается. А по мере выполнения шагов Mojo удаляет их из памяти. Таким образом, когда будет выполнен последний шаг, ссылавшийся на заклозуренную переменную — она тоже будет удалена. Что приводит к неприятному эффекту, если эта переменная была, например, объектом Mojo::UserAgent:
sub do_task {
    my $ua = Mojo::UserAgent->new->max_redirects(5);
    Mojo::IOLoop->delay(
        sub {
            my ($delay) = @_;
            $ua->get($url1, $delay->begin);
            $ua->get($url2, $delay->begin);
            $ua->get($url3, $delay->begin);
        },
        sub {
            my ($delay, $tx1, $tx2, $tx3) = @_;
            # все $tx будут с ошибкой "соединение разорвано"
        },
    );
}Как только первый шаг запустит неблокирующие операции выкачки url, завершится, и будет удалён из памяти — вместе с ним будет удалена и переменная
$ua, т.к. больше нет шагов, которые на неё ссылаются. А как только будет удалена $ua все открытые соединения, относящиеся к ней, будут разорваны и их callback-и будут вызваны с ошибкой в параметре $tx.Один из вариантов решения этой проблемы — использовать
->data для гарантирования времени жизни клозур не меньше, чем время выполнения всей задачи:sub do_task {
    my $ua = Mojo::UserAgent->new->max_redirects(5);
    Mojo::IOLoop->delay->data(ua=>$ua)->steps(
        sub {
            my ($delay) = @_;
            $ua->get($url1, $delay->begin);
            $ua->get($url2, $delay->begin);
            $ua->get($url3, $delay->begin);
        },
        sub {
            my ($delay, $tx1, $tx2, $tx3) = @_;
            # все $tx будут с результатами
        },
    );
}finish
Устанавливать обработчик события «finish» не обязательно, но во многих случаях очень удобно последний шаг указать не после остальных шагов, а обработчиком события «finish». Это вам даст следующие возможности:
- Если используется обработчик исключений ->catch, и бывают не фатальные ошибки, после которых всё-таки имеет смысл штатно завершить текущую задачу выполнив последний шаг — обработчик исключений сможет передать управление обработчику «finish» через->emit("finish",@results), но не сможет обычному шагу.
- Если финальный результат получен на промежуточном шаге, то чтобы  передать его на последний шаг нужно реализовать ручной механизм  «прокидывания» готового результата через все шаги между ними — но  если вместо последнего шага используется обработчик «finish», то можно  сразу вызвать его через ->remaining([])->pass(@result).- Так же нужно учитывать, что если этот шаг успел запустить какие-то  операции до передачи результатов в «finish», то обработчик «finish»  будет запущен только после того, как эти операции завершатся, причём  он получит параметрами не только вышеупомянутый @result, но и всё что вернут операции.
 
 
- Так же нужно учитывать, что если этот шаг успел запустить какие-то  операции до передачи результатов в «finish», то обработчик «finish»  будет запущен только после того, как эти операции завершатся, причём  он получит параметрами не только вышеупомянутый 
ВНИМАНИЕ! Делать
->emit("finish") можно только внутри обработчика исключений, а в обычном шаге нельзя. При этом в обычном шаге это же делается через ->remaining([])->pass(@result), но в обработчике исключений это не сработает.$delay→pass
Очень часто шаг запускает операции условно — внутри
if или в цикле, у которого может быть 0 итераций. В этом случае, как правило, необходимо чтобы этот шаг (обычно в самом начале или конце) вызвал:$delay->pass;Эта команда просимулирует запуск одной операции, которая тут же завершилась и вернула пустой список в качестве результата. Поскольку она вернула пустой список, то этот её «запуск» никак не скажется на параметрах, которые получит следующий шаг.
Дело в том, что если шаг не запустит ни одной операции вообще, то он будет считаться последним шагом (что логично — следующему шагу уже нечего «ожидать» так что в нём пропадает смысл). Иногда такой способ завершить выполнение задачи подходит, но если вы установили обработчик «finish», то он будет вызван после этого шага, причём получит параметрами параметры этого шага — что, как правило, не то, чего вы хотели.
Пример сложного парсера
Давайте рассмотрим пример, в котором используется почти всё вышеописанное. Предположим, что нам нужно скачать данные с сайта. Сначала нужно залогиниться (
$url_login), потом перейти на страницу со списком нужных записей ($url_list), для некоторых записей может быть доступна ссылка на страницу с деталями, а на странице с деталями могут быть ссылки на несколько файлов «приаттаченных» к этой записи, которые необходимо скачать.sub parse_site {
    my ($user, $pass) = @_;
    # сюда будем накапливать данные в процессе выкачки:
    # @records = (
    #   {
    #       key1 => "value1",
    #       …
    #       attaches => [ "content of file1", … ],
    #   },
    #   …
    # );
    my @records;
    # каждой запущенной задаче нужен свой $ua, т.к. можно запустить
    # несколько одновременных выкачек с разными $user/$pass, и нужно
    # чтобы в $ua разных задач были разные куки
    my $ua = Mojo::UserAgent->new->max_redirects(5);
    # запускаем задачу, удерживая $ua до конца задачи
    Mojo::IOLoop->delay->data(ua=>$ua)->steps(
        sub {
            $ua->post($url_login, form=>{user=>$user,pass=>$pass}, shift->begin);
        },
        sub {
            my ($delay, $tx) = @_;
            die $tx->error->{message} if $tx->error;
            # проверим ошибку аутентификации
            if (!$tx->res->dom->at('#logout')) {
                die 'failed to login: bad user/pass';
            }
            # всё в порядке, качаем список записей
            $ua->get($url_list, $delay->begin);
        },
        sub {
            my ($delay, $tx) = @_;
            die $tx->error->{message} if $tx->error;
            # если записей на странице не будет и никаких операций
            # на этом шаге не запустится - перейдём на следующий шаг
            $delay->pass;
            # считаем все записи
            for ($tx->res->dom('.record')->each) {
                # парсим обычные поля текущей записи
                my $record = {
                    key1 => $_->at('.key1')->text,
                    # …
                };
                # добавляем эту запись к финальному результату
                push @records, $record;
                # если есть страница с деталями - качаем
                if (my $a = $_->at('.details a')) {
                    # качаем страницу с деталями и приаттаченные к ней
                    # файлы как отдельную задачу - это немного
                    # усложнит, но зато ускорит процесс т.к. можно
                    # будет одновременно качать и страницы с
                    # деталями и файлы приаттаченные к уже скачанным
                    # страницам (плюс при таком подходе мы лениво
                    # клозурим $record и не нужно думать как привязать
                    # конкретную страницу с деталями к конкретной
                    # записи) - альтернативой было бы поставить на
                    # выкачку только страницы с деталями, а на
                    # следующем шаге основной задачи когда все
                    # страницы с деталями скачаются ставить на выкачку
                    # приаттаченные файлы
                    Mojo::IOLoop->delay(
                        sub {
                            $ua->get($a->{href}, shift->begin);
                        },
                        sub {
                            my ($delay, $tx) = @_;
                            die $tx->error->{message} if $tx->error;
                            # если файлов не будет - идём на след.шаг
                            $delay->pass;
                            # качаем 0 или более приаттаченных файлов
                            $tx->res->dom('.file a')->each(sub{
                                $ua->get($_->{href}, $delay->begin);
                            });
                        },
                        sub {
                            my ($delay, @tx) = @_;
                            die $_->error->{message} for grep {$_->error} @tx;
                            # добавляем файлы к нужной записи
                            for my $tx (@tx) {
                                push @{ $record->{attaches} }, $tx->body;
                            }
                            # нам необходимо чтобы finish вызвался без
                            # параметров, а не с нашими @tx, поэтому:
                            $delay->pass;
                        },
                    )->catch(
                        sub {
                            my ($delay, $err) = @_;
                            warn $err; # ошибка выкачки или парсинга
                            $delay->emit(finish => 'failed to get details');
                        }
                    )->on(finish => $delay->begin);
                } ### if .details
            } ### for .record
        },
    )->catch(
        sub {
            my ($delay, $err) = @_;
            warn $err; # ошибка логина, выкачки или парсинга
            $delay->emit(finish => 'failed to get records');
        }
    )->on(finish =>
        sub {
            my ($delay, @err) = @_;
            if (!@err) {
                process_records(@records);
            }
        }
    );
}Немного не очевидным моментом является способ обработки ошибок. Поскольку результаты работы передавать между шагами не требуется (они накапливаются в заклозуренном
@records), то при успехе на следующий шаг передаётся пустой список (через $delay->pass;), а при ошибке передаётся текст ошибки. Таким образом, если последний шаг в обработчике finish получит какие-то параметры — значит где-то в процессе выкачки или парсинга была ошибка(и). Саму ошибку уже перехватили и обработали (через warn) в обработчиках ->catch — собственно это как раз они и обеспечили передачу ошибки параметром в обработчик finish.Если кто-то знает, как можно проще и/или нагляднее решить такую задачу - пишите. Пример аналогичного решения на Promises тоже был бы кстати.
______________________

