LoginSignup
9
2

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から実装してみようと思います。

まずは、 Req をつかって簡単なGETリクエストする方法から始めます。ここではElixir のロゴの画像データをダウンロードの対象とします。

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

progress_bar

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

IEx
Mix.install([{:progress_bar, "~> 3.0"}])

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

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

ローカルファイルとして保存したい場合は :output オプションで保存先を指定します。

destination_path = Path.join(System.tmp_dir!(), "elixir_logo.png")

# ダウンロードしてファイルに保存
Req.get!(source_url, output: 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 フィールドに格納し、データを受信するたびに更新します。
defmodule MNishiguchi.Utils.HTTP do
  def download(source_url, req_options \\ []) do
    case Req.get(source_url, [finch_request: &finch_request/4] ++ req_options) do
      {:ok, response} -> {:ok, response.body}
      {:error, exception} -> {:error, exception}
    end
  end

  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_message/2, finch_options) do
      {:ok, response} -> {req_request, response}
      {:error, exception} -> {req_request, exception}
    end
  end

  defp handle_message({:status, status}, response), do: %{response | status: status}

  defp handle_message({:headers, headers}, response) do
    total_size =
      Enum.find_value(headers, fn
        {"content-length", v} -> String.to_integer(v)
        {_k, _v} -> nil
      end)

    response
    |> Map.put(:headers, headers)
    |> Map.put(:private, %{total_size: total_size, downloaded_size: 0})
  end

  defp handle_message({: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(5)> 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 で同じようなことができます。ドキュメントには載ってませんが利用可能です。

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

Nerves Livebook

せっかくいい感じのコードが書けたので Nerves 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