22
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

fukuoka.ex Elixir/PhoenixAdvent Calendar 2020

Day 8

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

Last updated at Posted at 2020-12-07

この記事は、fukuoka.ex Elixir/Phoenix Advent Calendar 2020 8日目です。
前日は、自分(@thehaigo) のPhoenix LiveViewで作る web/mobile チャットアプリ 下準備編 でした

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

前回は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

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

ルームのログイン状態

同一ルームに居るユーザーの一覧を表示します
実装に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にユーザーログイン状況を表示して終了になります

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

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

スマートフォンログイン

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

何が嬉しいの?

レスポンシブになっているならそのままブラウザやWebViewで表示すればいいのでは?

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

またiOSアプリとして申請する場合にwebview onlyだと、それはアプリにする必要があるのか?とレジェクト要因の1つになります
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 セットアップ編
Phoenix とExpoで作るスマホアプリ ⑥認証機能編
こちらを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

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 さんの記事です お楽しみに!

差分

リアルタイム更新
ログイン状況
jwt to session
jwt to session expo

参考ページ

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

22
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?