【Elixir】Phoenix/LiveView で作るシンプルアプリ
はじめに
最近 Elixir と Phoenix に入門したので学習用に WEB アプリケーションを作成しました。
これはその記録です。
今回作成したプロジェクトのソースコードは僕の Github リポジトリにあります。
これが誰かの参考になれば幸いです。
Elixir とは
Elixir は BEAM という Erlang VM 上で動作する関数型言語です。
軽量プロセスによる分散処理や高い対象外性、パターンマッチングやデータの不変性などによる高いスケーラビリティが特徴です。
他にも Elixir ではドキュメントやテストも重視されており豊富な補助機能が用意されています。
関数型プログラミングはとてもシンプルで、複雑なロジックも副作用のない小さな関数に分けることで読みやすくテストも容易になります。
Phoenix とは
Phoenix は Elixir で開発された WEB フレームワークです。
Laravel や Ruby on Rails のようなフルスタックアプリケーションはもちろん、他にもリアルタイムなチャットアプリも開発できます。
WEB フレームワークとしての機能は一通り備わっており、生成コマンドを使用して認証機能やコントローラを自動生成できます。
HEEx(HTML + Embedded Elixir)という独自のテンプレートによって作成された再利用可能なコンポーネントも特徴です。
LiveView とは
LiveView はリアルタイムな Web UI が作成できる Phoenix の機能の 1 つです。
最初のページ読み込みは通常の HTTP で行われ、その後はサーバーとクライアントで通信が確立されます。そしてイベントが呼ばれる度に差分となる HTML がサーバー側でレンダリングされ自動的にクライアントに表示されます。
サーバーからはページ全体ではなく差分のみを送信するので従来の WEB アプリケーションと比較して少ない通信料で済みます。
Live ナビゲーションによるスムーズなページ遷移も体験が良いです。
クライアントとサーバーは基本的に非同期通信ですが、開発は Elixir と HEEX だけで行うため特に JavaScript を意識する必要はありません。
LiveView を使用すればリアルタイムに更新されるダッシュボードやチャットアプリなどが開発できます。
今回はこの LiveView を使用してサンプルアプリを開発します。
要件
今回はシンプルな SNS サンプルアプリを開発します。
機能は必要最小限ですが一通りのアソシエーションを含んでいるのでリプライ機能やフォロー機能などにも応用できると思います。
- 機能リスト:
- ユーザーは Post を投稿できる
- Post は最大 200 文字の本文を持つ
- Post にはアップロードした画像を 1 枚添付できる
- ユーザーは Post にいいねできる
- Post にいいねしたユーザーを一覧表示できる
開発方針
- 時間短縮のためになるべくフレームワークの機能を使用する
- 実装後はテストを書いて機能の動作を確認する
基本
以下はプロジェクトの作成から認証処理の生成、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
プロジェクトの作成
コンテナで 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 で作成されているためシームレスなページ遷移とリアルタイムな情報更新が体験できます。
ユーザーと認証機能の生成
Phoenix はコマンド 1 つでユーザーと認証機能(メールアドレスとパスワードによる認証)を自動生成してくれます。
検証済みのテストコードも一緒に生成してくれるのも嬉しいです。
ユーザーと認証機能を自動生成するには以下のコマンドを実行します。
後ほど補足しますが Accounts、User、users はそれぞれコンテキストモジュール名、スキーマモジュール名、リソース名を表します。
また、今回は LiveView を使用するので--live
オプションを指定しています。
mix phx.gen.auth Accounts User users --live
続いて依存ライブラリのインストールを行います。
mix deps.get
最後にデータベースのマイグレーションを行います。
mix ecto.migrate
たったこれだけの操作でユーザーと認証機能の生成は完了です。
このように最初に生成コマンドで大まかにコード生成しておくと大幅な時間短縮になります。
メモ: 基本のルーティングについて
プロジェクトのルート一覧は以下のコマンドで確認できます。
mix phx.routes
Phoenix には plug というミドルウェアのようなものがあり Cookie やトークンの検証などのリクエストの前処理が行なえます。
plug を目的別にグループ化したのが pipeline であり、それを scope と関連付けるのが pipe_through マクロです。
scope マクロはルートのグループ化を行います。
認証機能を生成するとスコープと plug がいくつか追加されているはずです。
pipe_through によりブラウザからのリクエストは以下の 3 つの scope のどれかに振り分けられます。
## 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 の両方で同等の認証処理を行う必要があります。
## 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 モジュールについて
LiveView モジュールはページの状態の制御と描画を担います。
LiveView では assign という状態変数の変更を追跡することでレンダリングを制御できます。
テンプレートでは assign の変更を検知して差分をレンダリングします。
例えば以下の LiveView モジュールは最初に mount/3
が呼ばれ count が assign されます。
assign はassign/3
で行います。
その後テンプレートからイベントが送信されます。イベントはhandle_event/3
で受信し第一引数のイベント名で increase, decrease, reset が識別されます。
イベント内では assign の値を更新したり上書きしたりできます。
その結果 assign に変更があった場合は assign に関連するテンプレートが再レンダリングされます。
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"
のようにイベント名とイベントのトリガーをバインドできます。
<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>
ルート定義は以下のようになります。
scope "/", AppWeb do
pipe_through :browser
live "/example", ExampleLive.Index, :index
end
メモ: LiveView のライフサイクルについて
LiveView は最初に通常の HTTP レスポンスを返します。その後クライアントとサーバーが接続されインタラクティブになります。
- パスに対応した LiveView とアクション(live_action)が呼び出される
- mount/3 が呼び出される
- handle_params/3 が呼び出される。URL やパラメータを参照できる
- render/1 が呼び出される。ステートレスビューとしてレンダリングされる
- ステートレスビューが通常の HTTP レスポンスとしてクライアントに送信される
- クライアントとサーバーで接続が行われる
- ステートフルビューがレンダリングされる
- ステートフルビューがクライアントに push される
- phx-binding を介してクライアントがイベントを送信する
- イベントは handle_event/3 で受信する
メモ: live ナビゲーションについて
LiveView のナビゲーションは HTTP ナビゲーションとは異なりユースケースに応じた最適な方法が選択できます。
-
<.link href={...}>
またはredirect/2
: HTTP ベースで動作しリロードを伴います。 -
<.link navigate={...}>
またはpush_navigate/2
: 同じ live セッション間で動作し再マウントを伴います。 -
<.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
メモ: コンポーネントについて
LiveView のコンポーネントは主に 2 種類あります。
基本的には静的にしておき、フォームなどの独自の状態を持たせたいものには Live コンポーネントを使用するのがパフォーマンス的にも良いと思います。
- FunctionalComponents : 静的で再利用可能なマークアップの抽象化
- LiveComponents: 独自のライフサイクルを持ちイベントと状態をカプセル化する
メモ: Stream について
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
メモ: テストの作成について
Phoenix は ExUnit を内包しておりテストに関しては ExUnit が担います。
テストは test マクロを使用して行います。
test "簡単な計算のテスト" do
# 1 + 1 の結果が 2 と等しいこと
assert 1 + 1 == 2
end
例えば以下のテストコードは成功します。
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 でテストをグループ化しています。
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"
コントローラや 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'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
Post の追加
では早速 Post を追加していきます。主な仕様は以下の通りです。
- Post の閲覧と操作はログインユーザーのみ行える
- Post の編集・削除は作成者のみが行える。他人の Post は編集・削除できない
- 作成者が退会した場合はその User が投稿した Post も全て削除される
Post の追加: CRUD 機能の自動生成
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 ルートを追加します。
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 の一覧表示:
- /posts にアクセスするとルーターによって PostLive.Index が呼ばれます。その際に
live_action = :index
がセットされます。 - PostLive.Index の mount/3 が呼ばれ、:posts の stream が初期化されます。
- PostLive.Index の handle_params/3 が呼ばれ、live_action のパターンマッチングの結果:post が nil にセットされます。
- テンプレートでは post 一覧が表示されます。
Post の作成(リンクをクリックした時):
- /posts/new にアクセスするとルーターによって PostLive.Index が呼ばれます。その際に
live_action = :new
がセットされます。 - PostLive.Index の handle_params/3 が呼ばれ、live_action のパターンマッチングの結果:post にデフォルトの post がセットされます。
- テンプレートでは作成モーダルが表示されます。
- フォームの送信後に PostLive.FormComponent で post の作成処理が行われます。
- PostLive.FormComponent は処理後に親である PostLive.Index にメッセージを送信します。
- 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 のオプションに記載があります。
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 のオプションに記載があります。
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 の追加: アソシエーションの追加
アソシエーションはスキーマ同士の関連付けです。
User と Post は has_many な関係です。つまり User は複数の Post を持つことができます。
そして Post から見ると User は belongs_to な関係です。つまり Post から作成者である User を取得できます。
マイグレーションファイルには既に外部キーを設定していますが、スキーマ同士はお互いの関係をまだ知らないのでアソシエーションを追加してあげる必要があります。
まずは User スキーマ側に has_many を追加します。
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 フィールドを削除します。
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 と分離しています。
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
でログインユーザーを取得しています。
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}
を渡しています。
<.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
なども使用できます。
結合条件はデフォルトでプライマリキーが使用されます。
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 を受け取るように変更します。
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 "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
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 件に制限しています。
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 要素を残しそれ以外はコレクションから削除します。
@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
認可処理の追加
ここまでの手順で Post の CRUD 操作が完成しましたが、まだ権限管理ができていないため他人の Post を不正に編集・削除できてしまいます。
そのため Post に簡単な認可処理を追加していきます。
認可処理の追加: 権限チェック関数の追加
権限管理の方法はアプリケーションの規模によって色々あるのですが今回は Elixir のパターンマッチングを利用したシンプルなものを作成します。
Communication コンテキストに以下のような関数を追加します。
@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
認可処理の追加: コンテキストからの呼び出し
コンテキスト側での呼び出しは以下のようになります。
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 という新しい例外も作成しました。
このようにカスタム例外を作成しておくと例外の出し分けやテストが明確になります。
defmodule AppWeb.PermissionError do
defexception [:message]
end
認可処理の追加: LiveView からの呼び出し
LiveView からはログインユーザーを渡して結果をパターンマッチングしています。
権限がない場合はカスタム例外を raise します。
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
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 にセットします。
@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
を使用して宣言的に表示を切り替えるようにします。
<: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 で個別に作成します。
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?
関数のテストも追加します。
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
いいね機能の追加
次にいいね機能を追加していきます。
いいね機能の仕様は以下の通りです。
- いいねの閲覧と操作はログインユーザーのみ行える
- 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 つの他にプライマリキーも作成しています。
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 を呼び出す
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 コンテキストの実装が完了したので動作確認のためのテストコードを追加していきます。
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 の要素を追加・削除しています。
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 で非同期的に行われます。
<.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>
この時点ではまだいいね数が更新されませんが、いいねしたユーザーは追加・削除できると思います。
いいね機能の追加: いいね数の更新とトランザクション
いいねイベント後はPost.like_count
を更新する必要があるので一連のトランザクションで処理します。
以下のように Ecto.Multi とパイプ演算子を使って一連のトランザクションを記述できます。
問題なければコミットされ、途中で例外が発生した場合はロールバックされます。
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 として定義します。
そしていいねイベントの結果に応じて加算・減算します。
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
<.button phx-click={JS.push("like", value: %{id: @post.id})}>
<%= @like_count %> Likes
</.button>
いいね機能の追加: テストコードの編集
テストコードを変更して動作確認をしていきます。
まずは create_like と delete_like の後にPost.like_count
が更新されることをテストします。
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 のテストではいいね数が画面上でも更新されることを確認します。
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
アップロード機能の追加
画像のアップロード機能を追加して Post に画像を添付できるようにします。
アップロード機能の追加: マイグレーションファイルの編集
posts テーブルにアップロードファイルのパスを格納する path フィールドを追加します。
アップロードファイルは必須でないので nullable にします。
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 を追加します。
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 の作成時のみファイル選択を表示するようにしています。
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
を使用してファイルを削除します。
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 ディレクトリを追加します。
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 に画像を添付した場合は画像が表示されるはずです。
アップロード機能の追加: テストコードの編集
Phoenix はアップロード機能の自動テストも行えます。
予め/test/support/uploads/hello.png
にテスト用の画像ファイルを用意します。
そして以下のようにfile_input/4
とrender_upload/2
を組み合わせてテストします。
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
ブロードキャストの追加
今の段階では、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
を以下のようにスーパーバイザ配下で管理しているためすでに準備ができています。
@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
ブロードキャストの追加: ブロードキャストモジュールの作成
上記の記事が大変参考になりました。
topic はモジュール名の文字列を使用しているため以下の場合は"App.CommunicationBroadcaster"
になります。
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 されるようなので明示的に購読解除する必要はなさそうです。
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 を追加しています。
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 一覧も更新されるようになりました。