Phoenix LiveViewで作る web/mobile チャットアプリ リアルタイム処理編

この記事は、[fukuoka.ex Elixir／Phoenix Advent Calendar 2020](https://qiita.com/advent-calendar/2020/fukuokaex) 8日目です。
前日は、自分(@thehaigo) の[Phoenix LiveViewで作る web/mobile チャットアプリ 下準備編](https://qiita.com/the_haigo/items/c814c741faa447446ce5) でした

Phoenix LiveViewで web、スマートフォンアプリ両方で動く認証機能付きチャットアプリケーションを作成します
[Phoenix LiveViewで作る web/mobile チャットアプリ 下準備編](https://qiita.com/items/c814c741faa447446ce5/)
[Phoenix LiveViewで作る web/mobile チャットアプリ リアルタイム処理編](https://qiita.com/items/3b34fa4f4c3adaf192c4/) <- 本記事


[前回](https://qiita.com/items/c814c741faa447446ce5/)はLiveView CRUDを作成したので今回はリアルタイム処理を実装していきます


全体の構成は
・LiveViewでroom CRUDとmessageの作成 <-前回で実装済
・メッセージの更新時にルーム内のユーザー全体のメッセージが更新される
・ルーム内のユーザーログイン状況の表示

スマートフォンアプリでは
アプリ側でsignup/signin後WebView経由でLiveViewページをログインした状態で開けるようにします

では始めていきましょう

# メッセージ一覧リアルタイム更新
この機能の実装にはPhoenix.PubSubを使用します
実装ですがpubsubの設定はアプリ作成時ですでに完了しているので、
subscribeとbroadcastを書くだけで終了です

```lib/chat/application.ex
  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      Chat.Repo,
      # Start the Telemetry supervisor
      ChatWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: Chat.PubSub}, # <- ここで読み込んでChat.PubSubプロセス起動
      # Start the Endpoint (http/https)
      ChatWeb.Endpoint
      # Start a worker by calling: Chat.Worker.start_link(arg)
      # {Chat.Worker, arg}
    ]

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

```config/config.exs
config :chat, ChatWeb.Endpoint,
  url: [host: "localhost"],
  secret_key_base: "*",
  render_errors: [view: ChatWeb.ErrorView, accepts: ~w(html json), layout: false],
  pubsub_server: Chat.PubSub, # <- ChatWeb.Endpointで使用するpubsubプロセスを設定
  live_view: [signing_salt: "*]
```

# リアルタイム更新実装
変更内容は
・ mount時にroom:room_idをキーとしてsubscriptionを開始する
・ save eventでput_flash,put_redirectを削除して broadcastを実行
・ broadcastで指定したイベントbroadcast_messageで渡されたpayloadの値をassignする

```[edit]lib/chat_web/live/room_live/show.ex
defmodule ChatWeb.RoomLive.Show do

  @impl true
  def mount(%{"id" => id}, session, socket) do
    ...
    ChatWeb.Endpoint.subscribe("room:#{id}")
    ...
  end

  @impl true
  def handle_event("save", %{"message" => message_params}, socket) do
    case Rooms.create_message(message_params) do
      {:ok, _message} ->
        room = Rooms.get_room_with_messages!(socket.assigns.room.id)
        # 以下書き換え
        ChatWeb.Endpoint.broadcast!(
          "room:#{room.id}",
          "broadcast_message",
          %{ messages: room.messages }
        )
      {:error, %Ecto.Changeset{} = changeset } ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
  # ここ追加
  def handle_info(%{event: "broadcast_message", payload: state}, socket) do
    {:noreply, assign(socket, state)}
  end

  defp page_title(:show), do: "Show Room"
end

```

これでリアルタイム更新が実装できたので動作を確認してみましょう

#リアルタイム更新動作確認
webはChrome,iOS Simulatorはサファリでlocalhostを開いています
[![Image from Gyazo](https://i.gyazo.com/77f119b4b020d999cbbca951fa3d4dcf.gif)](https://gyazo.com/77f119b4b020d999cbbca951fa3d4dcf)

別々のユーザーがメッセージを送信して、その内容がリアルタイムに更新されているのが確認できました

# ルームのログイン状態
同一ルームに居るユーザーの一覧を表示します
実装にPhoenix.Presenceを使用します

Phoenix.Presenceについて
https://hexdocs.pm/phoenix/presence.html

```
Phoenix Presenceは、トピックのプロセス情報を登録し、クラスタ間で透過的に複製することができる機能です。
サーバーサイドとクライアントサイドの両方のライブラリを組み合わせたもので、実装が簡単です。
簡単な使用例としては、アプリケーションで現在オンラインになっているユーザーを表示することが挙げられます。

Phoenix Presenceが特別なのにはいくつかの理由があります。
単一の障害点がなく、単一の真実のソースがなく、完全に標準ライブラリに依存しており、運用上の依存関係がなく、自己修復が可能です。
これらはすべて、コンフリクトフリーのレプリケートデータ型（CRDT）プロトコルで処理されます。
```

まずpresenceを作成します

```
 mix phx.gen.presence
```

superviserにpresenceを追加します

```[edit]lib/chat/application.ex
defmodule Chat.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      Chat.Repo,
      # Start the Telemetry supervisor
      ChatWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: Chat.PubSub},
      # Start the Endpoint (http/https)
      ChatWeb.Endpoint,
      # Start a worker by calling: Chat.Worker.start_link(arg)
      # {Chat.Worker, arg}
      ChatWeb.Presence # <- ここ追加
    ]

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

そのままだとデータが扱いにくいので、メソッドxx_presenceでラップしてデータを加工します
関数名がtrack,update,listとかだとargument errorがでるので関数名は xx_presenceである必要があります

```[new]lib/chat_web/channels/presence.ex
defmodule ChatWeb.Presence do
  @moduledoc """
  Provides presence tracking to channels and processes.

  See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html)
  docs for more details.
  """
  use Phoenix.Presence, otp_app: :chat,
                        pubsub_server: Chat.PubSub
  # 以下追加
  alias ChatWeb.Presence

  def track_presence(pid, topic, key, payload) do
    Presence.track(pid, topic, key, payload)
  end

  def list_presence(topic) do
    topic
    |> Presence.list
    |> Enum.map(fn { _user_id, data} -> data |> extract_metadata end)
  end

  def update_presence(pid, topic, key, payload) do
    metas =
      Presence.get_by_key(topic, key)
      |> extract_metadata
      |> Map.merge(payload)

    Presence.update(pid, topic, key, metas)
  end

  def extract_metadata(data) do
    data |> Map.get(:metas) |> List.first
  end
end
```

mount時にpresenceにroomログインした情報を追加しtrackを開始し、assignsに現在のpresenceのデータを追加します
またpresenceの変更を検知した際にpresenceの最新データをassignします

```[edit]lib/chat_web/live/room_live/show.ex
defmodule ChatWeb.RoomLive.Show do
  use ChatWeb, :live_view
  alias Chat.Rooms
  alias Chat.Rooms.Message
  alias ChatWeb.Presence # <- 追加

  @impl true
  def mount(%{"id" => id}, session, socket) do
    ...
    Presence.track_presence(
      self(),
      "room:#{room.id}", 
      user.id,
      %{ email: user.email, id: user.id}
    ) # <-追加
    
    {: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)
      |> assign(:users, Presence.list_presence("room:#{room.id}")) # <- 追加
    }
  end

  def handle_info(%{event: "presence_diff"}, socket = %{assigns: %{room: room}}) do
    {:noreply, assign(socket, users: Presence.list_presence("room:#{room.id}"))}
  end
  ...
end

```

最後にviewにユーザーログイン状況を表示して終了になります


```elixir
# [edit]lib/chat_web/live/show.html.leex
<ul>
....
</ul>
<h3>Users</h3>
<ul id="user-container">
  <%= for user <- @users do %>
    <li id="<%= user.phx_ref %>"><%= user.email %></li>
  <% end %>
</ul>
<span><%= live_redirect "Back", to: Routes.room_index_path(@socket, :index) %></span>

```
# ログイン状況動作確認
実装が完了しましたので動作確認をしていきましょう

[![Image from Gyazo](https://i.gyazo.com/227e334ebe54647af4714c23915d674b.gif)](https://gyazo.com/227e334ebe54647af4714c23915d674b)

sampleがいるチャットルームのページに飛ぶとUsersにsample2が増え、backで別のページに移動するとsample2が消えるのを確認できました

# スマートフォンログイン
上記２つの機能でもスマートフォンのブラウザで動いてはいますが、
セッションがすぐ切れたりログインし直すのが面倒なので、
api経由でログインし、取得したJWTをアプリに保存して、保存したJWTを使用してsessionを貼ります

# 何が嬉しいの？
> レスポンシブになっているならそのままブラウザやWebViewで表示すればいいのでは？

phoenixのLiveView単体で完結させる場合や、スマートフォンでも見れないことはないで十分な場合はそれで構いませんが、ネイティブアプリにリアルタイムチャットを実装するのが面倒なので、LiveViewだけwebviewで表示して残りはネイティブで実装といった場合に役に立ちます。

またiOSアプリとして申請する場合にwebview onlyだと、それはアプリにする必要があるのか？とレジェクト要因の１つになります
https://developer.apple.com/forums/thread/103427
https://teratail.com/questions/90576
http://techno-monkey.com/apple-reject-ukenai-tsukuruna/

# 下準備
フレームワークはExpo(ReactNative)を使用します
expoでJWTでユーザー認証については
[Phoenix とExpoで作るスマホアプリ ⑤ expo セットアップ編](https://qiita.com/the_haigo/items/ef9181ebeb2406ec8903)
[Phoenix とExpoで作るスマホアプリ ⑥認証機能編](https://qiita.com/the_haigo/items/0f75da8338b4303f497f)
か[こちら](https://github.com/thehaigo/sns-expo/tree/jwt_authenticate)をcloneしてください

phoenix側でやること
jwtからユーザーを取得して、そのユーザーでログインしてsessionに情報を入れるコントローラーを作成

expo側でやること
react-native-webviewのインストール
アプリログイン後、webviewで上記のコントローラーにheaderのBearerにjwtを付けてアクセスする

では始めていきましょう

#Phoenix JWT to Session
Guardian.current_resousceでconnからuser_idを取得します
次に取得したユーザーでログインし、ブラウザからログインしたのと同じ状態にします

```[new]lib/chat_web/controllers/room_controller.ex
defmodule ChatWeb.RoomController do
  use ChatWeb, :controller

  def index(conn, _params) do
    user = Chat.Users.get_user!(current_resource(conn).id)
    ChatWeb.UserAuth.log_in_user(conn, user)
  end
end
```

jwt関連のGuardianの設定をしたモジュールを読み込み
jwt認証でしかアクセスできない/sp scope内にさっきのログイン処理のルートを追加します

```[edit]lib/chat_web/router.ex
defmodule ChatWeb.Router do
  use ChatWeb, :router
  alias ChatWeb.ApiAuthPipeline # <- ここ追加

  ...
  scope "/api/v1", ChatWeb do # <- expoのコードに合わせるため /v1 を追加
    pipe_through :api

    post "/sign_up", Api.UserController, :sign_up
    post "/sign_in", Api.UserController, :sign_in
  end

  scope "/sp", ChatWeb do
    pipe_through [:browser, :jwt_authenticated]
    get "/", RoomController, :index
  end
  ...
end

```

最後にroot(/)にはroomsの導線を入れてないので、
ログイン後のリダイレクト先を /から /roomsに変更します

```lib/chat_web/controllers/user_auth.ex
defmodule ChatWeb.UserAuth do
  import Plug.Conn
  import Phoenix.Controller
...

  defp signed_in_path(_conn), do: "/" # <- ここ削除
  defp signed_in_path(_conn), do: "/rooms" # <-　ここ追加
end

```
phoenix側は以上になります
次にexpo側を実装していきましょう

# Expo JWT to Session

```
expo install react-native-webview
```

webviewでアクセスする時にjwtをつけるだけです

```[edit]screens/HomeScreen.js
import React, { useContext } from "react";
import { StyleSheet } from "react-native";
import { WebView } from "react-native-webview";
import { Context as AuthContext } from "../context/AuthContext";
const HomeScreen = () => {
  const {
    state: { token },
  } = useContext(AuthContext);
  return (
    <WebView
      source={{
        uri: "http://localhost:4000/sp/",
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }}
      style={{ marginTop: 20 }}
    />
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "center",
    alignSelf: "center",
    flex: 1,
  },
});
export default HomeScreen;

```

実装は以上になります
次に動作確認をしていきましょう

# JWT -> Sessionの動作確認

[![Image from Gyazo](https://i.gyazo.com/f8771fdf38f0691745af1891dabfac9b.gif)](https://gyazo.com/f8771fdf38f0691745af1891dabfac9b)

expoからログインしてLiveViewのページにアクセスし、正常に動いているのを確認できました

いかがでしたでしょうか？
Phoenix.LiveViewとPresenceを使うことでリアルタイム処理の実装がとても楽になりました！
LiveViewは去年できた機能でスクロールなどのDOM操作はJS hookを使用してJSを書く必要があり、
全てをElixirで書けるわけではありませんが、複雑になりがちなデータ加工やリアルタイム処理をとてもシンプルに書くことができます。

アップデートの頻度も高くファイルアップロードの機能が実装されたりしています
また以下のようにベストプラクティスやライブラリも開発されており、今後が楽しみです
PhoenixのLiveComponentでReact + ReduxのようなFlux patternの書き方
https://www.youtube.com/watch?v=bhdeHhwDFQo&feature=youtu.be
LiveViewでReactを使えるようにするライブラリ
https://hexdocs.pm/phoenix_live_react/readme.html
vue + bootstrapのようなライブラリ
http://surface-demo.msaraiva.io/

2日続けてと長い記事になりましたが、ありがとうございました

fukuoka.ex Elixir／Phoenix Advent Calendar 2020 の9日目は @koga1020 さんの記事です お楽しみに！

# 差分
[リアルタイム更新](https://github.com/thehaigo/chat/commit/98de9a471ab75bf4c9713bbb1f4fef10f45b1596)
[ログイン状況](https://github.com/thehaigo/chat/commit/7644d1c5b5544820e51079edb9f8b4632a0ad665)
[jwt to session](https://github.com/thehaigo/chat/commit/98de9a471ab75bf4c9713bbb1f4fef10f45b1596)
[jwt to session expo](https://github.com/thehaigo/sns-expo/commit/ba988769ebc00c9352b0d6bcada8445807892a72)
# 参考ページ
https://www.870labo.com/posts/create-chat-app-with-liveview-part5
https://qiita.com/pojiro/items/dc8c9d97be82f91560bf
https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html
https://hexdocs.pm/phoenix/Phoenix.Presence.html