エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Phoenix LiveView の assign_async と async_result

Phoenix LiveView で値を socket に assign するとき、その値が例えば Web API などで取得しなければならないとき、一旦待ち状態を設定して、それから Task.async などを利用して、結果が得られたら取得できた値を設定し直すという非同期の処理を行うわけですが。

そういった使い方が多かったためなのか、 LiveView 0.20.0 では非同期で assign をおこなう Phoenix.LiveView.assign_async/3 と、その状態を表示する Phoenix.Component.async_result/1 が追加されていました。 9 月末に追加されていたのですが、すっかり見落としていました。

関数の追加に合わせて、ドキュメントにも一節が追加されています。

今後、繰り返し利用することになりそうなので、これらの使い方を確認してみました。

assign_async & async_result

基本的な使い方はそれほど難しくありません。

まず、値の設定には assign/3 の代わりに assign_async/3 を利用します。

第 2 引数は assign/3 と同じようにキーを指定しますが、第 3 引数には非同期で実行する関数を指定します。 その関数は、戻り値として {:ok, result}{:error, reason} の形の値を返す必要があります。 また result の値は assign_async/3 の第 2 引数に渡したキーを持つマップでなければなりません。

assign_async(socket, :foo, fn ->
  # 非同期で実行したい処理

  {:ok, %{foo: 42}} # 処理に成功したばあい、第 2 引数に渡したキーと同じキーを持つマップを返す
end)

assign_async/3 の第 3 引数に渡した関数の結果は async_result/1 で受けることができます。

async_result/1assign で指定したキーを指定し、:let で値を受け取ります。 内部のブロックは関数が成功して値を返するまで表示されません。

また async_result/1:loading:failed の 2 つのスロットを持っています。

:loading は関数が完了するまでのあいだ表示さるものです。

:failed は関数がエラーを返したときに表示されます。 エラーの内容は :let で受けることができます。 内容は関数の戻り値そのままになっています。

これらのスロットは省略可能です。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading>waiting...</:loading>
      <:failed :let={ {:error, reason} }><%= reason %></:failed>
    </.async_result>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async(:result, fn ->
        Process.sleep(2_000)     # 時間のかかる処理の代わり
        case :rand.uniform(2) do # 2 分の 1 で成功/失敗を返す
          1 -> {:ok, %{result: 42}}
          2 -> {:error, "no result"}
        end
      end)

    {:ok, socket}
  end
end

ちなみに。 関数の戻り値をマップにしなければならない理由は、一回の結果で複数の値を返せるようにするためのようです。 次の例ように assign_async/3 の第 2 引数はリストで複数のキーを指定することができ、そのキーごとに async_result/1 を記述することができます。

  def render(assigns) do
    ~H"""
    <.async_result :let={foo} assign={@foo}><%= foo %></.async_result>
    <.async_result :let={bar} assign={@bar}><%= bar %></.async_result>
    <.async_result :let={baz} assign={@baz}><%= baz %></.async_result>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async([:foo, :bar, :baz], fn ->
        Process.sleep(2_000)
        {:ok, %{foo: "Foo", bar: "Bar", baz: "Baz"}}
      end)
    {:ok, socket}
  end

ページを表示したあとに、ページ上のイベントで処理を実行したいばあいも assign_async/3 が利用できます。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading>waiting...</:loading>
      <:failed :let={{:error, reason}}><%= reason %></:failed>
    </.async_result>

    <div>
      <.button phx-click="rerun">再実行</.button>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_async(:result, &run/0)

    {:ok, socket}
  end

  # 再実行イベントのハンドラ
  def handle_event("rerun", _params, socket) do
    socket =
      socket
      |> assign_async(:result, &run/0)

    {:noreply, socket}
  end

  # 再実行イベントでも利用するので、独立した関数に分離した
  defp run do
    Process.sleep(2_000)

    case :rand.uniform(2) do
      1 -> {:ok, %{result: 42}}
      2 -> {:error, "no result"}
    end
  end
end

Phoenix.LiveView.AsyncResult

ちなみに assign_async/3 で割り当てられた値はどのようになっているかというと。

rener/1 の中に <%= inspect(@result) %> を挿入するなどしてその値を覗いてみます。

  • 実行中
%Phoenix.LiveView.AsyncResult{ok?: false, loading: [:result], failed: nil, result: nil}
  • 成功
%Phoenix.LiveView.AsyncResult{ok?: true, loading: nil, failed: nil, result: 42}
  • 失敗
%Phoenix.LiveView.AsyncResult{ok?: false, loading: nil, failed: {:error, "no result"}, result: nil}

構造体 Phoenix.LiveView.AsyncResult に値が格納されていることがわかります。

このことを理解しておくことが、もう一つの関数を利用するときに重要になります。

start_async & handle_async

今回のバージョンアップで、 assign_async/3async_result/1 に加えてもう一つ、関数 start_async/3 が追加されています。

start_async/3 を使うと assign_async/3 と比べべてより細かな制御ができるようになっています。

その代わり、構造体 Phoenix.LiveView.AsyncResult のキーへの割り当てと、結果を反映するためにハンドラ handle_async/3 は自分で記述しなければなりません。

start_async/3

まず、 Phoenix.LiveView.AsyncResult.loading/0-1 を使ってキーに割り当てる値を作成します。 ここで引数に任意の値を渡すことができます。

assign(socket, :result, Phoenix.LiveView.AsyncResult.loading())

# もしくは、引数に任意の値を指定する
assign(socket, :result, Phoenix.LiveView.AsyncResult.loading("実行中"))

引数で渡した値は、スロット :loading:let を使って受け取ることができます。

<:loading :let={loading}><%= loading %></:loading>

キーへの割り当てができたら、 start_async/3 で非同期処理を実行します。

    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("実行中"))
      |> start_async(:result, &run/0)

なお、今回は使用しませんが、構造体の値を更新する loading/2 も用意されています。 非同期処理を多段階で実行するときに活用できそうです。

socket
|> assign(:result, Phoenix.LiveView.AsyncResult.loading(1))

...

socket
|> update(:result, &Phoenix.LiveView.AsyncResult.loading(&1, &1.loading + 1))
<:loading :let={step}>ステップ <%= step %> を実行中</:loading>

handle_async/3

結果は、ハンドラ handle_async/3 で受け取ります。 第 1 引数には assign したキーを指定します。

タスク成功時

注意が必要なのは第 2 引数で、ここには非同期処理を実行したタスクの結果が渡されてきます。 {:ok, task_result} の形で受け取る値はタスクの実行結果であり、処理の実行結果ではありません。 処理の実行結果は task_result の内容を調べる必要があります。

結果を表示に反映するには、最初に割り当てた値を ok/2 または failed/2 を使って更新します。

ハンドラの全体は次のようになります。 成功時、失敗時、それぞれ扱う値がやや込み入っているので注意が必要です。

  def handle_async(:result, {:ok, task_result}, socket) do
    socket =
      case task_result do
        {:ok, %{result: result}} ->
          # 関数の成功時の処理
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.ok(&1, result))

        {:error, _reason} = error ->
          # 関数の失敗時の処理
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, error))
      end

    {:noreply, socket}
  end

タスク失敗時

handle_async/3 の第 2 引数はタスクの実行結果なので、処理が完了したときの結果は :ok のタプルで渡されてきますが、例外などで処理が完了しなかったばあいには :exit のタプルが渡されてきます。

そのときの第 2 引数は {:exit, reason} という形になり、原因が例外だったばあいには result{exception, stacktrace} の形になります。

ハンドリングした例外の内容は failed/2 で設定して表示することができます。

  def handle_async(:result, {:exit, {exception, _stacktrace}}, socket) do
    socket =
      socket
      |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, {:error, Exception.message(exception)}))

    {:noreply, socket}
  end

まとめ

コードの全体はこのようになりました。

defmodule MyAppWeb.PageLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <.async_result :let={result} assign={@result}>
      result = <%= result %>
      <:loading :let={loading}><%= loading %></:loading>
      <:failed :let={{:error, reason}}><%= reason %></:failed>
    </.async_result>

    <div>
      <.button phx-click="rerun">再実行</.button>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("実行中"))
      |> start_async(:result, &run/0)

    {:ok, socket}
  end

  # 再実行イベントのハンドラ
  def handle_event("rerun", _params, socket) do
    socket =
      socket
      |> assign(:result, Phoenix.LiveView.AsyncResult.loading("再実行中"))
      |> start_async(:result, &run/0)

    {:noreply, socket}
  end

  # 関数完了イベントのハンドラ
  def handle_async(:result, {:ok, task_result}, socket) do
    socket =
      case task_result do
        {:ok, %{result: result}} ->
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.ok(&1, result))

        {:error, _reason} = error ->
          socket
          |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, error))
      end

    {:noreply, socket}
  end

  # 例外発生時のハンドラ
  def handle_async(:result, {:exit, {exception, _stacktrace}}, socket) do
    socket =
      socket
      |> update(:result, &Phoenix.LiveView.AsyncResult.failed(&1, {:error, Exception.message(exception)}))

    {:noreply, socket}
  end

  defp run do
    Process.sleep(2_000)

    case :rand.uniform(3) do # 3 分の 1 で成功/失敗/例外を返す
      1 -> {:ok, %{result: 42}}
      2 -> {:error, "no result"}
      3 -> raise "Boom"
    end
  end
end

いつか読むはずっと読まない:恐竜前、恐竜後

恐竜前の単弓類の時代と、恐竜後の単弓類の時代。