Elixir で進捗状況を表示しながらダウンロードする方法について検討します。
やりたいこと
Bumblebee を使っているときにファイルをダウンロードするとこういうダウンロード進捗表示がでます。これをやってみたいです。
Bumblebee のコード
プライベートの Bumblebee.Utils.HTTP モジュールにダウンロード関連のコードがありました。Erlang の httpc モジュールと ProgressBar パッケージを使って実装されています。
ちなみに httpc の使い方は Elixir Forum にまとめられています。
同じように httpc モジュールを使って実装しても良いのですが、個人的に日頃よく利用する Req を使って1から自分で実装してみようと思います。
準備
IEx を開きます。
iex
今回のデモで使用するパッケージを以下のとおりインストールします。
Mix.install([
{:req, "~> 0.5.0"},
{:progress_bar, "~> 3.0.0"},
{:bumblebee, "~> 0.5.0"}
])
ここでは Elixir のロゴ画像のデータをダウンロードの対象とします。
source_url = "https://elixir-lang.org/images/logo/logo.png"
progress_bar
ProgressBar パッケージはプログレスバーのアニメーションを提供します。最大値と現在の値はプログラマーが渡します。
{current, max} = {8, 10}
ProgressBar.render(current, max)
いろんなオプションを渡して見た目を自由に変更することも可能です。
[99..44, 44..77, 77..0]
|> Enum.concat()
|> Enum.each(fn i ->
ProgressBar.render(i, 100,
bar: " ",
bar_color: [IO.ANSI.yellow_background()],
blank_color: [IO.ANSI.red_background()]
)
Process.sleep(22)
end)
Req をつかって進捗表示なしにダウンロード
まずは、 Req をつかって進捗表示なしにダウンロードしてみます。
# バイナリデータとしてダウンロード
<<_::binary>> = Req.get!(source_url).body
データをローカルファイルに保存したい場合は :into
オプションに保存先のファイルストリームを指定します。
destination_path = Path.join(System.tmp_dir!(), "elixir_logo.png")
# ダウンロードしてファイルに保存
Req.get!(source_url, into: File.stream!(destination_path))
# ちゃんと読み込めるか検証
File.read!(destination_path)
進捗表示を追加するにはどうしたら良いのでしょうか。Bumblebee のコードから ProgressBar パッケージを利用できることはすでにわかっています。それをどのように Req と連携させるかを調べます。
Req の構成要素(3 つ)
Req は 3 つの主要部分で構成されています。
- Req - 高階層の API
- Req.Request - 低階層の API とリクエスト構造体
- Req.Steps - ひとつひとつの処理
カスタマイズは比較的容易にできそうです。
Req.Steps.run_finch/1
Req.Steps.run_finch/1 に手を加えることにより、リクエストのロジックを変更できることがわかりました。ドキュメントにわかりにくい部分がありますが、サンプルコードを読んでみて高階層の API に :finch_request
オプションに関数を注入して Req.Steps.run_finch/1 ステップを入れ替えることができるようです。
Finch とは 初期設定の Req が依存する HTTP クライアントだそうです。さらに Finch は Mint と NimblePool を使って性能を意識して実装されているそうです。
余談ですが、Elixir の関数に「闘魂」を注入する方法については以下の@torifukukaiou さんの記事がおすすめです。
Req をつかって進捗表示付きダウンロードしてみる
このような形になりました。ポイントをいくつかあげます。
-
Req.get/2 に
:finch_request
オプションとしてリクエストを処理するカスタムロジック(関数)を注入します。 - Finch.stream/5 でリクエストの多重化が可能です。ストリームという概念に疎いので 「WEB+DB PRESS Vol.123」 を読み返しました。「イーチ、ニィー、サン、ぁッ ダー!!!」
- ストリームからは 3 パターンのメッセージが返ってくるようです。
-
{:status, status}
- the status of the http response -
{:headers, headers}
- the headers of the http response -
{:data, data}
- a streaming section of the http body
-
- 進捗表示に必要な情報はふたつ。
- データ全体のバイト数
- 受信完了したバイト数
- 進捗状況は記憶しておく必要があるので、Req.Response の
:private
フィールドに格納し、データを受信するたびに更新します。
defmodule MNishiguchi.Utils.HTTP do
def download!(source_url, req_options \\ []) do
Req.get!(source_url, [finch_request: &finch_request/4] ++ req_options).body
end
defp finch_request(req_request, finch_request, finch_name, finch_options) do
acc = Req.Response.new()
case Finch.stream(finch_request, finch_name, acc, &handle_finch_stream/2, finch_options) do
{:ok, response} -> {req_request, response}
{:error, exception} -> {req_request, exception}
end
end
defp handle_finch_stream({:status, status}, response), do: %{response | status: status}
defp handle_finch_stream({:headers, headers}, response) do
req_headers = headers |> Enum.map(fn {k, v} -> {k, List.wrap(v)} end) |> Map.new()
total_size = req_headers |> Map.fetch!("content-length") |> List.first() |> String.to_integer
response
|> Map.put(:headers, req_headers)
|> Map.put(:private, %{total_size: total_size, downloaded_size: 0})
end
defp handle_finch_stream({:data, data}, response) do
new_downloaded_size = response.private.downloaded_size + byte_size(data)
ProgressBar.render(new_downloaded_size, response.private.total_size, suffix: :bytes)
response
|> Map.update!(:body, &(&1 <> data))
|> Map.update!(:private, &%{&1 | downloaded_size: new_downloaded_size})
end
end
以上のコードを IEx でランしてみます。
MNishiguchi.Utils.HTTP.download!(source_url)
|=== | 4% (1.36/34.95 KB)
|======= | 8% (2.73/34.95 KB)
|========== | 12% (4.10/34.95 KB)
|============= | 16% (5.47/34.95 KB)
|================ | 20% (6.84/34.95 KB)
|=================== | 23% (8.20/34.95 KB)
|====================== | 27% (9.57/34.95 KB)
|========================= | 31% (10.94/34.95 KB)
|============================ | 35% (12.31/34.95 KB)
|=================================== | 43% (15.04/34.95 KB)
|====================================== | 47% (16.41/34.95 KB)
|========================================= | 51% (17.78/34.95 KB)
|============================================= | 55% (19.15/34.95 KB)
|================================================ | 59% (20.52/34.95 KB)
|=================================================== | 63% (21.88/34.95 KB)
|====================================================== | 67% (23.25/34.95 KB)
|========================================================= | 70% (24.62/34.95 KB)
|============================================================ | 74% (25.99/34.95 KB)
|=============================================================== | 78% (27.36/34.95 KB)
|================================================================== | 82% (28.72/34.95 KB)
|====================================================================== | 86% (30.09/34.95 KB)
|========================================================================= | 90% (31.46/34.95 KB)
|============================================================================ | 94% (32.83/34.95 KB)
|=======================================================================================| 100% (34.95 KB)
:ok
Livebook でやるともっといい感じに進捗状況が更新されるはずです。
Bumblebee.Utils.HTTP.download/2
Bumblebee を使っているのであれば、Bumblebee.Utils.HTTP.download/2
で同じようなことができます。ドキュメントには載ってませんが利用可能です。
Bumblebee.Utils.HTTP.download(source_url, destination_path)
Nerves Livebook
せっかくいい感じのコードが書けたので Nerves Livebook に寄贈いたしました。よかったら遊んでみてください。
Elixir コミュニティ
本記事は以下のモクモク會での成果です。みなさんから刺激と元氣をいただき、ありがとうございました。