Closed7

【Elixir】Phoenix/LiveView で作るシンプルアプリ

NanaoNanao

はじめに

最近 Elixir と Phoenix に入門したので学習用に WEB アプリケーションを作成しました。
これはその記録です。

今回作成したプロジェクトのソースコードは僕の Github リポジトリにあります。
これが誰かの参考になれば幸いです。

https://github.com/7oh2020/example-liveview-app

Elixir とは

https://elixir-lang.org/

Elixir は BEAM という Erlang VM 上で動作する関数型言語です。
軽量プロセスによる分散処理や高い対象外性、パターンマッチングやデータの不変性などによる高いスケーラビリティが特徴です。

他にも Elixir ではドキュメントやテストも重視されており豊富な補助機能が用意されています。
関数型プログラミングはとてもシンプルで、複雑なロジックも副作用のない小さな関数に分けることで読みやすくテストも容易になります。

Phoenix とは

https://www.phoenixframework.org/

Phoenix は Elixir で開発された WEB フレームワークです。
Laravel や Ruby on Rails のようなフルスタックアプリケーションはもちろん、他にもリアルタイムなチャットアプリも開発できます。
WEB フレームワークとしての機能は一通り備わっており、生成コマンドを使用して認証機能やコントローラを自動生成できます。
HEEx(HTML + Embedded Elixir)という独自のテンプレートによって作成された再利用可能なコンポーネントも特徴です。

LiveView とは

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html

LiveView はリアルタイムな Web UI が作成できる Phoenix の機能の 1 つです。
最初のページ読み込みは通常の HTTP で行われ、その後はサーバーとクライアントで通信が確立されます。そしてイベントが呼ばれる度に差分となる HTML がサーバー側でレンダリングされ自動的にクライアントに表示されます。
サーバーからはページ全体ではなく差分のみを送信するので従来の WEB アプリケーションと比較して少ない通信料で済みます。
Live ナビゲーションによるスムーズなページ遷移も体験が良いです。
クライアントとサーバーは基本的に非同期通信ですが、開発は Elixir と HEEX だけで行うため特に JavaScript を意識する必要はありません。

LiveView を使用すればリアルタイムに更新されるダッシュボードやチャットアプリなどが開発できます。
今回はこの LiveView を使用してサンプルアプリを開発します。

要件

今回はシンプルな SNS サンプルアプリを開発します。
機能は必要最小限ですが一通りのアソシエーションを含んでいるのでリプライ機能やフォロー機能などにも応用できると思います。

  • 機能リスト:
  • ユーザーは Post を投稿できる
  • Post は最大 200 文字の本文を持つ
  • Post にはアップロードした画像を 1 枚添付できる
  • ユーザーは Post にいいねできる
  • Post にいいねしたユーザーを一覧表示できる

開発方針

  • 時間短縮のためになるべくフレームワークの機能を使用する
  • 実装後はテストを書いて機能の動作を確認する
NanaoNanao

基本

以下はプロジェクトの作成から認証処理の生成、LiveView の概要やテストなど基本的な昨日をまとめています。

Phoenix 環境のセットアップ

VS Code の devcontainer にはありがたいことに Elixir + Phoenix + PostgreSQL + Node.js のテンプレートコンテナが用意されています。
一旦コンテナを起動してから.devcontainer/docker-compose.ymlを編集して Elixir と Phoenix のバージョン番号を最新に更新します。
その後コマンドパレットからコンテナのリビルドを実行すればバージョンアップされるはずです。

  • 執筆時点でのバージョンは以下の通りです:
  • Elixir: 1.15.4
  • Phoenix: 1.7.9
  • Node.js: 18
  • PostgreSQL: 15

プロジェクトの作成

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html

コンテナで Phoenix 環境がセットアップ済みの場合は以下のコマンドでプロジェクトを作成できます。
--installは依存ライブラリのインストール(mix deps.get)をするオプションです。

mix phx.new app --install

ちなみに以下のように--binary-idオプションを指定すると DB のプライマリキーが SERIAL から UUID に変わります。

mix phx.new app --install --binary-id

完了後 app ディレクトリにプロジェクトファイルが作成されているはずです。

cd app

データベースの作成

次に以下のコマンドでデータベースを作成します。
Phoenix は Ecto という DB ライブラリを内包しており、マイグレーション、スキーマ定義、バリデーション、クエリビルダなどのデータベース操作に関しては全て Ecto が担います。

mix ecto.create

参考までに Ecto の基本的なコマンドは以下の通りです。

# マイグレーションを適用する
mix ecto.migrate

# マイグレーションの適用を元に戻す
mix ecto.rollback

# マイグレーションを最初からやり直す
mix ecto.reset

# マイグレーションファイルを作成する
mix ecto.gen.migration create_xxx

開発サーバーの起動

以下のコマンドで開発サーバーを起動できます。
デフォルトではlocalhost:4000でトップページにアクセスできます。
開発サーバーの起動中はファイルを変更すると必要なファイルのみがコンパイルされます。

mix phx.server

以下のコマンドのように IEX と組み合わせて開発サーバーを起動しつつ IEX でインタラクティブな操作もできます。
テストデータを作成したり関数をデバッグしたりが手軽にできるので開発中はこちらの方がおすすめです。

iex -S mix phx.server

もしブラウザにPhoenix.Ecto.PendingMigrationErrorが表示される場合は DB のマイグレーションが必要です。

mix ecto.migrate

メモ: ダッシュボードについて

http://localhost:4000/dev/dashboard/homeにアクセスすると Phoenix のダッシュボードが表示されるはずです。

ダッシュボードではメモリや CPU の使用率、プロセス状態、リクエストなどデバッグに必要な情報を一括で監視できます。
画面は LiveView で作成されているためシームレスなページ遷移とリアルタイムな情報更新が体験できます。

ユーザーと認証機能の生成

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Auth.html

Phoenix はコマンド 1 つでユーザーと認証機能(メールアドレスとパスワードによる認証)を自動生成してくれます。
検証済みのテストコードも一緒に生成してくれるのも嬉しいです。

ユーザーと認証機能を自動生成するには以下のコマンドを実行します。
後ほど補足しますが Accounts、User、users はそれぞれコンテキストモジュール名、スキーマモジュール名、リソース名を表します。
また、今回は LiveView を使用するので--liveオプションを指定しています。

mix phx.gen.auth Accounts User users --live

続いて依存ライブラリのインストールを行います。

mix deps.get

最後にデータベースのマイグレーションを行います。

mix ecto.migrate

たったこれだけの操作でユーザーと認証機能の生成は完了です。
このように最初に生成コマンドで大まかにコード生成しておくと大幅な時間短縮になります。

メモ: 基本のルーティングについて

https://hexdocs.pm/phoenix/routing.html

プロジェクトのルート一覧は以下のコマンドで確認できます。

mix phx.routes

Phoenix には plug というミドルウェアのようなものがあり Cookie やトークンの検証などのリクエストの前処理が行なえます。
plug を目的別にグループ化したのが pipeline であり、それを scope と関連付けるのが pipe_through マクロです。


scope マクロはルートのグループ化を行います。
認証機能を生成するとスコープと plug がいくつか追加されているはずです。
pipe_through によりブラウザからのリクエストは以下の 3 つの scope のどれかに振り分けられます。

/lib/app_web/router.ex

## Authentication routes

scope "/", AppWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]
  # ログイン済みの場合は指定パスへリダイレクトされる
end

scope "/", AppWeb do
  pipe_through [:browser, :require_authenticated_user]
  # ログインが必須。未ログインの場合はログインページへリダイレクトされる
end

scope "/", AppWeb do
  pipe_through [:browser]
  # 未ログインでもアクセスできる
end

このように scope が別れているとログインが必要なページとそうでないページの分類が簡単になります。
例えばプロフィールの閲覧は未ログインでも可能、プロフィールの編集はログイン必須のようにアクセスを制限できます。

HTTP ルートの定義は以下のように行います。
HTTP メソッド、パス、コントローラ、アクションの関連付けを 1 行で定義できます。

# GETリクエスト
get "/tasks", TaskController, :index

# GETリクエスト(パラメータあり)
get "/tasks/:id", TaskController, :show

# POSTリクエスト
post "/tasks", TaskController, :create

メモ: LiveView のルーティングについて

LiveView のルートは live マクロを使用して定義できます。
HTTP ルートとは異なり HTTP メソッドの区別はありません。

live ルートはコントローラのかわりに LiveView モジュールが処理を担います。
1 つの LiveView で全てを処理もできますが、LiveView モジュールを機能別に分割してアクションとして個別に定義できます。

scope "/", MyAppWeb do
  pipe_through :browser

  # /tasksにアクセスした時にTaskLiveを呼び出す
  live "tasks", TaskLive

  # /booksにアクセスした時にBookLiveのIndexアクションを呼び出す
  live "/books", BookLive.Index, :index
end

live_session マクロにより live ルートをグループ化できます。
同じ live_session へのページ遷移は live ナビゲーション(後述)によりスムーズに行なえますが異なる live_session へのページ遷移にはリロードが伴います。

live_session は on_mount オプションにより前処理が可能なので、例えば認証が不要なルートから認証が必要なルートへのページ遷移などに使用できます。
認証機能の生成により以下のような scope が定義されているはずです。
LiveView と通常のリクエストが混在する場合は pipe_through と on_mount の両方で同等の認証処理を行う必要があります。

/lib/app_web/router.ex

## Authentication routes

# ログイン済みの場合は指定パスへリダイレクトされる
scope "/", AppWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  live_session :redirect_if_user_is_authenticated,
    on_mount: [{AppWeb.UserAuth, :redirect_if_user_is_authenticated}] do
    # LiveViewルート
    live ...
  end

  # 通常のWEBルート
end

# ログインが必須。未ログインの場合はログインページへリダイレクトされる
scope "/", AppWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :require_authenticated_user,
    on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do
    # LiveViewルート
    live ...
  end

  # 通常のWEBルート
end

# 未ログインでもアクセスできる
scope "/", AppWeb do
  pipe_through [:browser]

  live_session :current_user,
    on_mount: [{AppWeb.UserAuth, :mount_current_user}] do
    live ...
  end

  # 通常のWEBルート
end

メモ: LiveView モジュールについて

https://hexdocs.pm/phoenix_live_view/welcome.html

LiveView モジュールはページの状態の制御と描画を担います。
LiveView では assign という状態変数の変更を追跡することでレンダリングを制御できます。
テンプレートでは assign の変更を検知して差分をレンダリングします。

例えば以下の LiveView モジュールは最初に mount/3が呼ばれ count が assign されます。
assign はassign/3で行います。
その後テンプレートからイベントが送信されます。イベントはhandle_event/3で受信し第一引数のイベント名で increase, decrease, reset が識別されます。
イベント内では assign の値を更新したり上書きしたりできます。
その結果 assign に変更があった場合は assign に関連するテンプレートが再レンダリングされます。

/lib/app_web/live/example_live/index.ex
defmodule AppWeb.ExampleLive.Index do
  use AppWeb, :live_view

  # クライアント接続時に呼ばれる関数
  def mount(_params, _session, socket) do
    # assignを初期化する
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("increase", _value, socket) do
    # assignの値を+1する
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrease", _value, socket) do
    # assignの値を-1する
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def handle_event("reset", _value, socket) do
    # 現在のassignはassigns.countからアクセスできる
    IO.inspect(socket.assigns.count)

    # assignを新しい値で上書きする
    {:noreply, assign(socket, count: 0)}
  end
end

テンプレートは render 関数に定義する方法と別ファイルに定義する方法があります。
別ファイルに定義する場合は LiveView モジュールと同名の.html.heexファイルに HEEX 形式で記述します。
例えばindex.exのテンプレートファイルはindex.html.heexのようになります。

先程定義した count assign は@countのようにテンプレートからアクセスできます。
そしてイベントはphx-click="increase"のようにイベント名とイベントのトリガーをバインドできます。

/lib/app_web/live/example_live/index.html.heex
<h3>Counter App</h3>
<div>
  Count: <%= @count %>
</div>
<div>
  <.button phx-click="increase">Up</.button>
  <.button phx-click="decrease">Down</.button>
  <.button phx-click="reset">reset</.button>
</div>

ルート定義は以下のようになります。

/lib/app_web/router.ex
scope "/", AppWeb do
  pipe_through :browser

  live "/example", ExampleLive.Index, :index
end

メモ: LiveView のライフサイクルについて

LiveView は最初に通常の HTTP レスポンスを返します。その後クライアントとサーバーが接続されインタラクティブになります。

  1. パスに対応した LiveView とアクション(live_action)が呼び出される
  2. mount/3 が呼び出される
  3. handle_params/3 が呼び出される。URL やパラメータを参照できる
  4. render/1 が呼び出される。ステートレスビューとしてレンダリングされる
  5. ステートレスビューが通常の HTTP レスポンスとしてクライアントに送信される
  6. クライアントとサーバーで接続が行われる
  7. ステートフルビューがレンダリングされる
  8. ステートフルビューがクライアントに push される
  9. phx-binding を介してクライアントがイベントを送信する
  10. イベントは handle_event/3 で受信する

メモ: live ナビゲーションについて

https://hexdocs.pm/phoenix_live_view/live-navigation.html

LiveView のナビゲーションは HTTP ナビゲーションとは異なりユースケースに応じた最適な方法が選択できます。

  1. <.link href={...}>またはredirect/2: HTTP ベースで動作しリロードを伴います。
  2. <.link navigate={...}>またはpush_navigate/2: 同じ live セッション間で動作し再マウントを伴います。
  3. <.link patch={...}>またはpush_patch/2: 現在の LiveView で動作しスクロール位置も維持しながら最小限の差分のみを送信します。

2 つ目の navigate を使用した方法は同じセッション間の異なる LiveView をスムーズに移動できます。
push_navigate(socket, to: ~p"/posts", replace: true)のように replace オプションを true にするとブラウザバックしても戻れなくなります。

3 つ目の patch を使用した方法でナビゲーションすると LiveView 内のhandle_params/3コールバックが呼び出されます。
URL からパラメータを取得できるので例えばページネーションやソートに利用できます。
handle_params は mount 後と初回レンダリング前にも呼び出されます。
これによりページ全体ではなく部分的なページ遷移が行なえます。

def handle_params(params, uri, socket) do
  {:noreply, socket}
end

メモ: コンポーネントについて

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveComponent.html#c:mount/1

LiveView のコンポーネントは主に 2 種類あります。
基本的には静的にしておき、フォームなどの独自の状態を持たせたいものには Live コンポーネントを使用するのがパフォーマンス的にも良いと思います。

  1. FunctionalComponents : 静的で再利用可能なマークアップの抽象化
  2. LiveComponents: 独自のライフサイクルを持ちイベントと状態をカプセル化する

メモ: Stream について

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4

LiveView の assign はサーバー側のメモリに保持されるためタイムラインなどの大きなデータは負荷がかかってしまいます。
そこでクライアント側でコレクションを扱うための仕組みが必要になります。
以前は Temporary assigns という一時的な assign が使われていましたが色々と制限も多く、現在は stream という仕組みが用意されています。
stream はクライアント側でのコレクションの保持が可能で、並べかえや先頭・末尾へのデータ追加、データ削除なども動的に行えます。


# streamを初期化する
stream(socket, :posts, posts)

# データを末尾に追加する
stream_insert(socket, :posts, new_post)

# データを削除する
stream_delete(socket, :posts, target_post)

テストの実行

テストは以下のコマンドで行えます。
ここまでの手順でコードを何も変更していなければテストは成功するはずです。

mix test

特定のディレクトリやファイルだけを実行する場合は以下のように指定できます。
毎回プロジェクト全体をテストするのではなくテスト対象を必要最小限にすることでテストの実行時間を短縮できます。

mix test test/app_web/controllers
mix test test/app_web/controllers/page_controller_test.exs

特定の describe (後述)のみをテストするには以下のように--onlyオプションで指定します。

mix test --only describe:"describe_name"

プロジェクトを再作成していると以下のようなテーブル重複エラーが出力されることもあります。

** (Postgrex.Error) ERROR 42P07 (duplicate_table) relation "users" already exists

Phoenix は app_dev、app_test のように環境別にデータベースが別れています。
上記のエラーは何らかの理由で test 用データベースが古い状態になっているのが原因です。
解決法としては以下のようにテスト用データベースをリセットします。

MIX_ENV=test mix ecto.reset

メモ: テストの作成について

https://hexdocs.pm/ex_unit/main/ExUnit.html

Phoenix は ExUnit を内包しておりテストに関しては ExUnit が担います。
テストは test マクロを使用して行います。

test "簡単な計算のテスト" do
  # 1 + 1 の結果が 2 と等しいこと
  assert 1 + 1 == 2
end

例えば以下のテストコードは成功します。

/test/app/example_test.exs
test "簡単な計算のテスト" do
  # 1 + 1の結果が2と等しいこと
  assert 1 + 1 == 2

  # 1 + 1の結果が3と等しくないこと
  refute 1 + 1 == 3

  # falseとnil以外はtrueとみなされる
  assert 0
  assert -1
  assert "hello"
  assert true
  refute false
  refute nil
end

テストの前処理が必要な場合は setup マクロを使用します。
テスト前にデータを作成したりユーザーをログイン状態にするなどの使い方ができます。

setup do
  IO.puts("setup")
end

setup は map を返すこともできます。
map は以下のように test の第二引数で受け取れます。

setup do
  email = "test@example.com"
  %{email: email}
end

test "get email", %{email: email} do
  assert email == "test@example.com"
end

以下のように関数名も指定できます。
予め前処理用の関数を作成しておき、それを複数のテストで利用できます。

defp init(_conn) do
  # 初期化処理などを行う
  :ok
end

setup :init

テストデータの作成は/test/support/fixturesにあるモジュールが担います。
例えば認証機能の生成の際に accounts_fixtures.ex が作成されているはずです。

以下のように user_fixture を呼び出すとテスト用 DB にユーザーが作成されます。
引数に map を渡すとデフォルト値を上書きできます。

import App.AccountsFixtures

user1 = user_fixture()
user2 = user_fixture(%{email: "test@example.com"})

test は describe でグループ化できます。
describe 同士は分離されておりブロック内の import や setup は他の describe に影響しません。
以下のコードでは calc と get_user の 2 つの describe でテストをグループ化しています。

/test/app/example_test.exs
defmodule App.ExampleTest do
  use App.DataCase

  describe "calc" do
    test "簡単な計算のテスト" do
      # 1 + 1の結果が2と等しいこと
      assert 1 + 1 == 2

      # 1 + 1の結果が3と等しくないこと
      refute 1 + 1 == 3
    end
  end

  describe "get_user" do
    # 他のdescribeには影響しない
    import App.AccountsFixtures
    alias App.Repo
    alias App.Accounts.User

    # テストの前処理を行う
    setup do
      # fixtureにmapを渡してフィールド値を上書きできる
      user = user_fixture(%{email: "test@example.com"})
      # testにデータを渡したい場合はmapを返す
      %{user: user}
    end

    # 第二引数でsetupのmapを受け取れる
    test "emailを指定してuserを取得する", %{user: user} do
      # 指定idのuserが取得できること
      assert Repo.get_by(User, email: user.email)

      # 指定idのuserが取得できないこと
      refute Repo.get_by(User, email: "not exist")
    end
  end
end

特定の describe のみをテストするには以下のように--onlyオプションで指定します。

mix test test/app/example_test.exs --only describe:"get_user"

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html

コントローラや LiveView ではレンダリングをシミュレートして独自の assert が可能です。

コントローラの場合:

test "GET /", %{conn: conn} do
  # GET /にアクセスする
  conn = get(conn, ~p"/")

  # ステータスが200かつレスポンスに指定の文字列が含まれることを期待する
  assert html_response(conn, 200) =~ "Example Page"
end

LiveView の場合:

test "saves new post", %{conn: conn} do
  # LIVE /postsにアクセスできることを期待する。LiveViewとHTMLを取得する
  {:ok, index_live, _html} = live(conn, ~p"/posts")

  # "New Post"というテキストを含むanchor要素を1つ取得できることを期待する→クリック→レンダリング後のHTMLに指定の文字列が含まれることを期待する
  assert index_live |> element("a", "New Post") |> render_click() =~
    "New Post"

  # 指定時間(デフォルトは100ms)以内にlive patchが発生することを期待する
  assert_patch(index_live, ~p"/posts/new")

  # フォームにデータを入力→changeイベント→レンダリング後のHTMLに指定のエラーメッセージが含まれることを期待する
  assert index_live
          |> form("#post-form", post: @invalid_attrs)
          |> render_change() =~ "can&#39;t be blank"

  # フォームにデータを入力→submitイベント→レンダリング
  assert index_live
          |> form("#post-form", post: @create_attrs)
          |> render_submit()

  # 指定時間(デフォルトは100ms)以内にlive patchが発生することを期待する
  assert_patch(index_live, ~p"/posts")

  # レンダリング後のHTMLに指定の文字列が含まれることを期待する
  html = render(index_live)
  assert html =~ "Post created successfully"
  assert html =~ "some body"
end
NanaoNanao

Post の追加

では早速 Post を追加していきます。主な仕様は以下の通りです。

  • Post の閲覧と操作はログインユーザーのみ行える
  • Post の編集・削除は作成者のみが行える。他人の Post は編集・削除できない
  • 作成者が退会した場合はその User が投稿した Post も全て削除される

Post の追加: CRUD 機能の自動生成

https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Live.html

Phoenix には CRUD 機能を自動生成してくれる便利な生成コマンドがあります。
今回はこのコマンドで大まかにコードを生成してから細かいところをカスタマイズしていきます。

生成コマンドは以下の通りです。Communication、Post、posts はそれぞれコンテキスト名、スキーマ名、リソース名です。(後ほど補足します)
その後にtitle:stringのようにフィールド名と型名のペアを指定します。
コロンの左右に空白を入れないように注意してください。

mix phx.gen.live Communication Post posts body:string like_count:integer user_id:references:users

サポートされている型名は以下の通りです。

サポートされている型名リスト:
  • :integer
  • :float
  • :decimal
  • :boolean
  • :map
  • :string
  • :array
  • :references
  • :text
  • :date
  • :time
  • :time_usec
  • :naive_datetime
  • :naive_datetime_usec
  • :utc_datetime
  • :utc_datetime_usec
  • :uuid
  • :binary
  • :enum
  • :datetime

:referencesは他テーブルへの参照です。
user_id:references:usersのように参照先テーブルを指定すると自動的にそのテーブルのプライマリーキーを認識して外部キーを作成してくれます。

Post の追加: ルートの追加

次にルーティングの設定をします。
以下のようにログインが必須の scope に live ルートを追加します。

/lib/app_web/router.ex
scope "/", AppWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :require_authenticated_user,
    on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do

    live "/posts", PostLive.Index, :index
    live "/posts/new", PostLive.Index, :new
    live "/posts/:id/edit", PostLive.Index, :edit
    live "/posts/:id", PostLive.Show, :show
    live "/posts/:id/show/edit", PostLive.Show, :edit
  end
end

以下のようにルート一覧を確認すると LiveView の HTTP メソッドは GET になっているはずです。
これは LiveView の初回アクセス時に GET でページ全体が送信されるためです。

mix phx.routes

Post の追加: 生成されたコードについて

ここで簡単にですが生成されたコードの処理を追ってみます。
post の削除は通常のイベント、post の編集は作成とほぼ同じなので省略します。

PostLive.Index と PostLive.Show は LiveView モジュールであり、対応する同名の HEEX ファイルもセットで生成されます。
PostLive.FormComponent は LiveComponent です。LiveComponent は LiveView の子として動作するステートフルなコンポーネントです。

Post の一覧表示:

  1. /posts にアクセスするとルーターによって PostLive.Index が呼ばれます。その際にlive_action = :indexがセットされます。
  2. PostLive.Index の mount/3 が呼ばれ、:posts の stream が初期化されます。
  3. PostLive.Index の handle_params/3 が呼ばれ、live_action のパターンマッチングの結果:post が nil にセットされます。
  4. テンプレートでは post 一覧が表示されます。

Post の作成(リンクをクリックした時):

  1. /posts/new にアクセスするとルーターによって PostLive.Index が呼ばれます。その際にlive_action = :newがセットされます。
  2. PostLive.Index の handle_params/3 が呼ばれ、live_action のパターンマッチングの結果:post にデフォルトの post がセットされます。
  3. テンプレートでは作成モーダルが表示されます。
  4. フォームの送信後に PostLive.FormComponent で post の作成処理が行われます。
  5. PostLive.FormComponent は処理後に親である PostLive.Index にメッセージを送信します。
  6. PostLive.Index の handle_info/2 がメッセージを受信し、post 一覧の末尾に新規 post を追加します。

Post の追加: マイグレーションファイルの編集

コード生成が完了したのでこれをもとに開発していきます。
まずはマイグレーションファイルを編集して DB に制約を追加します。

defaultはデフォルト値、sizeはデータの長さ、null: falseは NOT NULL 制約を意味します。
その他のオプションはadd/3 のオプションに記載があります。

外部キーは references で指定します。
参照先データが削除された時の動作をon_deleteオプションで指定できます。値は nothing(何もしない)、delete_all(関連データを削除する)、nilify_all(関連フィールドを nil にする)のどれかです。
その他のオプションはreferences/2 のオプションに記載があります。

/priv/repo/migrations/xxx_create_posts.exs
defmodule App.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    create table(:posts) do
      add :body, :string, null: false
      add :like_count, :integer, default: 0, null: false
      add :user_id, references(:users, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create index(:posts, [:user_id])

  end
end

マイグレーションファイルを編集したので以下のコマンドで DB に反映させます。

mix ecto.migrate

Post の追加: スキーマの編集

スキーマはデータベースを Elixir 構造体にバインドする役割を持ちます。
この手順ではcomment_countフィールドにデフォルト値を追加しています。
その他のオプションはfield/3 のオプションに記載があります。

/lib/app/communication/post.ex
schema "posts" do
  field :body, :string
  field :like_count, :integer, default: 0
  field :user_id, :id

  timestamps(type: :utc_datetime)
end

Post の追加: changeset の編集

changeset は入力データのフィルタリング・変換・バリデーションを一括で行うための Ecto の機能です。
登録処理や更新処理など、目的別に changeset 関数を作成しておくと必要最小限のフィールドのみを扱えるので効率的です。

以下のコードでは body の長さチェックを追加しています。
この場合 changeset 関数が呼ばれると入力データはフィルタリングと変換処理 → 必須フィールドのチェック →body の長さチェックの順に処理されます。
その他の validate 関数についてはChangeset Functionsに記載があります。

def changeset(post, attrs) do
  post
  |> cast(attrs, [:body, :like_count])
  |> validate_required([:body, :like_count])
  |> validate_length(:body, min: 1, max: 200)
end

Post の追加: アソシエーションの追加

https://hexdocs.pm/ecto/2.2.11/associations.html

アソシエーションはスキーマ同士の関連付けです。
User と Post は has_many な関係です。つまり User は複数の Post を持つことができます。
そして Post から見ると User は belongs_to な関係です。つまり Post から作成者である User を取得できます。

マイグレーションファイルには既に外部キーを設定していますが、スキーマ同士はお互いの関係をまだ知らないのでアソシエーションを追加してあげる必要があります。


まずは User スキーマ側に has_many を追加します。

/lib/app/accounts/user.ex
schema "users" do
  field :email, :string
  field :password, :string, virtual: true, redact: true
  field :hashed_password, :string, redact: true
  field :confirmed_at, :naive_datetime

  # 追加
  has_many(:posts, App.Communication.Post)

  timestamps(type: :utc_datetime)
end

次に Post スキーマ側に belongs_to を追加します。
この時に user_id が重複してしまうため user_id フィールドを削除します。

/lib/app/communication/post.ex
schema "posts" do
  field :title, :string
  field :body, :string
  field :comment_count, :integer

  # 削除
  # field :user_id, :id

  # 追加
  belongs_to(:user, App.Accounts.User)

  timestamps(type: :utc_datetime)
end

Post の追加: 作成処理の変更

今回は既存の User を Post の作成時に関連付けたいので以下のように Communication コンテキストを変更しました。
User との関連付けは作成時のみ行いたいので更新時の changeset と分離しています。

/lib/app/communication.ex
alias App.Accounts
alias App.Accounts.User

def create_post(%User{id: user_id}, attrs \\ %{}) do
  %Post{}
  |> registration_change_post(user_id, attrs)
  |> Repo.insert()
end

def registration_change_post(%Post{} = post, user_id, attrs \\ %{}) do
  user = Accounts.get_user!(user_id)

  post
  |> Repo.preload(:user)
  |> Post.changeset(attrs)
  |> Ecto.Changeset.put_assoc(:user, user)
end

PostLive.FormComponent 側での呼び出しは以下のようになります。
socket.assigns.current_userでログインユーザーを取得しています。

/lib/app_web/live/post_live/form_component.ex
defp save_post(socket, :new, post_params) do
  user = socket.assigns.current_user

  case Communication.create_post(user, post_params) do
    # ...
  end
end

最後に PostLive.Index のテンプレートを以下のように変更します。
PostLive.FormComponent の引数にcurrent_user={@current_user}を渡しています。

/lib/app_web/live/post_live/index.html.heex
<.modal :if={@live_action == :new} id="post-modal" show on_cancel={JS.patch(~p"/posts")}>
  <.live_component
    module={AppWeb.PostLive.FormComponent}
    id={@post.id || :new}
    current_user={@current_user}
    title={@page_title}
    action={@live_action}
    post={@post}
    patch={~p"/posts"}
  />
</.modal>

Post の追加: 表示処理の変更

アソシエーションを定義している場合、Ecto のスキーマは関連データを明示的にロードする必要があります。
preloadを使用すると任意のタイミングで関連データをロードできます。

preload はデフォルトで 2 つのクエリを発行しますが、以下のように join と組み合わせることで 1 つのクエリにもできます。
この preload のおかげでいわゆるn + 1問題を回避できます。

joinは INNER JOIN ですが、他にもleft_joinなども使用できます。
結合条件はデフォルトでプライマリキーが使用されます。

/lib/app/communication.ex
def list_posts do
  query =
    from(
      p in Post,
      join: u in assoc(p, :user),
      preload: [user: u]
    )

  Repo.all(query)
end

def get_post!(id) do
  query =
    from(
      p in Post,
      where: p.id == ^id,
      join: u in assoc(p, :user),
      preload: [user: u]
    )

  Repo.one!(query)
end

最後にテンプレートを変更します。
関連データはpreloadによりロード済みなので例えば Post の作成者の Email は以下のようにアクセスできます。

<%= @post.user.email %>

Post の追加: テストコードの変更

Post が User を必要とするようになったのでテストコードも変更する必要があります。
まずは以下のように postfixture が user を受け取るように変更します。

/test/support/fixtures/accounts_fixtures.ex
def post_fixture(attrs \\ %{}) do
  attrs =
    attrs
    |> Enum.into(%{
      body: "some body",
      like_count: 42
    })

  {:ok, post} =
    App.Communication.create_post(attrs[:user], attrs)

  post
end

あとは呼び出し側でpost_fixture(%{user: user})のように User を渡してあげるだけで OK です。
以下はテストコードの一部です。

/test/app/communication_test.exs
test "list_posts/0 returns all posts" do
  user = user_fixture()
  post = post_fixture(%{user: user})
  assert Communication.list_posts() == [Map.put(post, :user, user)]
end
/test/app_web/live/post_live_test.exs
setup :register_and_log_in_user

test "lists all posts", %{conn: conn, user: user} do
  post = post_fixture(%{user: user})
  {:ok, _index_live, html} = live(conn, ~p"/posts")

  assert html =~ "Listing Posts"
  assert html =~ post.body
end

Post の追加: Post の並び順を変更

現在は Post.id の昇順でソートされており、新規 Post は一番下に追加されます。
これを逆にして上にいくほど新しい Post が表示されるように変更します。

まずは Post 一覧の取得クエリにorder_byを追加します。
stream の確認のため最新 10 件に制限しています。

/lib/app/communication.ex
def list_posts do
  query =
    from(
      p in Post,
      join: u in assoc(p, :user),
      preload: [user: u],
      order_by: [desc: p.updated_at],
      limit: 10
    )

  Repo.all(query)
end

次に stream と stream_insert にatオプションを追加します。
デフォルトは-1なので末尾に新規要素が追加されますが、0を指定すると先頭に新規要素が追加されるようになります。
stream のlimitオプションは要素の最大数です。例えば 10 を指定すると先頭 10 要素を残しそれ以外はコレクションから削除します。

/lib/app_web/live/post_live/index.ex
@impl true
def mount(_params, _session, socket) do
  {:ok, stream(socket, :posts, Communication.list_posts(), at: 0, limit: 10)}
end

def handle_info({AppWeb.PostLive.FormComponent, {:saved, post}}, socket) do
  {:noreply, stream_insert(socket, :posts, post, at: 0)}
end
NanaoNanao

認可処理の追加

ここまでの手順で Post の CRUD 操作が完成しましたが、まだ権限管理ができていないため他人の Post を不正に編集・削除できてしまいます。
そのため Post に簡単な認可処理を追加していきます。

認可処理の追加: 権限チェック関数の追加

権限管理の方法はアプリケーションの規模によって色々あるのですが今回は Elixir のパターンマッチングを利用したシンプルなものを作成します。
Communication コンテキストに以下のような関数を追加します。

/lib/app/communication.ex
@doc """
リソースへの権限があるかbooleanで返します。

- UserがPostの作成者の場合はtrueを返す。
- UserがPostの作成者でない場合はfalseを返す。
"""
def can?(%User{id: user_id}, action, %Post{user_id: user_id}) when action in [:update, :delete],
do: true

def can?(%User{}, action, %Post{}) when action in [:update, :delete],
do: false

認可処理の追加: コンテキストからの呼び出し

コンテキスト側での呼び出しは以下のようになります。

/lib/app/communication.ex
def update_post(%User{} = user, %Post{} = post, attrs) do
  if can?(user, :update, post) do
    post
    |> Post.changeset(attrs)
    |> Repo.update()
  else
    {:error, "permission denied"}
  end
end

def delete_post(%User{} = user, %Post{} = post) do
  if can?(user, :delete, post) do
    Repo.delete(post)
  else
    {:error, "permission denied"}
  end
end

認可処理の追加: カスタム例外の作成

認可処理用に PermissionError という新しい例外も作成しました。
このようにカスタム例外を作成しておくと例外の出し分けやテストが明確になります。

/lib/app_web.ex
defmodule AppWeb.PermissionError do
  defexception [:message]
end

認可処理の追加: LiveView からの呼び出し

LiveView からはログインユーザーを渡して結果をパターンマッチングしています。
権限がない場合はカスタム例外を raise します。

/lib/app_web/live/post_live/form_component.ex
defp save_post(socket, :edit, post_params) do
  user = socket.assigns.current_user

  case Communication.update_post(user, socket.assigns.post, post_params) do
    {:ok, post} ->
      notify_parent({:saved, post})

      {:noreply,
        socket
        |> put_flash(:info, "Post updated successfully")
        |> push_patch(to: socket.assigns.patch)}

    {:error, "permission denied"} ->
      raise AppWeb.PermissionError

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign_form(socket, changeset)}
  end
end
/lib/app_web/live/post_live/show.ex
def handle_event("delete", %{"id" => id}, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  case Communication.delete_post(user, post) do
    {:ok, _} -> {:noreply, push_navigate(socket, to: ~p"/posts", replace: true)}
    {:error, "permission denied"} -> raise AppWeb.PermissionError
  end
end

認可処理の追加: テンプレートでの権限チェッック

Post 詳細ページにアクセスした際ログインユーザーがその Post の作成者だった場合は編集および削除ボタンが表示されるよう変更します。
まずは LiveView 側で権限チェックを行い can_update、can_delete という assign にセットします。

/lib/app_web/live/post_live/show.ex
@impl true
def handle_params(%{"id" => id}, _, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  {:noreply,
    socket
    |> assign(:page_title, page_title(socket.assigns.live_action))
    |> assign(:can_update, Communication.can?(user, :update, post))
    |> assign(:can_delete, Communication.can?(user, :delete, post))
    |> assign(:post, post)}
end

テンプレート側では:ifを使用して宣言的に表示を切り替えるようにします。

/lib/app_web/live/post_live/show.html.heex
<:actions>
  <.link patch={~p"/posts/#{@post}/show/edit"} phx-click={JS.push_focus()} :if={@can_update}>
    <.button>Edit</.button>
  </.link>
  <.link phx-click={JS.push("delete", value: %{id: @post.id})} data-confirm="Are you sure?" :if={@can_delete}>
    <.button>Delete</.button>
  </.link>
</:actions>

認可処理の追加: テストコードの変更

コンテキストや LiveView を変更したのでテストコードも変更する必要があります。
まずコンテキストのテストでは User を受け取るように変更します。
ログインユーザー以外の Post が必要な場合は describe 内の setup で個別に作成します。

/test/app/communication_test.exs
describe "posts" do
  test "update_post/3 with valid data updates the post" do
    user = user_fixture()
    post = post_fixture(%{user: user})
    update_attrs = %{body: "some updated body"}

    assert {:ok, %Post{} = post} = Communication.update_post(user, post, update_attrs)
    assert post.body == "some updated body"
  end

  test "delete_post/2 deletes the post" do
    user = user_fixture()
    post = post_fixture(%{user: user})
    assert {:ok, %Post{}} = Communication.delete_post(user, post)
    assert_raise Ecto.NoResultsError, fn -> Communication.get_post!(post.id) end
  end
end

describe "other posts" do
  import App.CommunicationFixtures
  import App.AccountsFixtures

# 別ユーザーのPostを作成する
  setup do
    user = user_fixture()
    post = post_fixture(%{user: user})

    %{post: post}
  end

  # 第二引数でsetupのPostを受け取る
  test "update_post/3 他人のPostは更新できないこと", %{post: post} do
    user = user_fixture()
    update_attrs = %{body: "some updated body"}

    # 権限エラーになることぉ期待する
    assert {:error, "permission denied"} = Communication.update_post(user, post, update_attrs)
  end

  test "delete_post/2 他人のPostは削除できないこと", %{post: post} do
    user = user_fixture()

    # 権限エラーになることぉ期待する
    {:error, "permission denied"} = Communication.delete_post(user, post)
  end
end

また、以下のようにCommunication.can?関数のテストも追加します。

/test/app/communication_test.exs
describe "posts_authorization" do
  import App.CommunicationFixtures
  import App.AccountsFixtures

  test "Postに対する権限が取得できること" do
    u1 = user_fixture()
    p1 = post_fixture(%{user: u1})
    u2 = user_fixture()
    p2 = post_fixture(%{user: u2})

    assert Communication.can?(u1, :update, p1)
    assert Communication.can?(u1, :delete, p1)
    refute Communication.can?(u1, :update, p2)
    refute Communication.can?(u1, :delete, p2)
  end
end

LiveView の方はsetup [:create_post]のようにテスト前に Post を作成していましたが、以下のようにログインユーザーが Post の作成者になるように変更します。
こちらもログインユーザー以外の Post が必要な場合は describe 内の setup で個別に作成します。

import App.CommunicationFixtures

# ログインユーザーの作成とログインを行う
setup :register_and_log_in_user

describe "show posts" do
  # 第二引数でログインユーザーを受け取る
  test "ShowページでPostが削除できること", %{conn: conn, user: user} do
    post = post_fixture(%{user: user})
    {:ok, show_live, html} = live(conn, ~p"/posts/#{post}")

    # HTMLに削除ボタンが含まれていることを期待する
    assert html =~ "Delete"

    # 削除ボタンが見つかりクリックできることを期待する
    assert show_live |> element("a", "Delete") |> render_click() =~ "Delete

    # リダイレクトが発生することを期待する
    assert_redirect(show_live, ~p"/posts")
  end
end

describe "show other posts" do
  import App.AccountsFixtures

  # 別ユーザーでPostを作成する
  setup do
    user = user_fixture()
    post = post_fixture(%{user: user})
    %{post: post}
  end

  # 第二引数でsetupのPostを受け取る
  test "他人のPostは削除できないこと", %{conn: conn, post: post} do
    {:ok, _show_live, html} = live(conn, ~p"/posts/#{post}")

    # 削除ボタンが見つからないことを期待する
    refute html =~ "Delete"
  end
end
NanaoNanao

いいね機能の追加

次にいいね機能を追加していきます。
いいね機能の仕様は以下の通りです。

  • いいねの閲覧と操作はログインユーザーのみ行える
  • Post にいいねするとその Post のいいね数が+1 される
  • Post のいいねを取り消すとその Post のいいね数が-1 される
  • 自分のいいねだけを取り消すことができる。他人のいいねは取り消せない
  • Post が削除された場合はいいねデータも削除される

いいねデータは User と Post の多対多(many_to_many)の関係にあります。
つまり Post は複数の User にいいねされ、User は複数の Post にいいねできます。

いいね機能の追加: コンテキストの自動生成

今回も生成コマンドを使用して大まかにコードを自動生成します。
ただしいいね機能は専用ページを必要としないのでmix phx.gen.liveではなくmix phx.gen.contextを使用します。
これによりコンテキスト・スキーマ・マイグレーションファイルのみが生成されます。

mix phx.gen.context Interaction Like likes user_id:references:users post_id:references:posts

いいね機能の追加: マイグレーションファイルの編集

生成されたマイグレーションファイルを編集します。
likes テーブルは users と posts の中間テーブルとしての役割を持ちます。
後ほど Like スキーマを作成するので外部キー 2 つの他にプライマリキーも作成しています。

/priv/repo/migrations/xxx_create_likes.exs
defmodule App.Repo.Migrations.CreateLikes do
  use Ecto.Migration

  def change do
    create table(:likes) do
      add :user_id, references(:users, on_delete: :delete_all)
      add :post_id, references(:posts, on_delete: :delete_all)

      timestamps(type: :utc_datetime)
    end

    create unique_index(:likes, [:user_id, :post_id])
    create index(:likes, [:user_id])
    create index(:likes, [:post_id])
  end
end

マイグレーションを適用します。

mix ecto.migrate

いいね機能の追加: アソシエーションの追加

いいねデータの作成と削除は Like スキーマに対して行います。
さらに user_id を指定して関連する Post を取得、post_id を指定して関連する User を取得といったこともできます。

schema "likes" do

  # 削除
  # field :user_id, :id
  # field :post_id, :id

  # 追加
  belongs_to(:user, App.Accounts.User)

  # 追加
  belongs_to(:post, App.Communication.Post)

  timestamps(type: :utc_datetime)
end

def changeset(like, attrs) do
  like
  |> cast(attrs, [:user_id, :post_id])
  |> validate_required([:user_id, :post_id])
end

いいね機能の追加: コンテキストの編集

Interaction コンテキストに以下の関数を追加します。

  • list_likes_by_post_id/1: post_id にマッチするいいねのリストを取得する
  • get_like/2: 条件にマッチするいいねを 1 件取得する
  • toggle_like/2: いいね済みか判定していいね済みなら削除、そうでなければ追加処理を行う
  • like_exists?/2: いいねデータが存在するか boolean で返す
  • create_like/2: いいねを作成する
  • delete_like/1: いいねを削除する
  • registration_change_like/3: いいねの changeset を呼び出す
/lib/app/interaction.ex
defmodule App.Interaction do
  @moduledoc """
  The Interaction context.
  """

  import Ecto.Query, warn: false
  alias App.Repo
  alias App.Accounts.User
  alias App.Communication.Post
  alias App.Interaction.Like

  def list_likes_by_post_id(post_id) do
    query =
      from(
        l in Like,
        where: l.post_id == ^post_id,
        join: u in assoc(l, :user),
        preload: [user: u],
        order_by: [desc: l.inserted_at],
        limit: 10
      )

    Repo.all(query)
  end

  def get_like(user_id, post_id) do
    query =
      from(
        l in Like,
        where: l.post_id == ^post_id and l.user_id == ^user_id
      )

    Repo.one(query)
  end

  def toggle_like(%User{} = user, %Post{} = post) do
    # いいねデータが存在する場合は削除、なければ作成する
    if like_exists?(user.id, post.id) do
      like = get_like(user.id, post.id)

      case delete_like(like) do
        {:ok, %Like{} = like} -> {:ok, :delete, like}
        {:error, %Ecto.Changeset{} = changeset} -> {:error, :delete, changeset}
      end
    else
      case create_like(user, post) do
        {:ok, %Like{} = like} -> {:ok, :create, like}
        {:error, %Ecto.Changeset{} = changeset} -> {:error, :create, changeset}
      end
    end
  end

  def like_exists?(user_id, post_id) do
    Repo.exists?(
      from(
        l in Like,
        where: l.user_id == ^user_id and l.post_id == ^post_id,
        select: {l.id}
      )
    )
  end

  def create_like(%User{} = user, %Post{} = post) do
    %Like{}
    |> registration_change_like(user, post)
    |> Repo.insert()
  end

  def delete_like(%Like{} = like) do
    Repo.delete(like)
  end

  def registration_change_like(%Like{} = like, %User{} = user, %Post{} = post) do
    like
    |> Repo.preload([:user, :post])
    |> Like.changeset(%{user_id: user.id, post_id: post.id})
    |> Ecto.Changeset.put_assoc(:user, user)
    |> Ecto.Changeset.put_assoc(:post, post)
  end
end

いいね機能の追加: テストコードの編集

Interaction コンテキストの実装が完了したので動作確認のためのテストコードを追加していきます。

/test/app/interaction_test.exs
defmodule App.InteractionTest do
  use App.DataCase

  alias App.Interaction

  describe "likes" do
    alias App.Interaction.Like

    import App.AccountsFixtures
    import App.CommunicationFixtures
    import App.InteractionFixtures

    test "toggle_like/2 Likeが存在する場合は削除、なければ作成できること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like_fixture(%{user: user, post: post})
      assert Interaction.like_exists?(user.id, post.id)
      assert {:ok, :delete, _} = Interaction.toggle_like(user, post)
      refute Interaction.like_exists?(user.id, post.id)
      assert {:ok, :create, _} = Interaction.toggle_like(user, post)
      assert Interaction.like_exists?(user.id, post.id)
    end

    test "list_likes_by_post_id/1 post_idにマッチするLikeが取得できること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like = like_fixture(%{user: user, post: post})

      assert [result | _] =
               Interaction.list_likes_by_post_id(post.id)

      assert result.user_id == like.user_id
      assert result.post_id == like.post_id
    end

    test "get_like/2 Likeが取得できること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like = like_fixture(%{user: user, post: post})

      refute Interaction.get_like(0, 0)

      assert result =
               Interaction.get_like(user.id, post.id)

      assert result.user_id == like.user_id
      assert result.post_id == like.post_id
    end

    test "like_exists?/2 Likeが存在するかbooleanで返されること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like_fixture(%{user: user, post: post})
      assert Interaction.like_exists?(user.id, post.id)
      refute Interaction.like_exists?(0, 0)
    end

    test "create_like/2 正しいデータでLikeが作成できること" do
      user = user_fixture()
      post = post_fixture(%{user: user})

      assert {:ok, %Like{} = like} =
               Interaction.create_like(user, post)

      assert like.user_id == user.id
      assert like.post_id == post.id
      assert Interaction.like_exists?(user.id, post.id)
    end

    test "delete_like/1 Likeが削除できること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like = like_fixture(%{user: user, post: post})
      assert {:ok, %Like{}} = Interaction.delete_like(like)
      refute Interaction.like_exists?(user.id, post.id)
    end

    test "registration_change_like/3 changesetが返されること" do
      user = user_fixture()
      post = post_fixture(%{user: user})
      like = like_fixture(%{user: user, post: post})

      assert %Ecto.Changeset{} =
               Interaction.registration_change_like(like, user, post)
    end
  end
end

いいね機能の追加: LiveView からの呼び出し

コンテキストが完成したので LiveView から呼び出せるように変更していきます。
まずは PostLive.Show にいいねしたユーザーの一覧を表示するために likes という名前で stream を定義します。
初期値は先程作成したlist_likes_by_post_id/1から取得しています。

さらにいいねボタンをクリックした際のイベントも追加します。
コンテキストでの処理結果に応じて stream の要素を追加・削除しています。

/lib/app_web/live/post_live/show.ex
def handle_params(%{"id" => id}, _, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  {:noreply,
    socket
    |> assign(:page_title, page_title(socket.assigns.live_action))
    |> assign(:can_update, Communication.can?(user, :update, post))
    |> assign(:can_delete, Communication.can?(user, :delete, post))
    |> assign(:post, post)
    |> stream(:likes, Interaction.list_likes_by_post_id(post.id), at: 0, limit: 10)}
end

@impl true
def handle_event("like", %{"id" => id}, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  case Interaction.toggle_like(user, post) do
    {:ok, :create, like} ->
      {:noreply, stream_insert(socket, :likes, like)}

    {:ok, :delete, like} ->
      {:noreply, stream_delete(socket, :likes, like)}

    {:error, :create, _} ->
      {:noreply, put_flash(socket, :error, "いいねの途中でエラーが発生しました")}

    {:error, :delete, _} ->
      {:noreply, put_flash(socket, :error, "いいねの取り消しの途中でエラーが発生しました")}
  end
end

テンプレートにはいいねボタンといいねしたユーザーのリストを追加します。
いいねイベントの呼び出しは JavaScript で非同期的に行われます。

/lib/app_web/live/post_live/show.html.heex
<.list>
  <:item title="Like count">
    <.button phx-click={JS.push("like", value: %{id: @post.id})}>
  <%= @post.like_count %> Likes
  </.button>
  </:item>
</.list>

<h2>いいねしたユーザー</h2>
<ul id="liked-users" phx-update="stream">
  <li :for={{dom_id, like} <- @streams.likes} id={dom_id}>
  <%= like.user.email %>
      </li>
  </ul>

この時点ではまだいいね数が更新されませんが、いいねしたユーザーは追加・削除できると思います。

いいね機能の追加: いいね数の更新とトランザクション

https://hexdocs.pm/ecto/Ecto.Multi.html

いいねイベント後はPost.like_countを更新する必要があるので一連のトランザクションで処理します。
以下のように Ecto.Multi とパイプ演算子を使って一連のトランザクションを記述できます。
問題なければコミットされ、途中で例外が発生した場合はロールバックされます。

/lib/app/interaction.ex
def create_like(%User{} = user, %Post{} = post) do
  Ecto.Multi.new()
  |> Ecto.Multi.one(
    :post,
    from(p in Post, where: p.id == ^post.id)
  )
  |> Ecto.Multi.update(:set_count, fn %{post: post} ->
    Post.like_count_changeset(post, %{like_count: post.like_count + 1})
  end)
  |> Ecto.Multi.insert(
    :create_like,
    registration_change_like(%Like{}, user, post)
  )
  |> Repo.transaction()
  |> case do
    {:ok, %{create_like: like}} ->
      {:ok, like}

    {:error, _name, changeset} ->
      {:error, changeset}
  end
end

def delete_like(%Like{} = like) do
  Ecto.Multi.new()
  |> Ecto.Multi.one(
    :post,
    from(p in Post, where: p.id == ^like.post_id)
  )
  |> Ecto.Multi.update(:set_count, fn %{post: post} ->
    Post.like_count_changeset(post, %{like_count: post.like_count - 1})
  end)
  |> Ecto.Multi.delete(:delete_like, like)
  |> Repo.transaction()
  |> case do
    {:ok, %{delete_like: like}} ->
      {:ok, like}

    {:error, _name, changeset} ->
      {:error, changeset}
  end
end

あわせてPost.like_count_changeset/2も作成します。

def like_count_changeset(post, attrs) do
  post
  |> cast(attrs, [:like_count])
  |> validate_required([:like_count])
end

また、PostLive.Show ではPost.like_countを別の assign として定義します。
そしていいねイベントの結果に応じて加算・減算します。

/lib/app_web/live/post_live/show.ex
def handle_params(%{"id" => id}, _, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  {:noreply,
    socket
    |> assign(:page_title, page_title(socket.assigns.live_action))
    |> assign(:can_update, Communication.can?(user, :update, post))
    |> assign(:can_delete, Communication.can?(user, :delete, post))
    |> assign(:post, post)
    |> assign(:like_count, post.like_count)
    |> stream(:likes, Interaction.list_likes_by_post_id(post.id), at: 0, limit: 10)}
end

@impl true
def handle_event("like", %{"id" => id}, socket) do
  post = Communication.get_post!(id)
  user = socket.assigns.current_user

  case Interaction.toggle_like(user, post) do
    {:ok, :create, %Like{} = like} ->
      {:noreply,
        socket
        |> update(:like_count, &(&1 + 1))
        |> stream_insert(:likes, like)}

    {:ok, :delete, %Like{} = like} ->
      {:noreply,
        socket
        |> update(:like_count, &(&1 - 1))
        |> stream_delete(:likes, like)}

    {:error, :create, %Ecto.Changeset{}} ->
      {:noreply, put_flash(socket, :error, "いいねの途中でエラーが発生しました")}

    {:error, :delete, %Ecto.Changeset{}} ->
      {:noreply, put_flash(socket, :error, "いいねの取り消しの途中でエラーが発生しました")}
  end
end
/lib/app_web/live/post_live/show.html.heex
<.button phx-click={JS.push("like", value: %{id: @post.id})}>
  <%= @like_count %> Likes
</.button>

いいね機能の追加: テストコードの編集

テストコードを変更して動作確認をしていきます。
まずは create_like と delete_like の後にPost.like_countが更新されることをテストします。

/test/app/interaction_test.exs
test "create_like/2 正しいデータでLikeが作成できること" do
  user = user_fixture()
  post = post_fixture(%{user: user})

  assert {:ok, %Like{} = like} =
            Interaction.create_like(user, post)

  assert like.user_id == user.id
  assert like.post_id == post.id
  assert Interaction.like_exists?(user.id, post.id)
  assert %Post{like_count: 1} = Communication.get_post!(post.id)
end

test "delete_like/1 Likeが削除できること" do
  user = user_fixture()
  post = post_fixture(%{user: user})
  like = like_fixture(%{user: user, post: post})
  assert {:ok, %Like{}} = Interaction.delete_like(like)
  refute Interaction.like_exists?(user.id, post.id)
  assert %Post{like_count: 0} = Communication.get_post!(post.id)
end

LiveView のテストではいいね数が画面上でも更新されることを確認します。

/test/app_web/live/post_live_test.exs
alias App.Communication
alias App.Communication.Post

test "いいねボタンを押すといいね数が更新されること", %{conn: conn, user: user} do
  post = post_fixture(%{user: user})
  {:ok, show_live, _html} = live(conn, ~p"/posts/#{post}")

  assert show_live |> element("button", "0 Likes") |> render_click() =~ "1 Likes"

  assert show_live |> element("button", "1 Likes") |> render_click() =~ "0 Likes"
end
NanaoNanao

アップロード機能の追加

https://hexdocs.pm/phoenix_live_view/uploads.html

画像のアップロード機能を追加して Post に画像を添付できるようにします。

アップロード機能の追加: マイグレーションファイルの編集

posts テーブルにアップロードファイルのパスを格納する path フィールドを追加します。
アップロードファイルは必須でないので nullable にします。

/priv\repo\migrations\xxx_create_posts.exs
create table(:posts) do
  add :body, :string, null: false
  add :like_count, :integer, default: 0, null: false

  # 追加
  add :path, :string

  add :user_id, references(:users, on_delete: :delete_all)

  timestamps(type: :utc_datetime)
end

その後忘れずにマイグレーションを適用します。

mix ecto.reset

アップロード機能の追加: スキーマの編集

Post スキーマにも path を追加します。

/lib\app\communication\post.ex
schema "posts" do
  field :body, :string
  field :like_count, :integer, default: 0

  # 追加
  field :path, :string

  belongs_to(:user, App.Accounts.User)
  many_to_many(:liked_users, App.Accounts.User, join_through: "likes")

  timestamps(type: :utc_datetime)
end

def changeset(post, attrs) do
  post
  |> cast(attrs, [:body, :like_count, :path])
  |> validate_required([:body])
  |> validate_length(:body, min: 1, max: 200)
end

アップロード機能の追加: アップロードコンポーネントの追加

allow_upload/3を使用してアップロードを許可します。
オプションでファイルの情報を指定可能なので、以下の mount では最大 4MB・JPG または PNG・最大 1 枚の画像ファイルのみを許可しています。

save イベントではconsume_uploaded_entries/3を使用してアップロードされたファイルを受け取っています。
戻り値でアップロード先パスを受け取り作成パラメータに渡しています。
アップロード先である/prive/static/uploadsディレクトリは予め作成しておきます。

render ではフォームの中に`live_file_input を追加しています。
また、今回はアップロード画像の編集はしないため Post の作成時のみファイル選択を表示するようにしています。

/lib/app_web/live/post_live/form_component.ex
def mount(socket) do
  {:ok,
    allow_upload(socket, :images,
      accept: ~w(.jpg .jpeg .png),
      max_entries: 1,
      max_file_size: 4_000_000
    )}
end

defp save_post(socket, :new, post_params) do
  {:ok, post_params} = save_file(socket, post_params)
  user = socket.assigns.current_user

  case Communication.create_post(user, post_params) do
    # ...
  end
end

defp save_file(socket, post_params) do
  image_files =
    consume_uploaded_entries(socket, :images, fn %{path: path}, entry ->
      # 実際はバイナリコードなどをチェックするべき
      ext = Path.extname(entry.client_name)
      dest = Path.join([:code.priv_dir(:app), "static", "uploads", Path.basename(path) <> ext])

      File.cp!(path, dest)
      {:ok, "/uploads/#{Path.basename(dest)}"}
    end)

  post_params = if length(image_files) > 0 do
    [file | _] = image_files
    Map.put(post_params, "path", file)
  else
    Map.put(post_params, "path", nil)
  end

  {:ok, post_params}
end

@impl true
def render(assigns) do
  ~H"""
  <div>
    ・・・

    <.simple_form
      for={@form}
      id="post-form"
      phx-target={@myself}
      phx-change="validate"
      phx-submit="save"
    >
    ・・・

    <.live_file_input :if={@action == :new} upload={@uploads.images} />
    <%= for entry <- @uploads.images.entries do %>
      <figure>
        <.live_img_preview entry={entry} />
        <figcaption><%= entry.client_name %></figcaption>
      </figure>
      <progress value={entry.progress} max="100"><%= entry.progress %>%</progress>
      <%= for err <- upload_errors(@uploads.images, entry) do %>
        <p role="alert" class="alert alert-danger"><%= err %></p>
      <% end %>
    <% end %>

      <:actions>
        <.button phx-disable-with="Saving...">Save Post</.button>
      </:actions>
    </.simple_form>
  </div>
  """
end

アップロード機能の追加: Post 削除時の処理

Post が削除された時にアップロードファイルも一緒に削除する必要があります。
Post.pathが nil でない場合、File.rm/1を使用してファイルを削除します。

/lib/app_web/live/post_live/show.ex
defp delete_file!(post_path) do
  if !is_nil(post_path) do
    path = Path.join([:code.priv_dir(:app), "static", post_path])
    File.rm!(path)
  end
end

アップロード機能の追加: アップロード画像の表示

まずはアップロード画像へ URL でアクセスできるようにします。
plug Plug.Staticの only オプションに uploads ディレクトリを追加します。

/lib/app_web/endpoint.ex
plug Plug.Static,
  at: "/",
  from: :app,
  gzip: false,
  only: ~w(assets fonts images favicon.ico robots.txt uploads)

あとは PostLive.Index と PostLive.Show に img を追加します。

<div class="max-w-xs overflow-hidden" :if="{@post.path}">
  <img src="{@post.path}" class="w-full h-auto block" alt="添付画像" />
</div>

これで Post に画像を添付した場合は画像が表示されるはずです。

アップロード機能の追加: テストコードの編集

https://qiita.com/the_haigo/items/6ad00175bb3d9c15b3ee

Phoenix はアップロード機能の自動テストも行えます。
予め/test/support/uploads/hello.pngにテスト用の画像ファイルを用意します。
そして以下のようにfile_input/4render_upload/2を組み合わせてテストします。

/test/app_web/live/post_live_test.exs
describe "upload" do
  test "Postにアップロード画像を添付できること", %{conn: conn} do
    {:ok, index_live, _html} = live(conn, ~p"/posts")

    assert index_live |> element("a", "New Post") |> render_click() =~
              "New Post"

    assert_patch(index_live, ~p"/posts/new")

    assert index_live
            |> file_input("#post-form", :images, [
              %{
                name: "hello.png",
                type: "image/png",
                content: File.read!("test/support/uploads/hello.png")
              }
            ])
            |> render_upload("hello.png") =~ "hello.png"

    assert index_live
            |> form("#post-form", post: @create_attrs)
            |> render_submit()

    assert_patch(index_live, ~p"/posts")

    html = render(index_live)
    assert html =~ "Post created successfully"
    assert html =~ "some body"
  end
end
NanaoNanao

ブロードキャストの追加

https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html

今の段階では、Post の作成後に他のユーザーに通知されないため他のユーザーは Post の一覧画面を再マウントしないと新しい Post を表示できません。
そのため Post の作成後に新しい Post を Pub/Sub等でブロードキャストする必要があります。

Pub/Subは subscriber(購読者)と publisher(送信者)がメッセージを非同期的にやり取りする仕組みです。
subscriber は特定の topic のみを購読しておき、publisher は特定の topic にのみデータを送信します。これにより topic を購読している subscriber のみがデータを受信できます。
topic があることで publisher はどこへ送るか、subscriber はどこから送られたかを意識しなくて済みます。

Phoenix にはこれを簡単に行うPhoenix.PubSubという機能が用意されています。
そして実は Phoenix プロジェクトはデフォルトでPhoenix.PubSubを以下のようにスーパーバイザ配下で管理しているためすでに準備ができています。

lib/app/application.ex
@impl true
def start(_type, _args) do
  children = [
    ...
    {Phoenix.PubSub, name: App.PubSub},
  ]

  # See https://hexdocs.pm/elixir/Supervisor.html
  # for other strategies and supported options
  opts = [strategy: :one_for_one, name: App.Supervisor]
  Supervisor.start_link(children, opts)
end

ブロードキャストの追加: ブロードキャストモジュールの作成

https://qiita.com/mnishiguchi/items/b528dccde6c531206eb9

上記の記事が大変参考になりました。
topic はモジュール名の文字列を使用しているため以下の場合は"App.CommunicationBroadcaster"になります。

/lib/app/communication_broadcaster.ex
defmodule App.CommunicationBroadcaster do
  @moduledoc """
  Communicationデータのブロードキャストを行うモジュール
  """

  # このモジュール名の文字列("App.CommunicationBroadcaster")をtopicにする
  @topic inspect(__MODULE__)

  @doc false
  def subscribe do
    Phoenix.PubSub.subscribe(App.PubSub, @topic)
  end

  @doc false
  def broadcast({event, data}) do
    Phoenix.PubSub.broadcast(App.PubSub, @topic, {event, data})
  end
end

ブロードキャストの追加: ブロードキャストモジュールのテスト

broadcast されたデータは handle_info コールバックで受信できます。
これは assert_receive マクロで検証できます。
ちなみに subscribe したプロセスが終了すると自動的に unsubscribe されるようなので明示的に購読解除する必要はなさそうです。

/test/app/communication_broadcaster_test.exs
defmodule App.CommunicationBroadcasterTest do
  use App.DataCase
  alias App.CommunicationBroadcaster

  describe "broadcaster" do
    test "broadcast/1 ブロードキャストされたデータを受信できること" do
      data = {:create_message, "some message"}

      CommunicationBroadcaster.subscribe()
      CommunicationBroadcaster.broadcast(data)
      assert_receive ^data
    end
  end
end

ブロードキャストの追加: LiveView からの呼び出し

PostLive.Index のマウント時に subscribe し、Post の追加後に新しい Post を broadcast します。
broadcast 後は handle_info コールバックが呼ばれます。その中で posts stream に新しい Post を追加しています。

/lib/app_web/live/post_live/index.ex
def mount(_params, _session, socket) do
# mount時にsubscribeする
  if connected?(socket), do: App.CommunicationBroadcaster.subscribe()

  {:ok, stream(socket, :posts, Communication.list_posts(), at: 0, limit: 10)}
end

...

# FormComponentの送信後に呼び出されるコールバック関数
@impl true
def handle_info({AppWeb.PostLive.FormComponent, {:saved, post}}, socket) do
# subscriberに新しいPostをbroadcastする
  App.CommunicationBroadcaster.broadcast({:saved, post})

  {:noreply, stream_insert(socket, :posts, post, at: 0)}
end

# broadcast後に呼び出されるコールバック関数
@impl true
def handle_info({:saved, post}, socket) do
  {:noreply, stream_insert(socket, :posts, post, at: 0)}
end

これで Post 作成後に他のユーザーの Post 一覧も更新されるようになりました。

このスクラップは2023/11/29にクローズされました