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

18 декабря 2012

Возможно, я плохо искал, но что-то мне не удалось найти для 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’ом.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.