Очередная поделка на Erlang: ibrowse + gzip
18 декабря 2012
Возможно, я плохо искал, но что-то мне не удалось найти для Erlang HTTP-клиента с поддержкой gzip. В этой заметке я продемонстрирую небольшую поделку, которая умеет получать gzip’ованные данные, используя библиотеки zlib и ibrowse.
Мы с вами уже использовали ibrowse, когда парсили выдачу Google на Erlang. Посылка запроса осуществлялась очень просто:
{ ok, "200" , _Headers, Data } ->
parse_serp(Data);
Rslt ->
io:format("Request failed: ~p~n", [Rslt])
end.
Для того, чтобы принимать данные в gzip, достаточно добавить к запросу заголовок Accept-Encoding: gzip
, а при получении ответа проверять, не пришел ли Content-Encoding: gzip
и, если это так, распаковывать данные с помощью библиотеки zlib:
{ ok, "200" , Headers, Data } ->
case proplists:get_value("Content-Encoding", Headers) of
"gzip" ->
parse_serp(zlib:gunzip(Data));
_ContentEncoding ->
parse_serp(Data)
end;
Rslt ->
io:format("Request failed: ~p~n", [Rslt])
end.
Жизнь прекрасна и удивительна, расходимся по домам. Правда, есть нюанс.
Что, если мы получим по HTTP 10 Гб данных? А если их окажется 10 Гб после распаковки? Придет OOM Killer и начнет наводить порядок. Для решения этой проблемы можно попросить ibrowse писать ответ сервера в файл с помощью опции save_response_to_file
, но это не очень поможет в контексте текущей задачи (распаковать gzip).
К счастью, ibrowse позволяет обрабатывать данные частями, прямо по мере их получения. Как и библиотека zlib. Вот как примерно это выглядит:
-export([main/0, main/1]).
% copy-pasted from /usr/lib/erlang/lib/erts-5.9.1/src/zlib.erl
-define(MAX_WBITS, 15).
main() ->
io:format("Usage: get ~n").
main([UrlAtom|[OutFileAtom|_]]) ->
UrlString = atom_to_list(UrlAtom),
io:format("Fetching ~s ...~n", [UrlString]),
OutFileString = atom_to_list(OutFileAtom),
{ ok, Fid } = file:open(OutFileString, [ write ]),
ibrowse:start(),
{ibrowse_req_id, _RequestId} = ibrowse:send_req(
UrlString,
[ {"Accept-Encoding", "gzip"} ], get, [],
[ { stream_to, self() }, { response_format, binary } ],
infinity
),
receive_loop(
Fid,
fun(_Data) -> io:format("Error: undefined processor!~n") end,
fun() -> io:format("Error: undefined finalizer!~n") end
),
file:close(Fid);
main(_) ->
main().
receive_loop(Fid, Processor, Finalizer) ->
receive
{ ibrowse_async_headers, _RequestId, _Code, Headers } ->
{ NewProcessor, NewFinalizer } =
case proplists:get_value("Content-Encoding", Headers) of
"gzip" ->
io:format("Gzipped data received~n"),
ZlibStream = zlib:open(),
ok = zlib:inflateInit(ZlibStream, 16+?MAX_WBITS),
{
fun(Data) ->
Decompressed = zlib:inflate(ZlibStream, Data),
file:write(Fid, Decompressed)
end,
fun() ->
ok = zlib:inflateEnd(ZlibStream),
zlib:close(ZlibStream)
end
};
ContentEncoding ->
io:format(
"Plaintext data received (ContentEncoding = ~p)~n",
[ContentEncoding]
),
{ fun(Data) -> file:write(Fid, Data) end, fun() -> ok end }
end,
receive_loop(Fid, NewProcessor, NewFinalizer);
{ ibrowse_async_response, _RequestId, Data } ->
io:format("Data received, time = ~p~n", [time()]),
Processor(Data),
receive_loop(Fid, Processor, Finalizer);
{ ibrowse_async_response_end, _RequestId } ->
Finalizer()
end.
С помощью опции { stream_to, self() }
мы говорим ibrowse присылать нам сообщения с данными по мере их получения. Затем эти сообщения принимаются в функции receive_loop/3
. При получении заголовков мы определяем, сжаты данные с помощью gzip или не сжаты, и в зависимости от этого определяем две функции — Processor и Finalizer. Первая отвечает за распаковку данных, если они запакованы, вторая — за освобождение ресурсов, если они были выделены. Лямбды и замыкания… ммм, красота!
Функция zlib:inflate/2
в действительности возвращает iolist()
, а не binary()
, как можно было бы ожидать. В приведенном коде после распаковки данные сразу передаются в функцию file:write/2
, поэтому все прекрасно работает. Однако если вы ожидаете получить именно binary()
, придется воспользоваться функцией iolist_to_binary/1
.
Обратите внимание на последний аргумент функции ibrowse:send_req/6
, атом infinity
. По умолчанию ibrowse ждет данные от сервера в течение 30-и секунд и, если после этого периода передача не завершится, тихо-мирно умирает. Функция receive_loop/3
не получит никакого сообщения, а в консоль ничего не будет выведено, даже если вы вызвали ibrowse:trace_on/0
. Вы разве что можете воспользоваться timer:send_after/2
. Передача infinity
последним аргументом в ibrowse:send_req/6
меняет это поведение, данные будут получаться столько времени, сколько потребуется.
Опция { response_format, binary }
говорит ibrowse присылать данные в виде binary()
, а не списка байт. Согласно моим замерам, в этом случае передача осуществляется чуточку быстрее.
По умолчанию ibrowse отправляет сообщения асинхронно. Я обнаружил, что если синхронизировать отправку и получение сообщений с помощью опции { stream_to, { self(), once }}
, а также вызовов ibrowse:stream_next/1
и ibrowse:stream_close/1
, передача осуществляется быстрее. Знающие люди говорят, что это может быть связано со следующим. По умолчанию перед отправкой данные буферизируются, что приводит к дополнительным накладным расходам. Об этом можно судить по тому, что в случае использования синхронного подхода сообщения от ibrowse приходят чаще и общее их количество больше. Синхронность хороша еще и тем, что она исключает возможность переполнения очереди сообщений.
Полную версию кода вы можете посмотреть в этом архиве. По скорости он не сильно уступает связке wget с gunzip. Во время его тестирования я обратил внимание на один интересный момент. Код прекрасно работает со всеми сайтами, на которых я его проверял, за исключением mail.ru. Расследование показало, что mail.ru отдает не совсем корректный gzip, в чем можно убедиться следующим образом:
gunzip > mail.html
Утилита gunzip все успешно распаковывает, но в конце выводит:
Почему на Мейле так сделано и как должен быть написан код, чтобы обрабатывать подобную ситуацию, мне неизвестно.
Дополнение: HTTP-прокси на Erlang, позволяющий, помимо прочего, сжимать данные gzip’ом.
Метки: Erlang, Функциональное программирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.