22
17

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 5 years have passed since last update.

Phoenix Channelとelm-phoenixについて

Last updated at Posted at 2018-03-11

Phoenix Channelをテーマにした過去記事
Phoenix Channelとelm-phoenixについて -Qiita
Phoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiita
Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita
東京電力電力供給状況監視 - Phoenix Channel - Qiita

 phoenixはelixirのキラーアプリケーションで、channelはphoenixのキラー機能であると言われています。そこで今回はchannelを試してみることにしました。channelはwebsocketのラッピングで、Webアプリの新しい通信の枠組みを提供するものと言えます。効率の悪いコネクションレスのhttpの代わり、という位置づけです。ここでは、技術的な理論ではなく、実際の使い方を見ていきたいと思います。

 クライアントはElmを使いたいと思います。saschatimme/elm-phoenixというElmのパッケージを使います。exampleコードもそのパッケージに付属のものを使います。ただし、単にgithubからダウンロードするのではなく、通常のPhoenixプロジェクトの構築手順に従い構築していきたいと思います。つまりこの記事の目的は、チャットサーバを構築することでなく、Phoenixのchennelの使い方の手順を示すことにあります。現在最新のPhoenix1.3を使います。

 本記事の目標は2つあります。第1にサーバ側でPhoenixのChannelが容易に構築できることを見ます。第2にクライアント側をElmで実装し、elm-phoenixの使い方を説明します。

1.Phoenixのプロジェクトを開始する

次のコマンドでプロジェクトを開始します。データベースは使いませんので --no-ectoを付けています。

mix phx.new elm_phoenix --no-ecto
cd elm_phoenix

 次に以下のコマンドでサーバを立ち上げ、ブラウザから4000ポートを叩いてください。phoenixの初期画面が表示されます。

iex -S mix phx.server

2.Phoenixのchannelの設定をステップを踏んで行う

 さてmix.exsファイルで、このプロジェクトのスタート地点を確認します。modはこのApplicationのトップのmoduleを示しています。(ちなみにextra_applicationsはこのApplicationが実行されるときに、すでに動作している必要があるApplicationのリストです。動作していなければ起動されます。)

mix.exs
#
  def application do
    [
      mod: {ElmPhoenix.Application, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end
#

 Phoenix channelにはPresenceという機能があります。Phoenix.Presence。これはchannelのtopicをsubscribeしているクライアントを記録するためのものです。つまりチャットに参加しているクライアントを記録します。一般的には分散環境などでも安全に記録するためにPresenceが提供されているようです。以下のコマンドでlib/elm_phoenix_web/channels/presence.exが作成されます

mix phx.gen.presence

 作成されたファイルの中身です。ElmPhoenixWeb.Presence が定義されています。以下はコメントを削除した後のシンプルなものですが、コメントにはpresenceの実装のガイドがありますので一読の価値はあると思います。

lib/elm_phoenix_web/channels/presence.ex
defmodule ElmPhoenixWeb.Presence do
  use Phoenix.Presence, otp_app: :elm_phoenix,
                        pubsub_server: ElmPhoenix.PubSub
end

 modで指定されたElmPhoenix.Applicationを確認してみましょう。今回はPresence機能を使いたいので、childrenにElmPhoenixWeb.Presenceを追加します。これでElmPhoenix.ApplicationはsupervisorとしてElmPhoenixWeb.EndpointとElmPhoenixWeb.Presenceをスタートするようになります。Presenceはあるchannelのあるtopicに参加しているユーザをtrackし続ける機能です。

lib/elm_phoenix/application.ex
#
    children = [
      supervisor(ElmPhoenixWeb.Endpoint, []),
      supervisor(ElmPhoenixWeb.Presence, [])
    ]
#

 ElmPhoenixWeb.Presenceを追加したことにより、join後にpresencesがtrackされるようになります。

 次にElmPhoenixWeb.Endpointをみてみます。このプロジェクトのEndpointで、socketへのrouteがElmPhoenixWeb.UserSocketへ振り分けられていることを確認します。

lib/elm_phoenix_web/endpoint.ex
defmodule ElmPhoenixWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :elm_phoenix

  socket "/socket", ElmPhoenixWeb.UserSocket
#

 次にElmPhoenixWeb.UserSocketを確認します。以下のようにchannel行のコメントを外します。これで"room:"ではじまるtopicに関連したメッセージは全てElmPhoenixWeb.RoomChannelを通るようになります。

lib/elm_phoenix_web/channels/user_socket.ex
defmodule ElmPhoenixWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", ElmPhoenixWeb.RoomChannel
#
  def connect(_params, socket) do
    {:ok, socket}
  end
#

 ちなみにここで定義してあるconnect/2は、クライアントからのsocket接続のリクエストが必ず通る関数です。この関数は認証などを行う場所として適していますが、今回はデフォルトのソースのままで修正は加えません。

3.Phoenixのchannel module

 さて以下にsaschatimme/elm-phoenixのexampleコード(サーバ側)を示します。構築の仕方が違うので、ディレクトリ構成がオリジナルと異なっていますが、それ以外はオリジナルのままです。基本的に今回のチャットプログラム構築におけるサーバ側のプログラミンはこのchannel module(room_channel.ex)だけです。しかもここにはビジネスロジックと呼べるものは存在しません。Phoenix Channelがいかに簡単に利用できるかがわかると思います。

lib/elm_phoenix_web/channels/room_channel.ex
defmodule ElmPhoenixWeb.RoomChannel do
  use ElmPhoenixWeb, :channel
  alias ElmPhoenixWeb.Presence

  def join("room:lobby", %{"user_name" => user_name}, socket) do
    send(self(), {:after_join, user_name})
    {:ok, socket}
  end

  def handle_in("new_msg", %{"msg" => msg}, socket) do
    user_name = socket.assigns[:user_name]

    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name})
    {:reply, :ok, socket}
  end

  def handle_info({:after_join, user_name}, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
    {:noreply, assign(socket, :user_name, user_name)}
  end

  def terminate(_reason, socket) do
    {:noreply, socket}
  end

  defp now do
    System.system_time(:seconds)
  end
end

 まずjoin/3についてです。クライアントがChannel通信を利用するためには、最初にtopic-subtopicを指定してjoinする必要があります。joinに対するサーバ側の処理はここでコーディングしています。"room:lobby"はtopicが"room"でsubtopicが"lobby"であることを示しています。presenceを使うので以下のコードを追加しています。

send(self(), {:after_join, user_name})

 これは自分自身(self())にメッセージを送り、handle_info/3を起動しています。

  def handle_info({:after_join, user_name}, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
    {:noreply, assign(socket, :user_name, user_name)}
  end

 クライアント側はPresenceの状態を維持するために、'presence_state'と'presence_diff'のイベントをリッスンしています。

 push/3はjoinしてきたクライアントに対して'presence_state'イベントを通知し、現在のPresenceの状態(joined ユーザ一覧)を伝えます。クライアントは初期状態としてこれを記憶します。

 またPresence.track/3はこのchannel processをuser_nameに対するpresenceとして登録します。これ以降のPresenceの変更は'presence_diff'イベントとして、自動的にクライアントに通知されます。初期状態が変更され、Presenceの状態が最新のものに維持され続けます。ここではこれ以上Presenceに深入りしません(できません)が、このようなコーディングで入退室時のユーザ一覧を管理できるようになります。

 handle_in/3でチャットメッセージを受取、他のユーザにブロードキャストします。

4.クライアントでelm-phoenixの設定をステップを踏んで行う

 クライアント側のポイントは2つあります:(1)PhoenixのクライアントとしてElmを使う。(2)elm-phoenixでchannel接続を行う。

 (1)については私の過去の記事「ElmからPhoenix v1.3のRest APIを叩いてみる - Qiita」も参考にしてください。(2)は初めてですのでここで紹介していきます。

 しばらくassetsディレクトリで作業します。

cd assets/

 Phoenixにおいてassets(JavaScriptやCSS)の管理はBrunchを使います。Elmを使うのでassets/brunch-config.jsを修正します。2個所追加します。(B)によってElmプログラムがassets/elm/src/MyChat.elmであり、jsへのコンパイル結果をassets/vendorディレクトリに吐き出すことを指示しています。一般的にvendorディレクトリはBrunchによってこれ以上変換(コンパイル)されないファイルの置き場所で、そこのファイルは全て、appプログラムの前にロードされ、連結されるという決まりになっています。特にapp.jsから明示的にimportされなくとも、自動的にロードされます。

assets/brunch-config.js
#
  paths: {
    // Dependencies and current project directories to watch
//---------- (A)"elm"を追加
    watched: ["static", "css", "js", "elm",  "vendor"],
//----------
    // Where to compile files to
    public: "../priv/static"
  },

  // Configure your plugins
  plugins: {
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/vendor/]
    },
//---------- (B)elmBrunch項を追加
    elmBrunch: {
      elmFolder: "elm",
      mainModules: ["src/MyChat.elm"],
      outputFolder: "../vendor"
    }
//----------
  },
#

 (B)のElm拡張をを扱えるように、elm-brunchをインストールします。

npm install --save-dev elm-brunch

 次にelmディレクトリを作成し必要なパッケージをインストールします。

mkdir elm
cd elm
elm-package install elm-lang/html
elm-package install elm-lang/websocket

 さて今回のテーマのひとつであるelm-phoenixパッケージですが、elm-package installではインストールできません。ちょっと面倒ですが、elm-github-installを使います。公式サイト「saschatimme/elm-phoenix」に従ってインストールします。

 elm-package.jsonを修正します。

assets/elm/elm-package.json
{
  ...
  "dependencies": {
    ...
    "saschatimme/elm-phoenix": "0.3.0 <= v < 1.0.0",
    ...
  }
  ...
}

 次にelm-github-installをインストールします。これは公式サイトからのリンクで、いくつかの選択肢がありましたが、私の環境では以下のコマンドで行いました。

npm i -g elm-github-install --unsafe-perm=true --allow-root

 さて、これで以下のコマンドでelm-phoenixをインストールできます。

elm-github-install

 この時exampleもインストールされ、cssファイルなどが勝手にロードされてしまうので、削除します。

rm -rf ./assets/elm/elm-stuff/packages/saschatimme/elm-phoenix/0.3.2/example/

5.クライアント側でelm-phoenixを使ってElmプログラムを作る

 まずexampleのソースためのディレクトリを作成します。

cd assets/elm
mkdir src

5-1 elm-phoenixライブラリ

 elm-phoenixはElmプログラムからPhoenixのChannelを扱うためのライブラリです。

 以下公式ドキュメント「Phoenix」を参照していきます。公式ドキュメントにない関数もありますので、「ソースコード」も見ながら説明していきます。

Phoenix
Phoenix ChannelsのためのElm clientです。このパッケージは Phoenix Channelsへの接続を簡単にしてくれます。通常のPhoenix Socket Javascript libraryよりもより宣言的に使えます。このライブラリは、単にSocketと参加したいChannelsのリストを渡すだけで多くのことを為してくれます。つまり接続をオープンし、Channelsにjoinし、ネットワークエラー時の再接続を試み、その他のイベントハンドラを登録します。Phoenixはconnect関数とpush関数を提供します。加えて以下の要素を使うことができます。

Phoenix.Socket
Phoenix.Socketはどのendpointへsocket接続を確立しているかを宣言しています。

Phoenix.Channel
Phoenix.Channelはどのtopicがjoinされるべきかを宣言し、イベントハンドラを登録し、さまざまなライフサイクルのイベントに対応したcallbackを持つことを宣言します。

Phoenix.Presence
PhoenixのPresence機能をサポートするためのchannelsの拡張です。

Phoenix.Push
channelへ情報をpushするためのmessageです。

 これらの要素をどのように使ってチャットアプリを構築しているかを以下に見ていきます。

5-2 Subscriptions

 「5-2 Subscriptions」~「5-5 Phoenix.Presence」までSubscriptionのコードで、Channelのメッセージの受信についての説明になります。「5-6 Phoenix.Push」がメッセージ送信の説明となります。

 ElmのSubscriptionsについて少し復習します。SubscriptionsはElmの外で起こったイベントを、Elmのmessageに変換しupdate関数へ送りつけることによって、Elmの中へ取り込む手段です。subscriptions関数の引数はModelです。これはModelの変更によって、subscriptions関数がどんなイベントをリッスンするかを選択することができることを意味します。Modelを変更することで自動的に変更されます。

 さて本exampleのsubscriptionを見てみましょう。socketやchannelをコーディングしていきますが、これはこのexampleの特性上仕方ないのですが、elm-phoenixパッケージの全機能を使うようにプログラミングされています。多分必要以上に複雑化していると感じるかもしれませんが、そのまま掲載します。

 subscriptionsの定義ではphoenixSubscription modelとTime.every Time.second Tickでイベントをリッスンします。ここではphoenixSubscription modelに注目しましょう。

subscriptions
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch [ phoenixSubscription model, Time.every Time.second Tick ]

phoenixSubscription model =
    Phoenix.connect socket <|
        if model.isActive then
            [ lobby model.userName ]
        else
            []

 phoenixSubscriptionは、上で復習した通りmodelの状態に依存して定義を変えますが、基本的にはsocket接続をオープンします。model.isActiveでログインを判断し、ログインしていればそのユーザ名でchannelにjoinします。チャンネルリストが空の時はsocket接続のオープン状態を維持し続けます。逆に退室の場合はmodel.isActive=FALSEとするだけで、channelリストが[]でconnectし直すので、サーバ側に状況が伝わるようです。

 Phoenix.connectは、接続したいsocketとjoinしたいchannelの宣言を引数に渡せば、一気にsocket openとchannelへのjoinを行うものです。

Phoenix.connect
Phoenix.connect : Socket -> List (Channel msg) -> Sub msg

 lobby model.userNameについては「5-4 Phoenix.Channel」で詳しく見ますが、ここでは型だけを確認しておきます。

Phoenix.connect
lobby : String -> Channel Msg

 念のための公式サイトのサンプルプログラムを掲載しておきます。Phoenix.connectはelm-phoenixライブラリの肝の部分であり、このサンプルは分かりやすく理解しやすいものなので、俯瞰するのは良いでしょう。

Phoenix.connectの使い方のサンプル
import Phoenix.Socket as Socket
import Phoenix.Channel as Channel

type Msg = NewMsg Value | ...

socket =
    Socket.init "ws://localhost:4000/socket/websocket"

channel =
    Channel.init "room:lobby"
        -- register a handler for messages
        -- with a "new_msg" event
        |> Channel.on "new_msg" NewMsg

subscriptions model =
    connect socket [channel]

5-3 Phoenix.Socket

 次にPhoenix.connectで使われる、socketを確認します。

socket
lobbySocket : String
lobbySocket =
    --- "ws://localhost:4000/socket/websocket"
    "ws://www.mypress.jp:4000/socket/websocket"


{-| Initialize a socket with the default heartbeat intervall of 30 seconds
-}
socket : Socket Msg
socket =
    Socket.init lobbySocket
        |> Socket.onOpen (ConnectionStatusChanged Connected)
        |> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
        |> Socket.onAbnormalClose SocketClosedAbnormally
        |> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)

 Socket.initはendpoint(lobbySocket)に対してのsocket接続を初期化します。

 ここでは初期化したscketに対してパイプラインで次々とイベントハンドラを設定していきます。

 これらのハンドラは、model.connectionStatusというsocket接続の状態変数をキープする役割を持っています。Socket.reconnectTimerによって接続が切れた時に再接続を行いますが、model.connectionStatusは現在の状態を表示するために使われます。

 例えばSocket.onOpenはsocket接続がオープンしたときにmessage(ConnectionStatusChanged Connected)を発生させupdate関数を呼び、model.connectionStatusの値をConnectedに更新します。これは例えばPhoenix.connectのタイミングで起こります。

 ちなみに一般的にPhoenix Channelのsocketでは、デフォルトで30秒間インターバルのheartbeatを送っていて(keep alive)、ブラウザを閉じたりしたタイミングでサーバ側が即座にsocketの無効を判断します。この時以下のterminate/2関数が呼ばれます。

lib/elm_phoenix_web/channels/room_channel.ex
#
  def terminate(_reason, socket) do
    {:noreply, socket}
  end
#

 以下は理解に役立つと思われるSocket関数の型定義です。

init : String -> Socket
onOpen : msg -> Socket msg -> Socket msg
onClose : ({ code : Int, reason : String, wasClean : Bool } -> msg) -> Socket msg -> Socket msg
onAbnormalClose : (AbnormalClose -> msg) -> Socket msg -> Socket msg
reconnectTimer : (Int -> Time) -> Socket msg -> Socket msg

5-4 Phoenix.Channel

 次にPhoenix.connectで使われる、channel(lobby)を確認します。

lobby
lobby : String -> Channel Msg
lobby userName =
    let
        presence =
            Presence.create
                |> Presence.onChange UpdatePresence
    in
        Channel.init "room:lobby"
            |>  Channel.withPayload  (JE.object [ ( "user_name", JE.string userName ) ])
            |> Channel.onRequestJoin (UpdateState JoiningLobby)
            |> Channel.onJoin (\_ -> UpdateState JoinedLobby)
            |> Channel.onLeave (\_ -> UpdateState LeftLobby)
            |> Channel.on "new_msg" (\msg -> NewMsg msg)
            |> Channel.withPresence presence
            |> Channel.withDebug

 Channel.initは与えられたtopicに対してのchannelを初期化します。 Channel.withPayload はjoin messageにpayloadを添付します。ここではユーザ名を追加していますが、一般的にはuserIdや認証情報の追加にも使われます。

 続いて初期化したchannelに対して、onJoin、onLeave、onのイベントハンドラを追加します。withPresenceは次の説で詳しく見ます。Channel.withDebugで変更された状態がブラウザのコンソールにプリントとされます。

5-5 Phoenix.Presence

 さてPhoenix channelのPresence機能は、チャットに参加しているクライアントを記録するためのものでした。elm-phoenixライブラリではPhoenix.Presenceがその機能を扱います。

 Channel.withPresenceはサーバ側でPresenceの状態に変更があったときに呼ばれるcallbackを設定します。ここではcallbackもpresenceという名前です。紛らわしい! 以下のように定義されています。これを少し詳しく見ていきましょう。

presence
        presence =
            Presence.create
                |> Presence.onChange UpdatePresence --(*)

 大雑把に言えば、Presence.createでPresenceの設定オブジェクトを作って、それを引数に onChange callbackを設定しています。Presenceの状態に変更があった時にはこのcallbackが呼ばれます。つまりUpdatePresenceメッセージでupdate関数を呼びmodel.presenceを更新します。

以下にPresence.onChangeの説明をしていきます。

Presence.onChange : (Dict String (List Value) -> msg) -> PhoenixPresence msg -> PhoenixPresence msg

 Presence.onChangeはPresenceの状態が更新される度に呼ばれます。Dictとは一意なキーと値をひも付けたデータ型です。キーと値が一対一で並んでいるので、キーで値を検索できます。例えば、Dict String Userという辞書の場合、String型を使えば、User型を検索できます。ここではキーとしてpresence keys(user_name)が使われ、値としてサーバから送られたpayloadsのリストが使われます。

 サーバ側ではelixirで以下のように書きました。

lib/elm_phoenix_web/channels/room_channel.ex
#
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
#

 push/3が'presence_state'イベントを送信し、Presence.trackで登録済みになった後は'presence_diff'イベントが送信されてきます。クライアントは'presence_state'イベント時にPresennceの状態を初期化し、'presence_diff'イベントでPresennceの状態を最新のものに更新していきます。しかしelm-phoenixを使うときはこの辺は明示的には現れません。Presence.onChangeで抽象化(隠蔽)されています。Elm側ではこのようなイベントのデータを以下のようなDictの形で受け取ります。

    { "user1": [{online_at: 1491493666123}]
    , "user2": [{online_at: 1491492646123}, {online_at: 1491492646624}]
    }

 Presence.onChangeの型をもう少し追ってみましょう。Modelのpresence は以下のようにDictとして定義されています。

type alias Model =
#
    , presence : Dict String (List JD.Value)
#

 UpdatePresenceはMsgの定義に以下のように現れます。

type Msg
#
    | UpdatePresence (Dict String (List JD.Value))
#

 ここでUpdatePresenceを関数とみた場合、以下のような型となります。

UpdatePresence: Dict String (List Value) -> msg

 つまり、Presence.onChangeの第一引数にUpdatePresenceはマッチします。

 以下のPresence.createの型定義と合わせると(*)のPresence.onChangeが正しく呼ばれていることがわかります。

-- Create a Presence configuration
Presence.create : PhoenixPresence msg

 結論としてChannel.withPresenceで、Channelに対して、Presenceの状態が変更されたときに呼ばれるcallbackを設定できました。つまり変更されたときに、UpdatePresence (Dict String (List JD.Value))の形のmessageでupdate関数が呼ばれます。

 ちなみに以下に通常のJavaScriptクライアントがPresenceをどう扱うかを示します。素朴で分かりやすいですね。

let presences = {};

channel.on("presence_state", state => {
  presences = Presence.syncState(presences, state)
  renderOnlineUsers(presences)
})

channel.on("presence_diff", diff => {
  presences = Presence.syncDiff(presences, diff)
  renderOnlineUsers(presences)
})

5-6 Phoenix.Push

 Channelへのメッセージ送信についてです。update関数でSendComposedMessageを受信したときに、Phoenix.pushでメッセージを送信します。以下のようなソースになります。

update
update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
    case message of

        --
        SendComposedMessage ->
            let
                push =
                    Push.init "room:lobby" "new_msg"
                        |> Push.withPayload (JE.object [ ( "msg", JE.string model.composedMessage ) ])
            in
                { model | composedMessage = "" } ! [ Phoenix.push lobbySocket push ]
        --

 このコードを直接説明する代わりに、elm-ohoenixの公式サイトにある例を見ていきたいと思います。ほぼ同じですので。

 Phoenix.pushは 指定されたsocket addressにPush messageを送ります。socket addressはSubscriptionsの時にsocketを作りましたがその時のaddressと同じものである必要があります。この場合はどちらもlobbySocketで指定することで同じaddressであることが保証されます。

Phoenix.push
-- Push messageを送る
Phoenix.push : String -> Push msg -> Cmd msg

-- 使い方
payload =
    Json.Encode.object [("msg", "Hello Phoenix")]

message =
    Push.init "room:lobby" "new_msg"
        |> Push.withPayload payload

Phoenix.push "ws://localhost:4000/socket/websocket" message

 Push messageは指定のchannel topicへのjoinが成功するまでキューに蓄えられることに注意してください。

 さて上でPush messageを作成するためにPush.initとPush.withPayloadを使いました。その型と使い方を示します。

Push
-- topic と event でPush messageを初期化
Push.init : Topic -> Event -> Push msg

-- Push messageにpayloadを追加
Push.withPayload : Value -> Push msg -> Push msg

-- 使い方
payload =
    Json.Encode.object [("msg", "Hello Phoenix")]

init "room:lobby" "new_msg"
    |> withPayload

5-7 Elmの全ソース

 今まではelm-phoenixライブラリの使われ方に注目して説明してきました。それ以外の部分も含めてexampleの全ソースを以下に示します。

assets/elm/src/MyChat.elm
module MyChat exposing (..)

import Json.Encode as JE
import Json.Decode as JD exposing (Decoder)
import Dict exposing (Dict)
import Html exposing (Html)
import Html.Attributes as Attr
import Html.Events as Events
import Phoenix
import Phoenix.Channel as Channel exposing (Channel)
import Phoenix.Presence as Presence exposing (Presence)
import Phoenix.Socket as Socket exposing (Socket, AbnormalClose)
import Phoenix.Push as Push
import Time exposing (Time)


main : Program Never Model Msg
main =
    Html.program
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        }



-- MODEL


type alias Model =
    { userName : String
    , state : State
    , presence : Dict String (List JD.Value)
    , isActive : Bool
    , messages : List Message
    , composedMessage : String
    , connectionStatus : ConnectionStatus
    , currentTime : Time
    }


type ConnectionStatus
    = Connected
    | Disconnected
    | ScheduledReconnect { time : Time }


type State
    = JoiningLobby
    | JoinedLobby
    | LeavingLobby
    | LeftLobby


type Message
    = Message { userName : String, message : String }


initModel : Model
initModel =
    { userName = "User1"
    , messages = []
    , isActive = False
    , state = LeftLobby
    , presence = Dict.empty
    , composedMessage = ""
    , connectionStatus = Disconnected
    , currentTime = 0
    }


init : ( Model, Cmd Msg )
init =
    ( initModel, Cmd.none )



-- UPDATE


type Msg
    = UpdateUserName String
    | UpdateState State
    | UpdateComposedMessage String
    | Join
    | Leave
    | NewMsg JD.Value
    | UpdatePresence (Dict String (List JD.Value))
    | SendComposedMessage
    | SocketClosedAbnormally AbnormalClose
    | ConnectionStatusChanged ConnectionStatus
    | Tick Time


update : Msg -> Model -> ( Model, Cmd Msg )
update message model =
    case message of
        UpdateUserName name ->
            { model | userName = name } ! []

        UpdateState state ->
            { model | state = state } ! []

        UpdateComposedMessage composedMessage ->
            { model | composedMessage = composedMessage } ! []

        Join ->
            { model | isActive = True } ! []

        Leave ->
            { model | isActive = False, presence = Dict.empty } ! []

        SendComposedMessage ->
            let
                push =
                    Push.init "room:lobby" "new_msg"
                        |> Push.withPayload (JE.object [ ( "msg", JE.string model.composedMessage ) ])
            in
                { model | composedMessage = "" } ! [ Phoenix.push lobbySocket push ]

        NewMsg payload ->
            case JD.decodeValue decodeNewMsg payload of
                Ok msg ->
                    { model | messages = List.append model.messages [ msg ] } ! []

                Err err ->
                    model ! []

        UpdatePresence presenceState ->
            { model | presence = Debug.log "presenceState " presenceState }
                ! []

        SocketClosedAbnormally abnormalClose ->
            { model
                | connectionStatus =
                    ScheduledReconnect
                        { time = roundDownToSecond (model.currentTime + abnormalClose.reconnectWait)
                        }
            }
                ! []

        ConnectionStatusChanged connectionStatus ->
            { model | connectionStatus = connectionStatus } ! []

        Tick time ->
            { model | currentTime = time } ! []


roundDownToSecond : Time -> Time
roundDownToSecond ms =
    (ms / 1000) |> truncate |> (*) 1000 |> toFloat



-- Decoder


decodeNewMsg : Decoder Message
decodeNewMsg =
    JD.map2 (\userName msg -> Message { userName = userName, message = msg })
        (JD.field "user_name" JD.string)
        (JD.field "msg" JD.string)



-- SUBSCRIPTIONS


lobbySocket : String
lobbySocket =
    --- "ws://localhost:4000/socket/websocket"
    "ws://www.mypress.jp:4000/socket/websocket"


{-| Initialize a socket with the default heartbeat intervall of 30 seconds
-}
socket : Socket Msg
socket =
    Socket.init lobbySocket
        |> Socket.onOpen (ConnectionStatusChanged Connected)
        |> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
        |> Socket.onAbnormalClose SocketClosedAbnormally
        |> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)


lobby : String -> Channel Msg
lobby userName =
    let
        presence =
            Presence.create
                |> Presence.onChange UpdatePresence
    in
        Channel.init "room:lobby"
            |> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
            |> Channel.onRequestJoin (UpdateState JoiningLobby)
            |> Channel.onJoin (\_ -> UpdateState JoinedLobby)
            |> Channel.onLeave (\_ -> UpdateState LeftLobby)
            |> Channel.on "new_msg" (\msg -> NewMsg msg)
            |> Channel.withPresence presence
            |> Channel.withDebug


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch [ phoenixSubscription model, Time.every Time.second Tick ]


phoenixSubscription model =
    Phoenix.connect socket <|
        if model.isActive then
            [ lobby model.userName ]
        else
            []



--
-- VIEW


view : Model -> Html Msg
view model =
    Html.div []
        [ enterLeaveLobby model
        , chatUsers model.presence
        , chatMessages model.messages
        , composeMessage model
        , statusMessage model
        ]


enterLeaveLobby : Model -> Html Msg
enterLeaveLobby model =
    let
        inputDisabled =
            case model.state of
                LeftLobby ->
                    False

                _ ->
                    True

        socketStatusClass =
            "socket-status socket-status--" ++ (String.toLower <| toString <| model.connectionStatus)
    in
        Html.div [ Attr.class "enter-lobby" ]
            [ Html.label []
                [ Html.text "Name"
                , Html.input [ Attr.class "user-name-input", Attr.disabled inputDisabled, Attr.value model.userName, Events.onInput UpdateUserName ] []
                ]
            , button model
            , Html.div [ Attr.class socketStatusClass ] []
            ]


statusMessage : Model -> Html Msg
statusMessage model =
    case model.connectionStatus of
        ScheduledReconnect { time } ->
            let
                remainingSeconds =
                    truncate <| (time - model.currentTime) / 1000

                reconnectStatus =
                    if remainingSeconds <= 0 then
                        "Reconnecting ..."
                    else
                        "Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
            in
                Html.div [ Attr.class "status-message" ] [ Html.text reconnectStatus ]

        _ ->
            Html.text ""


button : Model -> Html Msg
button model =
    let
        buttonClass disabled =
            Attr.classList [ ( "button", True ), ( "button-disabled", disabled ) ]
    in
        case model.state of
            LeavingLobby ->
                Html.button [ Attr.disabled True, buttonClass True ] [ Html.text "Leaving lobby..." ]

            LeftLobby ->
                Html.button [ Events.onClick Join, buttonClass False ] [ Html.text "Join lobby" ]

            JoiningLobby ->
                Html.button [ Attr.disabled True, buttonClass True ] [ Html.text "Joining lobby..." ]

            JoinedLobby ->
                Html.button [ Events.onClick Leave, buttonClass False ] [ Html.text "Leave lobby" ]


chatUsers : Dict String (List JD.Value) -> Html Msg
chatUsers presence =
    Html.div [ Attr.class "chat-users" ]
        (List.map chatUser (Dict.toList presence))


chatUser : ( String, List JD.Value ) -> Html Msg
chatUser ( user_name, payload ) =
    Html.div [ Attr.class "chat-user" ]
        [ Html.span [ Attr.class "chat-user-user-name" ] [ Html.text user_name ] ]


chatMessages : List Message -> Html Msg
chatMessages messages =
    Html.div [ Attr.class "chat-messages" ]
        (List.map chatMessage messages)


chatMessage : Message -> Html Msg
chatMessage msg =
    case msg of
        Message { userName, message } ->
            Html.div [ Attr.class "chat-message" ]
                [ Html.span [ Attr.class "chat-message-user-name" ] [ Html.text (userName ++ ":") ]
                , Html.span [ Attr.class "chat-message-message" ] [ Html.text message ]
                ]


composeMessage : Model -> Html Msg
composeMessage { state, composedMessage } =
    let
        cannotSend =
            case state of
                JoinedLobby ->
                    False

                _ ->
                    True
    in
        Html.form [ Attr.class "send-form", Events.onSubmit SendComposedMessage ]
            [ Html.input [ Attr.class "send-input", Attr.value composedMessage, Events.onInput UpdateComposedMessage ] []
            , Html.button [ Attr.class "send-button", Attr.disabled cannotSend ] [ Html.text "Send" ]
            ]

 Elmプログラムを走らせるために、index.html.eexとapp.jsをそれぞれ以下のように書き換えます。

lib/elm_phoenix_web/templates/page/index.html.eex
<div id="elm-container"></div>
assets/js/app.js
import "phoenix_html"

const elmDiv = document.querySelector("#elm-container");
const elmApp = Elm.MyChat.embed(elmDiv);

最後にapp.cssをgithubからコピーします。長いので全掲載は省略します。

assets/css/app.css
/*!
 * Bootstrap v3.3.5 (http://getbootstrap.com)
 * Copyright 2011-2015 Twitter, Inc.
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)

---

6.チャットアプリの画像

 チャットセッションの画像を掲載します。ブラウザ画面を2つ用意します。1つはuser1,
もう一つはuser2で入室し会話します。最後にuser2が退室します。画像は全て最初のブラウザの方のものです。

最初のブラウザを立ち上げた画面です。
image.png

user1で入室します。右のコンソールに Channel.withDebugのデバッグがプリントされます。Presenceの状態がわかります。
image.png

別ブラウザでUser2が入室しました。
image.png

User2に「こんにちわー」のメッセージを送ります。
image.png

User2から「さよならー」のメッセージを受け取ります。
image.png

User2が退室します。User2が消えます。デバッグプリントにも反映されています。
image.png

長かったのですが、以上で終わります。

追記:参考
Using Phoenix Presence
PhoenixのChannels/Presenceについて
Tracking User State with Phoenix Presence, React and Redux

22
17
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
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?