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

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

Phoenix 1.7 とともに LiveView Streams がやってきた

Elixir の Web フレームワークである Phoenix Framework に、待望の 1.7 がやってきました。

phoenixframework.org

やはり今回のバージョンの最大のポイントは、Controller を用いた静的な View と LiveView のテンプレートが、コンポーネントという形で統合されたところだと思います。 これで同じ部品を Controller でも LiveView でも利用できるようになりました。

これについては、いろいろな人がいろいろな記事を書いてくださると思うので、わたしは別のポイントに注目したいと思います。

リリース記事のタイトルにもある「LiveView Streams」です。

これまでの LiveView は要素の削除が苦手だった

苦手というよりも、フレームワークとして専用の機能が提供されていなかった、が正確かもしれません。 フレームワークが持つ機能を組み合わせ、たりない部分をコードで補うことで実現しなければなりませんでした。

このことについては 2020年12月に記事にも書いています。 実に2年以上前。

blog.emattsan.org

コレクションを扱う

上記のような削除の問題は、ある集まり、コレクションを扱うときに重大になってきます。 データベースを扱うケースは、まさにそのようなケースです。 Web アプリケーションのフレームワークとして「ちょっと遅れをとっている」と、個人的に感じていた部分でした。

それも、今回のバージョンアップで、それも解消されたのです。

内部的には、コレクションを抱え込むモジュールが定義され、挿入や削除の操作が標準装備されました。 フロントエンド側にも対応する操作が実装されているため、最小限のデータのやり取りで、コレクションの要素の挿入や一部の更新や削除ができるようになっています。

さっそくその恩恵を、簡単なコードで見てゆきます。

サンプルアプリケーション MyAPP

まずは mix phx.new コマンドを最新の 1.7 に更新して、サンプルのプロジェクトを作成してください。

$ mix archive.install hex phx_new
$ mix phx.new my_app
$ cd my_app

データを扱うモジュール

本格的にデータベースを利用してもよいのですが、簡単に済ませたいのと、データベースなどに影響されない本質だけを見てゆきたいので、データを扱うモジュールを一から書いてゆきます。

ここで MyApp.Items.Item はデータを格納する構造体、 MyApp.Items はデータを扱うモジュールです。 MyApp.Items.Item のリストを LiveView Stream のコレクションとするわけですが、コレクションの要素になるデータには :id という名前のキーが必要になるようです(要素自体に ID を持たせずに済ませる方法もありますが今回は省略)。

また LiveView Streams の効果がわかりやすいように、 MyApp.Items を使ったデータの更新は非同期で、結果はメッセージで受け取るようにしました。 結果を受け取りたいプロセスは、MyApp.Items.subscribe_items/0 で購読登録をし、更新時にブロードキャストされるメッセージを処理するようにします。

defmodule MyApp.Items.Item do
  defstruct [:id, :name]
end
defmodule MyApp.Items do
  use GenServer

  alias MyApp.Items.Item

  @name __MODULE__

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: @name)
  end

  def subscribe_items do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "items")
  end

  def list_items do
    GenServer.call(@name, :list_items)
  end

  def create_item do
    GenServer.cast(@name, :create_item)
  end

  def delete_item(id) when is_binary(id) do
    delete_item(String.to_integer(id))
  end

  def delete_item(id) do
    GenServer.cast(@name, {:delete_item, id})
  end

  def init(opts) do
    size = opts[:size] || 10

    items =
      1..size
      |> Enum.map(fn id ->
        %Item{id: id, name: "item-#{id}"}
      end)

    {:ok, %{items: items, next_id: size + 1}}
  end

  def handle_call(:list_items, _from, %{items: items} = state) do
    {:reply, items, state}
  end

  def handle_cast(:create_item, %{items: items, next_id: next_id} = state) do
    created_item = %Item{id: next_id, name: "item-#{next_id}"}

    Phoenix.PubSub.broadcast(MyApp.PubSub, "items", {:item_created, created_item})

    {:noreply, %{state | items: items ++ [created_item], next_id: next_id + 1}}
  end

  def handle_cast({:delete_item, id}, %{items: items} = state) do
    case Enum.split_with(items, &(&1.id == id)) do
      {[deleted_item], rest} ->
        Phoenix.PubSub.broadcast(MyApp.PubSub, "items", {:item_deleted, deleted_item})
        {:noreply, put_in(state.items, rest)}

      _ ->
        {:noreply, state}
    end
  end
end

最後に、アプリケーションの起動時にプロセスを起動するため MyApp.Application の children に MyApp.Items を追加します。

defmodule MyApp.Application do
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      MyApp.Items, # 追加
      # ...
    ]

    # ...
  end
end

LiveView 本体

次にデータを表示する LiveView のモジュールを追加します。 コレクションの要素を一覧するだけのシンプルなものです。

最上部に表示されている「create」ボタンで要素を追加し、各要素欄にある「delete」ボタンでその要素を削除します。 データのところで説明したように処理を非同期にしているので、ボタンを押した時の処理は MyApp.Items.Item の関数を呼び出すだけで、表示の更新はブロードキャストされたメッセージを受け取った時に行うようにしています。

ここでポイントは 4 つ。

  1. コレクションの割り当てに Phoenix.IiveView.stream/4 を使う
  2. 要素の構築は :for={{dom_id, item} <- @streams.items} id={dom_id} のように、@streams.items から ID とデータのペアを取り出し、ID を設定する
  3. 要素の追加に Phoenix.IiveView.stream_insert/4 を使う
  4. 要素の削除に Phoenix.IiveView.stream_delete/3 を使う

ちなみに、要素の更新は Phoenix.IiveView.stream_insert/4 で行います。 ID が同じ要素があるばあいは更新し、ないばあいは挿入するという動きをします。 そのために ID の指定が必須になっています。

2年あまり前に苦戦した要素の削除が、わずか一行ですんでしまいました。 またコレクションの更新を非同期の PubSub で実現したので、すべての LiveView で同時に要素の追加や削除が行われます。

defmodule MyAppWeb.ItemLive do
  use MyAppWeb, :live_view

  alias MyApp.Items
  alias MyApp.Items.Item

  def mount(_params, _session, socket) do
    items =
      if connected?(socket) do
        Items.subscribe_items()
        Items.list_items()
      else
        []
      end

    socket = stream(socket, :items, items)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <h1 class="text-4xl font-bold mb-2">Items</h1>
    <div class="my-2"><.button phx-click="create-item">create</.button></div>
    <div class="border rounded-lg grid grid-cols-1 divide-y">
      <div :for={{dom_id, item} <- @streams.items} id={dom_id} class="h-12 p-2 flex items-center">
        <span class="flex-1"><%= item.name %></span>
        <.button phx-click="delete-item" phx-value-id={item.id}>delete</.button>
      </div>
    </div>
    """
  end

  def handle_event("create-item", _params, socket) do
    Items.create_item()

    {:noreply, socket}
  end

  def handle_event("delete-item", %{"id" => id}, socket) do
    Items.delete_item(id)

    {:noreply, socket}
  end

  def handle_info({:item_created, created_item}, socket) do
    {:noreply, stream_insert(socket, :items, created_item)}
  end

  def handle_info({:item_deleted, deleted_item}, socket) do
    {:noreply, stream_delete(socket, :items, deleted_item)}
  end
end

ルーティング

最後にルーティングの追加です。

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  # ...

  scope "/", MyAppWeb do
    pipe_through :browser

    # ...

    live "/items", ItemLive # 追加
  end

  # ...
end

実行

mix phx.server コマンドでアプリケーションを起動し、ブラウザで http://localhost:4000/items を開くと、次のような一覧が表示されると思います。 「create」ボタンを押すと要素が一つ追加され、「delete」ボタンを押すとその要素が削除されます。 ブラウザの複数のウィンドウでこのページを開いて追加したり削除したりすると、連動するのが見て取れると思います。

動作としては「ただそれだけ」のことなのですが、ただそれだけのことを、ただこれでだけで実現できるインパクトは決して小さくありません。

LiveView Streams にかぎらず、今回の 1.7 に搭載された機能は Phoenix をもう一段高いレベルに押し上げた感じが、個人的にはしています。

余談

このサンプルコードを書き上げたあと、Elixir Forum の Phoenix 1.7 のリリースを告げるスレッドの中に、次の投稿を見つけて「やはり」と思ったのでした。

josevalim
The win for streams will come when pushing updates to LV via PubSub. It is not in the generated code but streams get us closer to that and adding the remaining PubSub bits should be easy.

elixirforum.com