← На главную

Очередная поделка на Erlang: ibrowse + gzip

Возможно, я плохо искал, но что-то мне не удалось найти для Erlang HTTP-клиента с поддержкой gzip. В этой заметке я продемонстрирую небольшую поделку, которая умеет получать gzip’ованные данные, используя библиотеки zlib и ibrowse.

Мы с вами уже использовали ibrowse, когда парсили выдачу Google на Erlang. Посылка запроса осуществлялась очень просто:

case ibrowse:send_req(Url, [], get) of { ok, "200" , _Headers, Data } -> parse_serp(Data); Rslt -> io:format("Request failed: ~p~n", [Rslt]) end.

Для того, чтобы принимать данные в gzip, достаточно добавить к запросу заголовок Accept-Encoding: gzip, а при получении ответа проверять, не пришел ли Content-Encoding: gzip и, если это так, распаковывать данные с помощью библиотеки zlib:

case ibrowse:send_req(Url, [{"Accept-Encoding", "gzip"}], get) of { 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. Вот как примерно это выглядит:

-module(ibrowse_get_stream). -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, в чем можно убедиться следующим образом:

wget --header 'Accept-Encoding: gzip' -S http://mail.ru -O - | \ gunzip > mail.html

Утилита gunzip все успешно распаковывает, но в конце выводит:

gunzip: truncated input

Почему на Мейле так сделано и как должен быть написан код, чтобы обрабатывать подобную ситуацию, мне неизвестно.

Дополнение: HTTP-прокси на Erlang, позволяющий, помимо прочего, сжимать данные gzip’ом.