Elixir の Web フレームワークである Phoenix Framework に、待望の 1.7 がやってきました。
やはり今回のバージョンの最大のポイントは、Controller を用いた静的な View と LiveView のテンプレートが、コンポーネントという形で統合されたところだと思います。 これで同じ部品を Controller でも LiveView でも利用できるようになりました。
これについては、いろいろな人がいろいろな記事を書いてくださると思うので、わたしは別のポイントに注目したいと思います。
リリース記事のタイトルにもある「LiveView Streams」です。
これまでの LiveView は要素の削除が苦手だった
苦手というよりも、フレームワークとして専用の機能が提供されていなかった、が正確かもしれません。 フレームワークが持つ機能を組み合わせ、たりない部分をコードで補うことで実現しなければなりませんでした。
このことについては 2020年12月に記事にも書いています。 実に2年以上前。
コレクションを扱う
上記のような削除の問題は、ある集まり、コレクションを扱うときに重大になってきます。 データベースを扱うケースは、まさにそのようなケースです。 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 つ。
- コレクションの割り当てに
Phoenix.IiveView.stream/4
を使う - 要素の構築は
:for={{dom_id, item} <- @streams.items} id={dom_id}
のように、@streams.items
から ID とデータのペアを取り出し、ID を設定する - 要素の追加に
Phoenix.IiveView.stream_insert/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.