Выпуск 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 для веба →