この記事は、fukuoka.ex Elixir/Phoenix Advent Calendar 2020 7日目です。
前日は、@yoshitia さんの やがてelixirになるでした。
やること
Phoenix LiveViewで web、スマートフォンアプリ両方で動く認証機能付きチャットアプリケーションを作成します
全体の構成は
・LiveViewでroom CRUDとmessageの作成 <- 本記事の範囲
・ルーム内のユーザーログイン状況の表示
・メッセージの更新時にルーム内のユーザー全体のメッセージが更新される
スマートフォンアプリでは
アプリ側でsignup/signin後WebView経由でLiveViewページをログインした状態で開けるようにします
補足
Phoenixとは
https://hexdocs.pm/phoenix/overview.html
Phoenix は Elixir で書かれたウェブ開発フレームワークで、サーバーサイドの MVC (Model View Controller) パターンを実装しています。
Phoenix.LiveView
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
LiveViewは、サーバーレンダリングされたHTMLを使用して、リッチでリアルタイムなユーザー体験を提供します。
「Elixirでフロントエンドのバリデーションとかアニメーションと書けちゃう。しかもリアルタイムに。」
これを使用することによって、Discordのようなユーザーのログイン状況、メッセージ新規作成時に他のユーザーのメッセージ一覧を更新などを容易に実装できます
またフロントエンドのデータ加工部分やイベント,viewをElixirで記述でき、テストもElixirで書くことができます
トップページにコードと実際に動いているイメージがあるのでそちらを見ていただければと思います
https://www.phoenixframework.org/
ReactやVueを置き換える!といった類ではありませんが、リアルタイム処理がとても楽にかけるというのが最大の利点だと思います
準備
下準備としてwebインターフェースとAPIでのsignup/signinは以下のページを参考に作成していただくか
Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth
Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編
こちらをcloneしてください
ではroomのLiveView CRUDとmessage作成、一覧を実装していきます
Rooms LiveView CRUD
genコマンドは mix phx.gen.liveです
データ構造はルーム名(name)、詳細(description)と外部キーのuser_idとします
mix phx.gen.live Rooms Room rooms name:string description:string user_id:references:users
mix ecto.migrate
そのままだとroom作成時にUserと関連付けられないので、
リレーションの設計とchangesetのcastにuser_idを追加
defmodule Chat.Users.User do
use Ecto.Schema
import Ecto.Changeset
@derive {Inspect, except: [:password]}
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :hashed_password, :string
field :confirmed_at, :naive_datetime
has_many :rooms, Chat.Rooms.Room # <= ここ追加
timestamps()
end
...
end
#[edit] lib/chat/rooms/room
defmodule Chat.Rooms.Room do
use Ecto.Schema
import Ecto.Changeset
schema "rooms" do
field :description, :string
field :name, :string
field :user_id, :integer # <- ここ削除
belongs_to :user, Chat.Users.User # <- ここ追加
timestamps()
end
@doc false
def changeset(room, attrs) do
room
|> cast(attrs, [:name, :description, :user_id]) # <- user_idを追加
|> validate_required([:name])
end
end
defmodule ChatWeb.Router do
use ChatWeb, :router
...
scope "/", ChatWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings/update_password", UserSettingsController, :update_password
put "/users/settings/update_email", UserSettingsController, :update_email
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
# 以下自動で追加
live "/rooms", RoomLive.Index, :index
live "/rooms/new", RoomLive.Index, :new
live "/rooms/:id/edit", RoomLive.Index, :edit
live "/rooms/:id", RoomLive.Show, :show
live "/rooms/:id/show/edit", RoomLive.Show, :edit
end
...
end
User情報をセッションから取得
room作成時にユーザーに関連付ける処理を追加します
defmodule Chat.Users do
...
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
...
end
<h2><%= @title %></h2>
<%= f = form_for @changeset, "#",
id: "room-form",
phx_target: @myself,
phx_change: "validate",
phx_submit: "save" %>
....
<%= hidden_input f, :user_id %> # <- ここ追加
<%= submit "Save", phx_disable_with: "Saving..." %>
</form>
ログインしたユーザーの情報がトークンとしてsessionに保持されていますので、
先程実装したget_user_by_session_tokenでトークンをもとにユーザーを取得し、
マウント時にassignsにログインユーザーの情報を追加します
defmodule ChatWeb.RoomLive.Index do
use ChatWeb, :live_view
alias Chat.Rooms
alias Chat.Rooms.Room
@impl true
# 以下書き換え
def mount(_params, session, socket) do
user = Chat.Users.get_user_by_session_token(session["user_token"])
{:ok, assign(socket |> assign(:user, user), :rooms, list_rooms())}
end
...
# 新規作成時にuser_idの情報を追加
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Room")
|> assign(:room, %Room{user_id: socket.assigns.user.id}) # <- user_id追加
end
end
Message作成
messageを作成、リレーションの設定をします
mix phx.gen.schema Rooms.Message messages body:string user_id:references:users room_id:references:rooms
defmodule Chat.Rooms.Room do
use Ecto.Schema
import Ecto.Changeset
schema "rooms" do
field :description, :string
field :name, :string
belongs_to :user, Chat.Users.User
has_many :messages, Chat.Rooms.Message # <- ここ追加
timestamps()
end
end
defmodule Chat.Rooms.Message do
use Ecto.Schema
import Ecto.Changeset
schema "messages" do
field :body, :string
#user_id,room_idをbelongs_toに書き換え
belongs_to :user, Chat.Users.User
belongs_to :room, Chat.Rooms.Room
timestamps()
end
@doc false
def changeset(message, attrs) do
# user_id, :room_idを受け取り、必須になるように変更
message
|> cast(attrs, [:body, :user_id, :room_id])
|> validate_required([:body, :user_id, :room_id])
end
end
rooms.exにメッセージ取得と作成機能を追加
defmodule Chat.Rooms do
alias Chat.Rooms.Message
...
def get_room_with_messages!(id) do
Room
|> preload(messages: :user)
|> Repo.get!(id)
end
def change_message(%Message{} = message, attrs \\ %{}) do
Message.changeset(message, attrs)
end
def create_message(attrs \\ %{}) do
%Message{}
|> Message.changeset(attrs)
|> Repo.insert()
end
...
end
最後にチャットルームを作成します
マウント時にuser、現在のroomと関連するmessage、
messageのchangeset、メッセージ作成後のredirect先をassignします
defmodule ChatWeb.RoomLive.Show do
use ChatWeb, :live_view
alias Chat.Rooms
alias Chat.Rooms.Message
@impl true
def mount(%{"id" => id}, session, socket) do
user = Chat.Users.get_user_by_session_token(session["user_token"])
room = Rooms.get_room_with_messages!(id)
changeset = Rooms.change_message(%Message{user_id: user.id, room_id: room.id})
return_to = Routes.room_show_path(socket, :show, room)
{:ok,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:room, room)
|> assign(:messages, room.messages)
|> assign(:return_to, return_to)
|> assign(:changeset, changeset)
|> assign(:user, user)
}
end
@impl true
def handle_event("save", %{"message" => message_params}, socket) do
case Rooms.create_message(message_params) do
{:ok, _message} ->
{:noreply, socket
|> put_flash(:info, "Message created successfully")
|> push_redirect(to: socket.assigns.return_to)
}
{:error, %Ecto.Changeset{} = changeset } ->
{:noreply, assign(socket, changeset: changeset)}
end
end
defp page_title(:show), do: "Show Room"
end
view
<ul>
<li>
<strong>Name:</strong>
<%= @room.name %>
</li>
<li>
<strong>Description:</strong>
<%= @room.description %>
</li>
<table>
<tbody id="messages">
<%= for message <- @messages do %>
<tr id="message-<%= message.id %>">
<td><%= message.body %></td>
<td><%= message.user.email %></td>
</tr>
<% end %>
</tbody>
</table>
<div>
<%= f = form_for @changeset, "#",
id: "board-form",
phx_submit: "save" %>
<%= textarea f, :body, class: "form-control" %>
<%= error_tag f, :body %>
<%= hidden_input f, :user_id %>
<%= hidden_input f, :room_id %>
<%= submit "Save", phx_disable_with: "Saving..." %>
</form>
</div>
</ul>
<span><%= live_redirect "Back", to: Routes.room_index_path(@socket, :index) %></span>
動作確認
roomが作成でき、room内でmessageが作成できることが確認できました
本記事は以上になりますありがとうございました
8日目は、引き続きPhoenix LiveViewで作る web/mobile チャットアプリ リアルタイム処理編 をお送りいたします
次回はこのままだと、他のユーザーがメッセージを送信した際にページリロードをしないと新しいメッセージは見れないので、
LiveViewの得意なリアルタイム処理で、メッセージの更新・ユーザーのログイン状況の実装と
スマホアプリでのLiveViewアプリ連携を実装していきます
今回のコード
https://github.com/thehaigo/chat/tree/realtime_ready
参考にしたサイト
https://www.870labo.com/posts/create-chat-app-with-liveview-part4/
https://qiita.com/pojiro/items/dc8c9d97be82f91560bf
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html
https://qiita.com/kotar0/items/a3a886fa53dc6e0ab854
https://www.phoenixframework.org/