9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Elixirで進捗表示ダウンロード

Last updated at Posted at 2023-05-22

Elixir で進捗状況を表示しながらダウンロードする方法について検討します。

Run in Livebook

やりたいこと

Bumblebee を使っているときにファイルをダウンロードするとこういうダウンロード進捗表示がでます。これをやってみたいです。

Bumblebee のコード

プライベートの Bumblebee.Utils.HTTP モジュールにダウンロード関連のコードがありました。Erlang の httpc モジュールと ProgressBar パッケージを使って実装されています。

ちなみに httpc の使い方は Elixir Forum にまとめられています。

同じように httpc モジュールを使って実装しても良いのですが、個人的に日頃よく利用する Req を使って1から自分で実装してみようと思います。

準備

IEx を開きます。

ターミナル
iex

今回のデモで使用するパッケージを以下のとおりインストールします。

IEx
Mix.install([
  {:req, "~> 0.5.0"}, 
  {:progress_bar, "~> 3.0.0"}, 
  {:bumblebee, "~> 0.5.0"}
])

ここでは Elixir のロゴ画像のデータをダウンロードの対象とします。

IEx
source_url = "https://elixir-lang.org/images/logo/logo.png"

progress_bar

ProgressBar パッケージはプログレスバーのアニメーションを提供します。最大値と現在の値はプログラマーが渡します。

IEx
{current, max} = {8, 10}
ProgressBar.render(current, max)

いろんなオプションを渡して見た目を自由に変更することも可能です。

IEx
[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 をつかって進捗表示なしにダウンロードしてみます。

IEx
# バイナリデータとしてダウンロード
<<_::binary>> = Req.get!(source_url).body

データをローカルファイルに保存したい場合は :into オプションに保存先のファイルストリームを指定します。

IEx
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 クライアントだそうです。さらに FinchMint  と  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 フィールドに格納し、データを受信するたびに更新します。
IEx
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 でランしてみます。

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 でやるともっといい感じに進捗状況が更新されるはずです。

Run in Livebook

Bumblebee.Utils.HTTP.download/2

Bumblebee を使っているのであれば、Bumblebee.Utils.HTTP.download/2 で同じようなことができます。ドキュメントには載ってませんが利用可能です。

IEx
Bumblebee.Utils.HTTP.download(source_url, destination_path)

Nerves Livebook

せっかくいい感じのコードが書けたので Nerves Livebook に寄贈いたしました。よかったら遊んでみてください。

Run in Livebook

Elixir コミュニティ

本記事は以下のモクモク會での成果です。みなさんから刺激と元氣をいただき、ありがとうございました。

9
2
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?