技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第11章 Presence)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 今回はPresenceということであまりなじみのない概念・・・
どうやらChannnel絡みの機能だそうですので、前回のサンプルの続き、ということになりそうです。

Presence

Presenceはトピックのプロセス情報を取得したり、クラスタ上にこの情報を複製したりする機能のようです。
幸いサーバー側もクライアント側も実装がシンプルになっているそうなのでそこまで苦労することはないでしょう。

ごちゃごちゃ書いてますが要はCRDTであるといった感じです。
CRDTに関しては下記を参考にしました。

qiita.com

ぶっちゃけよく分かってませんが、要はチャットとかで時間の整合性が若干ズレても構わんけど最終的に全員の発言をきっちり表示したいとかそんなんだと思います。

Presenceモジュール作成

Presenceを利用するにはPresenceモジュールを作成する必要があるそうです。mixで作成していきます。

PS \hello> mix phx.gen.presence
* creating lib/hello_web/channels/presence.ex

Add your new module to your supervision tree,
in lib/hello/application.ex:

    children = [
      ...
      HelloWeb.Presence
    ]

You're all set! See the Phoenix.Presence docs for more details:
http://hexdocs.pm/phoenix/Phoenix.Presence.html

mix phx.gen.presenceするとlib/hello_web/channels/presence.exが作成されます。
該当するファイルはほとんどがコメントで中身はほとんどないです。

defmodule HelloWeb.Presence do
...
  use Phoenix.Presence, otp_app: :hello,
                        pubsub_server: Hello.PubSub
end

ひとまずこのままにしておきます。
生成した際にでていた指定通りにlib/hello/application.exHelloWeb.Presenceを追記します。

...
  def start(_type, _args) do
    # List all child processes to be supervised
    children = [
      # Start the Ecto repository
      Hello.Repo,
      # Start the endpoint when the application starts
      HelloWeb.Endpoint,
      # Starts a worker by calling: Hello.Worker.start_link(arg)
      # {Hello.Worker, arg},
      HelloWeb.Presence,
    ]
...

mixを利用したChannel作成

次にRoomChannelを作成します。前回作成したんですが、どうやらmixでも生成できるみたいなので作り直します。

PS \hello> mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs

Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", HelloWeb.RoomChannel

lib/hello_web/channels/user_socket.exに上記の情報を追加し、connect関数を書き換えます。

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  channel "room:lobby", HelloWeb.RoomChannel

  def connect(params, socket) do
    {:ok, assign(socket, :user_id, params["user_id"])}
  end
  def id(_socket), do: nil
end

channelのルーティングの方は前回通りです。
connectはパラメータからユーザーIDを取り出し、ソケット情報にアサイン(登録)しておきます。
おそらく接続ユーザー一覧とかを作成したい意図でしょう。とりあえず先に進みます。

最後のdef id(_socket), do: nilですが、サンプルではとくに記載されていませんがコレが無いとUserSocketの生成の際にエラーが発生するようです。

Channel編集

RoomChannelでPresenceを利用するように編集していきます。

defmodule HelloWeb.RoomChannel do
  use HelloWeb, :channel
  alias HelloWeb.Presence

  def join("room:lobby", payload, socket) do
    send(self(), :after_join)
    {:ok, socket}
  end

  def handle_info(:after_join, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
        online_at: inspect(System.system_time(:second))
    })
    {:noreply, socket}
  end
end

接続を確立した際にsend/2:after_joinというメッセージを送っています。
これはChannelの内部実装がGenServerになっていて、send/2でメッセージを送信しているようなものでしょう。なのでhandle_infoが発火してdef handle_info(:after_join, socket) doの処理が始まるといったところです。

肝心のhandle_infoでは"presence_state"というイベントにPresence.list(socket)というメッセージを載せてプッシュ(サブスクライブ)しています。
Presenceはどうやら"presence_state""presence_diff"といったイベントを監視して状態を共有しているのだと思います。
Presence.trackの箇所はユーザーID毎にどのタイミングまでオンラインであったかをトラッキングできるように情報を更新しているようなものでしょうか。inspect(System.system_time(:second))で現在のシステムの時間がUNIX時間で格納されるようです。

クライアントサイドを実装する

phoenix.jsを使っているクライアント側でPresenceを取得したりするよう実装します。
どうやら"presence_state""presence_diff"といったイベントが発生した際にpresence.onSyncでその時に実行されるコールバック関数を登録できるようです。
現在接続しているユーザーを表示している際などは、レンダリング関数などもこのコールバックの中に入れておくのが望ましいでしょう。

Presenceに登録されている情報を取得するにはpresence.listを利用するようです。
presences.list()のコールバックには引数が二つ用意されており、第一引数はPresence id(今回でいうユーザーID)と、第二引数は登録してあるメタ情報(今回で言うonline_atのマップ)が利用できます。

サンプルではassets/js/app.jsを利用してますが、ここでは前回同様assets/js/socket.jsを編集します。

import {Socket, Presence} from "phoenix"

let socket = new Socket("/socket", {
  params: {user_id: window.location.search.split("=")[1]}
})

let channel = socket.channel("room:lobby", {});
let presence = new Presence(channel);

function renderOnlineUsers(presence) {
  let response = "";

  presence.list((id, {metas: [first, ...rest]}) => {
    let count = rest.length + 1;
    response += `<br>${id} (count: ${count})</br>`; 
  });
  
  document.querySelector("#main").innerHTML = response;
}

socket.connect();

presence.onSync(() => renderOnlineUsers(presence));

channel.join();

export default socket

上記で#mainを利用するような記述に変更しましたのでlib/hello_web/templates/page/index.html.eexを編集しておきます。

<div id="main"></div>

今回は接続しているクライアントを表示するだけにします。
これで実装完了です。実際にブラウザで見てみましょう。

動作確認

実際にブラウザで見てみます。
http://localhost:4000/?name=Aliceを2個、http://localhost:4000/?name=Bobを1個のブラウザで開きます。

f:id:ysmn_deus:20190316191033g:plain

ここでは写し忘れましたが、ブラウザを閉じるときちんと名前が消えたりカウントが減ったりします。

厳密にはnameというクエリを取得しているわけではないんですが、とりあえずそれっぽいものはできました。
実際の実装ではクエリ文字からユーザーIDを取得するよりかはユーザートークンから取得することになりそうです。