Phoenix LiveViewで作る web/mobile チャットアプリ 下準備編

この記事は、[fukuoka.ex Elixir／Phoenix Advent Calendar 2020](https://qiita.com/advent-calendar/2020/fukuokaex) 7日目です。
前日は、@yoshitia さんの [やがてelixirになる](https://qiita.com/yoshitia/items/5ad36ef83ad69a564970)でした。

# やること
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を使用して、リッチでリアルタイムなユーザー体験を提供します。

https://qiita.com/kotar0/items/a3a886fa53dc6e0ab854
> 「Elixirでフロントエンドのバリデーションとかアニメーションと書けちゃう。しかもリアルタイムに。」

これを使用することによって、Discordのようなユーザーのログイン状況、メッセージ新規作成時に他のユーザーのメッセージ一覧を更新などを容易に実装できます

またフロントエンドのデータ加工部分やイベント,viewをElixirで記述でき、テストもElixirで書くことができます
トップページにコードと実際に動いているイメージがあるのでそちらを見ていただければと思います
https://www.phoenixframework.org/

ReactやVueを置き換える！といった類ではありませんが、リアルタイム処理がとても楽にかけるというのが最大の利点だと思います

# 準備
下準備としてwebインターフェースとAPIでのsignup/signinは以下のページを参考に作成していただくか
[Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth](https://qiita.com/the_haigo/items/76dedef74ffe98230137)
[Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編](https://qiita.com/the_haigo/items/e01e671ca117ed2b8648)

[こちら](https://github.com/thehaigo/chat/tree/setup)を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を追加

```[edit]lib/chat/users/user.ex
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
```

```elixir
#[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

```

```[edit]lib/chat_web/router.ex
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作成時にユーザーに関連付ける処理を追加します

```[edit]lib/chat/users.ex
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
```

```eex:[edit]lib/chat_web/live/room_live/form_component.html.leex
<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にログインユーザーの情報を追加します

```[edit]lib/chat_web/live/room_live/index.ex
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
```

```[new]lib/chat/rooms/room.ex
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

```

```[new]lib/chat/rooms/message.ex
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にメッセージ取得と作成機能を追加

```[edit]lib/chat/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します

```[new]libs/chat_web/live/room_live/show.ex
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

```eex:[new]lib/chat_web/live/room_live/show.html.eex
<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>

```

# 動作確認
以上で実装が完了しましたので動作確認をしましょう
[![Image from Gyazo](https://i.gyazo.com/646607402e172d78a97c114a0b33be5d.gif)](https://gyazo.com/646607402e172d78a97c114a0b33be5d)

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/