Выпуск 33. Ноябрь 2015

Ближайшие конференции | Содержание | Perl 6 для веба

Сравнение веб-клиентов

Практическое сравнение веб-клиентов на реальной задаче

Недавно я подумывал о смене съемной квартиры, и в связи с этим первый вопрос, который возник у меня, был: «Какова средняя цена квартиры в моем районе?». И поскольку сам пишу на перле, то я подумал написать скрипт для сканирования объявлений в Авито.

Первое, что мне пришло в голову для решения этой бытовой задачи, — использовать Mojo::UserAgent, поскольку с ним можно скачать и сразу распарсить страничку, не применяя других модулей. (По правде сказать, он не один такой, есть, например, еще WWW::Scripter, но последний медленнее работает). Получился маленький скриптик:

#!/usr/bin/env perl

use strict;
use warnings;
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new();

my $url_template =
  'https://www.avito.ru/moskva/kvartiry/sdam/na_dlitelnyy_srok/1-komnatnye?p=%s&i=1&metro=85&view=list';

my $data = {};

# Скачиваем первую странницу
my $dom = $ua->get( sprintf( $url_template, 1 ) )->res->dom;

# Определяем последнюю
my $last_page = ( $dom->find('div.pagination__pages a')->[-1]->attr('href') =~ /\?p=(\d+)/ )[0];

# Парсим первую страничку
parse_dom( $dom, $data );

# Качаем и парсим последующие странички
if ( $last_page > 1 ) {
    for my $page ( 2 .. $last_page ) {
        my $dom = $ua->get( sprintf( $url_template, $page ) )->res->dom;
        parse_dom( $dom, $data );
    }
}

my ( $count, $area, $price, %min, %max );
my $simple = shift @{ [ keys %$data ] };

$min{price} = $max{price} = $data->{$simple}->{price};
$min{ref}   = $max{ref}   = $simple;

# Определяем среднюю цену, среднюю за квадратный метр, минимум, максимум
for my $link ( keys %$data ) {
    $count++;
    $price += $data->{$link}{price};
    $area  += $data->{$link}{area};
    if ( $data->{$link}{price} < $min{price} ) {
        $min{price} = $data->{$link}{price};
        $min{ref}   = $link;
    }
    if ( $data->{$link}{price} > $max{price} ) {
        $max{price} = $data->{$link}{price};
        $max{ref}   = $link;
    }
}

print "count = $count, price = " . $price / $count . " area_price = " . $price / $area . "\n";
print "min => $min{price} $min{ref}\n";
print "max => $max{price} $max{ref}\n";

sub parse_dom {
    my ( $dom, $data ) = @_;

    $dom->find('div.catalog-list div.item')->each(
        sub {
            my $a   = $_->find('div.title a')->[0];
            my $ref = $a->attr('href');
            ( $data->{$ref}{rooms}, $data->{$ref}{area} ) = split /,/, $a->attr('title');
            $data->{$ref}{price} = $_->find('div.price p')->[0]->text;

            for my $field (qw(price rooms area)) {
                $data->{$ref}{$field} =~ s/\D//g;
            }

        }
    );
}

Конечно, для какого-либо проекта сюда надо добавить всякие проверки статуса запроса, валидацию и т.п., но для меня и так вполне сошло. И отработал скрипт довольно быстро.

$ time ./mojo.pl
count =142, price = 29180.9788732394 area_price = 755.322457163689
min => 5000 /moskva/kvartiry/1-k_kvartira_42_m_1117_et._672470440
max => 49000 /moskva/kvartiry/1-k_kvartira_44_m_618_et._668652789

real 0m1.599s
user 0m0.640s
sys 0m0.044s

Всего 1,5 секунды, совсем не плохо. Однако он скачал и пропарсил всего 2-3 страницы, а если мне понадобится узнать среднюю не по моему району, а по всему городу/области/стране, то там уже сотни страниц (для Москвы где-то 200 было), и, следовательно, надо это дело оптимизировать. Для начала посмотрим, что у нас медленнее выполняется: парсинг или скачивание данных? Для этой цели я решил использовать модуль Time::HR, который работает с наносекундами. Добавив замеры, получил такой скриптик.

#!/usr/bin/env perl

use strict;
use warnings;
use Mojo::UserAgent;
use Data::Dumper;
use Time::HR;

my $ua = Mojo::UserAgent->new();

my $url_template =
  'https://www.avito.ru/moskva/kvartiry/sdam/na_dlitelnyy_srok/1-komnatnye?p=%s&i=1&metro=85&view=list';

my %time;
my $data = {};

# Засекаем начало скачивания
$time{1} = gethrtime();

my $dom = $ua->get( sprintf( $url_template, 1 ) )->res->dom;

# Конец скачивания и начало парсинга
$time{2} = gethrtime();

parse_dom( $dom, $data );

# Конец парсинга
$time{3} = gethrtime();

my $last_page = ( $dom->find('div.pagination__pages a')->[-1]->attr('href') =~ /\?p=(\d+)/ )[0];

print "Fetching: @{[ $time{2} - $time{1} ]}\nparsing: @{[ $time{3} - $time{2} ]}
\ndelta: @{[ ( $time{3} - $time{2} ) * 100 / ( ( $time{2} - $time{1} ) - ( $time{3} - $time{2} ) ) ]} %\n";
exit;

...

Прогоняем

./mojo.pl
Fetching: 733339136
parsing: 92294656
delta: 14.3975432094821 %

То есть парсинг всего на 14% быстрее, чем скачивание данных. Это конечно несущественно, но начнем оптимизацию со скачивания. Для начала я сравнил известные мне веб-клиенты:

LWP::UserAgent
Mojo::UserAgent
WWW::Curl
IO::Socket::SSL (SSL потому как Авито на https)

И для сравнения я решил теребить не сайт Авито (чтоб ненароком не забанили), а сайт https://example.com. Сравнение произвожу просто: делаю запрос, HTML-код ответа пишу в переменную. Получился такой скрипт. Для сравнения скорости буду использовать модуль Benchmark, а для памяти Memchmark.

#!/usr/bin/env perl

use strict;
use warnings;
use Benchmark;
use Memchmark;
use Mojo::UserAgent;
use LWP::UserAgent;
use WWW::Curl::Easy;
use IO::Socket::SSL;

my $curl = WWW::Curl::Easy->new;
my $m    = Mojo::UserAgent->new;
my $l    = LWP::UserAgent->new;
my $sock = IO::Socket::SSL->new('example.com:443') or die "Cannot construct socket - $@";
my $url  = "https://example.com";

my %cmp_hash = (
    'lwp' => sub { my $res = $l->get($url); my $html = $res->decoded_content; },
    'mojo' => sub { my $html = $m->get($url)->res->body; },
    'curl' => sub {
        $curl->setopt( CURLOPT_URL, $url );
        my $html;
        $curl->setopt( CURLOPT_WRITEDATA, \$html );
        $curl->perform;
    },
    'sock' => sub {
        print $sock "GET / HTTP/1.1\nHost: example.com\nConnection: keep-alive\n\n";
        my $html = "";
        while (<$sock>) {
            next if /\r\n$/;
            $html .= $_;
            last if /<\/body>/;
        }
    }
);

Memchmark::cmpthese(%cmp_hash);
Benchmark::cmpthese( 30000, \%cmp_hash );

Прогон бенчмарка занял огромное время (поначалу ставил 100 000 итераций, но скрипт не отработал за ночь, так что опустил до 30 000):

$ time ./benchmark.pl
test: curl, memory used: 2875392 bytes
test: lwp, memory used: 8388608 bytes
test: mojo, memory used: 188416 bytes
test: sock, memory used: 0 bytes
Rate lwp mojo curl sock
lwp 34.8/s -- -85% -97% -100%
mojo 226/s 550% -- -79% -99%
curl 1096/s 3053% 385% -- -96%
sock 29703/s 85300% 13042% 2609% --

real 451m26.547s
user 16m11.740s
sys 0m54.373s

Данные моего ноута:

Processor: Intel® Core™ i5-2450M CPU @ 2.50GHz × 4
Memory: 3.8 GiB
OS: Ubuntu, Release 12.04 (precise) 64-bit, Kernel Linux 3.13.0-67-generic

Победителем оказался IO::Socket::SSL, и, по-моему, это просто потому, что он держит соединение (заголовок Connection: keep-alive), а остальные на каждый запрос открывают новый сокет. Я так и не понял, почему Memchamrk не зафиксировал потребление памяти для IO::Socket::SSL, но для остальных меньше всего памяти потребляет Mojo::UserAgent.

Но это пока синхронный вариант скачивания. Посмотрим, что выйдет, если скачивать асинхронно. Для реализации асинхронного скачивания мне были известны два модуля, и еще два (AnyEvent::HTTP, WWW::Curl::Multi) мне подсказали коллеги:

Mojo::UserAgent
YADA
AnyEvent::HTTP
WWW::Curl::Multi

Чтобы сравнить их производительность, я просто 1000 раз качаю https://example.com и складываю результат в массив. Но для начала проделаю это с IO::Socket::SSL, чтобы было с чем сравнить. Скрипт для него:

#!/usr/bin/env perl

use strict;
use warnings;
use IO::Socket::SSL;

my $sock = IO::Socket::SSL->new('example.com:443') or die "ERROR::$@";
my @html;
for ( ( 1 .. 1000 ) ) {
    get();
}

sub get {
    print $sock "GET / HTTP/1.1\nHost: example.com\nConnection: keep-alive\n\n";
    my $html = "";
    while (<$sock>) {
        +next if /\r\n$/;
        $html .= $_;
        last if /<\/body>/;
    }
    push @html, $html;
}

Прогоняем:

$ time ./socket.pl

real 2m22.407s
user 0m2.635s
sys 0m0.306s

Далее пишем для осталных модулей.

Для YADA:

#!/usr/bin/env perl

use strict;
use warnings;
use YADA;

my @url;
$url[$_] = "https://example.com/$_" for ( ( 0 .. 999 ) );  # урлы для YADA должны быть разные

my @html;

YADA->new->append( [@url] => sub { push @html, ${ $_[0]->data }; } )->wait;

Прогоняем:

$ time ./yada.pl

real 0m37.593s
user 0m8.100s
sys 0m0.405s

О! Уже гораздо лучше, вместо двух минут всего 37 секунд, и код гораздо компактней.

Для AnyEvent::HTTP:

#!/usr/bin/env perl

use strict;
use warnings;
use AnyEvent;
use AnyEvent::HTTP;

my @url;
$url[$_] = "https://example.com" for ( ( 0 .. 999 ) );

my @html;
my $cv = AnyEvent->condvar;

for my $url (@url) {
    $cv->begin;
    http_get(
        $url,
        sub {
            push @html, $_[0];
            $cv->end;
        }
    );
}

$cv->wait;

Результат:

$ time ./anyevent.pl

real 0m35.149s
user 0m1.215s
sys 0m0.179s

Почти так же, но кода больше.

Для WWW::Curl:

#!/usr/bin/env perl

use strict;
use warnings;
use WWW::Curl::Easy;
use WWW::Curl::Multi;

my $url = 'https://example.com';

my $curlm = WWW::Curl::Multi->new;
my %html;
my %easy;
my $active_handles = 1000;

for my $i ( ( 1 .. 1000 ) ) {

    my $curl = WWW::Curl::Easy->new;
    $easy{$i} = $curl;

    $curl->setopt( CURLOPT_PRIVATE,   $i );
    $curl->setopt( CURLOPT_URL,       $url );
    $curl->setopt( CURLOPT_WRITEDATA, \$html{$i} );
    $curlm->add_handle($curl);
}
while ($active_handles) {
    my $active_transfers = $curlm->perform;
    if ( $active_transfers != $active_handles ) {
        while ( my ( $id, $return_value ) = $curlm->info_read ) {
            if ($id) {
                $active_handles--;
                delete $easy{$id};
            }
        }
    }
}

Результат

$ time ./curl.pl

real 1m48.848s
user 0m53.507s
sys 0m2.206s

Может я как-то криво написал (я писал по примеру на CPAN), но получилось втрое медленнее, однако это все равно почти в два раза быстрее синхронного варианта с IO::Socket::SSL.

Для Mojo::UserAgent:

#!/usr/bin/env perl

use strict;
use warnings;
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new();
my @url;
$url[$_] = "https://example.com/$_" for ( ( 0 .. 999 ) );

my @html;
Mojo::IOLoop->delay(
    sub {
        my $delay = shift;
        for my $url (@url) {
            $ua->get( $url => $delay->begin );
        }
    },
    sub {
        my ($delay) = shift;
        for my $r (@_) {
            push @html, $r->res->body;
        }
    }
)->wait;

Прогоняем:

$ time ./mojo_async.pl

real 0m11.177s
user 0m3.198s
sys 0m0.727s

Ого! В три раза быстрее, чем с AnyEvent::HTTP и YADA (я все примеры прогонял по 5 раз, разброс результатов был в пределах 1-2 секунд). Теперь посмотрим, что с бенчмарком и потреблением памяти.

#!/usr/bin/env perl

use strict;
use warnings;
use Memchmark;
use Benchmark;
use AnyEvent;
use AnyEvent::HTTP;
use Mojo::UserAgent;
use YADA;
use WWW::Curl::Easy;
use WWW::Curl::Multi;

my @url;
$url[$_] = "https://example.com/$_" for ( ( 0 .. 999 ) );

my $html;

my $url = 'https://example.com';

my %cmp_hash = (
    'mojo' => sub {
        my $ua = Mojo::UserAgent->new();
        Mojo::IOLoop->delay(
            sub {
                my $delay = shift;
                for my $url (@url) {
                    $ua->get( $url => $delay->begin );
                }
            },
            sub {
                my ($delay) = shift;
                for my $r (@_) {
                    $html = $r->res->body;
                }
            }
        )->wait;
    },
    'anyevent' => sub {
        my $cv = AnyEvent->condvar;

        for my $url (@url) {
            $cv->begin;
            http_get(
                $url,
                sub {
                    $html = $_[0];
                    $cv->end;
                }
            );
        }
        $cv->wait;
    },
    'curl' => sub {
        my $curlm = WWW::Curl::Multi->new;
        my $html;
        my %easy;
        my $active_handles = 1000;

        for my $i ( ( 1 .. $active_handles ) ) {
            my $curl = WWW::Curl::Easy->new;
            $easy{$i} = $curl;

            $curl->setopt( CURLOPT_PRIVATE,   $i );
            $curl->setopt( CURLOPT_URL,       $url );
            $curl->setopt( CURLOPT_WRITEDATA, \$html );
            $curlm->add_handle($curl);
        }
        while ($active_handles) {
            my $active_transfers = $curlm->perform;
            if ( $active_transfers != $active_handles ) {
                while ( my ( $id, $return_value ) = $curlm->info_read ) {
                    if ($id) {
                        $active_handles--;
                        delete $easy{$id};
                    }
                }
            }
        }
    },
    'yada' => sub {
        YADA->new->append( [@url] => sub { $html = ${ $_[0]->data }; } )->wait;
    },
);

Memchmark::cmpthese(%cmp_hash);
Benchmark::cmpthese( 100, \%cmp_hash );

Результат:

$ time ./benchmark_async.pl
test: anyevent, memory used: 8114176 bytes
test: curl, memory used: 2878337024 bytes
test: mojo, memory used: 17453056 bytes
test: yada, memory used: 18440192 bytes
         s/iter     curl     yada     mojo anyevent
curl       62.2       --     -86%     -97%     -97%
yada       8.48     634%       --     -77%     -80%
mojo       1.99    3030%     327%       --     -13%
anyevent   1.73    3488%     389%      15%       --

real    301m51.671s
user    121m48.920s
sys    4m14.271s

Здесь я ограничился всего 100 итерациями, потому как 1000 выполнялись более суток, и я не дождался результата. В итоге 400 000 запросов пролетели за 301 минуту, и как видно, больше всех потребляет память вариант с curl, а меньше всех AnyEvent, причем вариант с Mojo в 2 разa больше чем AnyEvent. По скорости же Mojo и AnyEvent выполняются почти одинаково, Mojo даже чуток уступает AnyEvent. Однако я запустил скрипты для Mojo и AnyEvent на 100 000 урлов вместо 1000, и вот что получилось:

$ time ./anyevent.pl

real    59m3.158s
user    6m8.883s
sys    0m28.461s

$ time ./mojo_async.pl

real    1m20.387s
user    1m11.533s
sys    0m2.759s

Разница огромная. Отсюда предполагаю что у Mojo медленно создается событийная петля, но запросы идут гораздо быстрей, хотя и потребляют гораздо больше памяти.

В следующей статье попробую разобраться с парсерами.

Тигран Оганесян


Ближайшие конференции | Содержание | Perl 6 для веба
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье