Phoenix Framework は Rails 風なリッチなフレームワークである割にデフォルトでもそこそこ速いことが知られています.例えば次のようなベンチマークがあります.
しかし,それよりも私がおもしろいと思った特長に,リアルタイム通信機能がフレームワークに統合されている点があります.しかしまだ日本ではあまり知られておりません.そこで Phoenix Framework の最新版である v0.17.0 における wiki にある Channel の記事を日本語へと翻訳しました.
翻訳や内容におかしなところがあれば指摘お待ちしております
チャンネルはフェニックスの本当に刺激的で強力な部分で,アプリケーションへ簡単にソフトリアルタイムな特徴を追加することができるようになります.チャンネルは簡単なアイディア - メッセージを送る,受けとる に基づいています.送信者はトピックスに関するメッセージをブロードキャストします.受信者はそれらのメッセージを得ることができるように,トピックを購読します.送信者と受信者は同じトピックにおいていつでも役割を切り替えることができます.
エリクサーはメッセージパッシングに基づいているので,メッセージを送受信するのになぜこのような余分なメカニズムが必要になるのか不思議に思うかもしれません.チャンネルでは,送信者も受信者もどちらもエリクサーのプロセスである必要がありません.私たちがチャンネルを介した通信で教えられるものに対しては何でもすることができます - JavaScriptのクライアント,iOSのアプリ,別のフェニックスアプリケーション,腕時計など.また,チャンネルを介してブロードキャストしたメッセージは多くの受信者に届くことがあります.エリクサーのプロセスではひとつひとつと通信します.
"チャンネル" は複数のコンポーネントからなる階層構造を省略して表した言葉です.概要がもう少しわかるように,それぞれのさわりの部分を見てみましょう.
The Moving Parts
Socket Handlers
フェニックスは,サーバとは単一の接続を持ち,その接続を介して複数のチャンネルソケットを持ちます.web/channels/user_socket.ex
のようなソケットハンドラは,認証とソケット接続の識別,全てのチャンネルで利用するためのデフォルトのソケットを割り当てるためのモジュールです.
Channel Routes
web/channels/user_socket.ex
のようなファイルであるソケットハンドラへ,重複しないようにルートが定義されています.それらはトピック文字に適合し,適合したリクエストを与えられたチャンネルモジュールへと送ります.星型文字 *
はワイルドカードとして働くので,次の例のルートでは sample_topic:pizza
と sample_topic:oranges
はどちらも SampleTopicChannel
へと送られます.
channel "sample_topic:*", HelloPhoenix.SampleTopicChannel
Channels
チャンネルはクライアントからのイベントを処理するのでコントローラに似ていますが,鍵となる違いが 2 つあります.チャンネルのイベントは,着信および発信の両方向に行くことができます.チャンネルの接続は1つのリクエスト/レスポンスでは終わらず,持続します.チャンネルはフェニックスにおけるリアルタイム通信のコンポーネントが最も抽象化されたものです.
どのチャンネルも,これらの4つのコールバック関数 - join/3
, terminate/2
, handle_in/3
, handle_out/3
のうち1つかそれ以上の節を実装します.
PubSub
フェニックスのPubSub層は,Phoenix.PubSub
モジュール及び様々なアダプタと(アダプタの)Genserverで構成されています.これらのモジュールはチャンネル通信をまとめるための根幹となる関数 - トピックを購読する,トピックから退会する,そしてメッセージをトピックに乗せてブロードキャストする を含んでいます.
必要なら自分たちでPubSubアダプターを定義することができます.詳しくはPhoenix.PubSub docsを参照してください.
フェニックスの内部で使うことを意図されて作られているモジュールであることは特筆すべき点です.チャンネルはそれらを使い,動作させるためにたくさんの働きを裏で行います.私たちのアプリケーションでは,エンドユーザーがそれらを直接使う必要があるようにすべきではありません.
Messages
Phoenix.Socket.Message
モジュールは,以下に示すキーが有効なメッセージとなる構造を定義します.Phoenix.Socket.Message docs より.
-
topic
- 文字列のトピックかトピック:サブトピックのペアの名前空間,つまり "messages" か "messages:123" -
event
- 文字列のイベント名、つまり "join" -
payload
- JSON形式の文字列に載せたメッセージ -
ref
- 着信イベントに応答するために使用される一意の文字列
Topics
トピックスは文字列識別子です - 様々な層がメッセージを最後に正しい場所へ収まるようにするために利用する名前です.上で見たようにトピックではワイルドカードを使えます.これにより "topic:subtopic" という形式をうまく扱えるようになります.多くの場合,トピックを "users:123"
のようにモデル層のレコード ID で構成するでしょう.
Transports
トランスポート層は大事な足回りです.Phoenix.Channel.Transport
モジュールはチャンネルを行き来する全てのメッセージのディスパッチを扱います.
Transport Adapters
デフォルトのトランスポートの仕組みはWebSocketを介して行われ,WebSocketが動作/利用できない場合はLongPollingを代替手段として用います.他のトランスポートアダプターも使え,実際のところアダプタの決まりにのっとるなら自分たちで書くこともできます.例としてはPhoenix.Transports.WebSocket
を見てください.
Client Libraries
フェニックスは現在のところJavaScriptクライアントをリリースしており,1.0リリースではiOSとAndroidのクライアントも含める予定です.
Tying it all together
単純なチャットアプリケーションを構築することでこれらの概念を結びつけてみましょう.ソケットをエンドポイントにマウントし,チャンネルルートへと通すところから初めましょう.
# lib/hello_phoenix/endpoint.ex
defmodule HelloPhoenix.Endpoint do
use Phoenix.Endpoint
socket "/socket", HelloPhoenix.UserSocket
...
end
# web/channels/user_socket.ex
defmodule HelloPhoenix.UserSocket do
use Phoenix.Socket
channel "rooms:*", HelloPhoenix.RoomChannel
...
end
これでクライアントが"rooms:"
から始まるトピックのメッセージを送るたび,メッセージはRoomChannelへと送られます.次にチャットルームのメッセージを管理するためにRoomChannel
モジュールを定義します.
Joining Channels
あなたのチャンネルで最初に大事なことは,特定のトピックに参加するクライアントを認可することです.認可のために,web/channels/room_channel.ex
へjoin/3
を実装しなければなりません.
defmodule HelloPhoenix.RoomChannel do
use Phoenix.Channel
def join("rooms:lobby", auth_msg, socket) do
{:ok, socket}
end
def join("rooms:" <> _private_room_id, _auth_msg, socket) do
{:error, %{reason: "unauthorized"}}
end
end
私たちのチャットアプリでは"rooms:lobby"
トピックには誰でも繋ぐことができるように許可しています,しかし他の部屋はプライベートでデータベースから取得するような特別な認可が必要になるだろうと考えています.この演習ではプライベートなチャットルームについて考えることはしませんが,終わったら気軽に試してみてください.ソケットがトピックに参加するのを認可するには{:ok, socket}
か{:ok, reply, socket}
を返します.アクセスを拒否するには{:error, reply}
を返します.トークンによる認可についての詳しい情報はPhoenix.Token
documentationにあります.
チャンネルができたので,web/static/js/app.js
でクライアントとサーバーのやりとりを見ましょう.
import {Socket} from "deps/phoenix/web/static/js/phoenix"
let socket = new Socket("/socket")
socket.connect()
let chan = socket.channel("rooms:lobby", {})
chan.join().receive("ok", chan => {
console.log("Welcome to Phoenix Chat!")
})
ファイルを保存するとブラウザが自動的に再更新するはずです,フェニックスのライブリローダーのおかげです.感謝しましょう.もし全てがうまく動いているなら,ブラウザのJavaScriptコンソールで"Welcome to Phoenix Chat!"という表示を見るはずです.今,クライアントとサーバーは永続的な接続を介してやりとりをしています.チャットができるようにしてそれを使いこなせるようにしましょう.
web/templates/page/index.html.eex
へ,チャットメッセージを取っておくコンテナを追加,また,それらを送るための入力フィールドを追加します.
<div id="messages"></div>
<input id="chat-input" type="text"></input>
また web/templates/layout/app.html.eex
にあるapplication layoutへjQueryを追加します:
...
<%= @inner %>
</div> <!-- /container -->
<script src="//code.jquery.com/jquery-1.11.2.min.js"></script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
さて,app.js
へいくつかイベントリスナーを追加しましょう:
let chatInput = $("#chat-input")
let messagesContainer = $("#messages")
let socket = new Socket("/socket")
socket.connect()
let chan = socket.channel("rooms:lobby", {})
chatInput.on("keypress", event => {
if(event.keyCode === 13){
chan.push("new_msg", {body: chatInput.val()})
chatInput.val("")
}
})
chan.join().receive("ok", chan => {
console.log("Welcome to Phoenix Chat!")
})
これから,enterが押されるのを検知し,チャンネルを介してイベントをメッセージの内容と一緒にpush
できるようにします.このイベントには"new_msg"と名前をつけました.ここで,新しいメッセージを待ち受け,来たらメッセージのコンテナに追加する,チャットアプリケーションの別の構成要素について扱ってみましょう.
let chatInput = $("#chat-input")
let messagesContainer = $("#messages")
let socket = new Socket("/socket")
socket.connect()
let chan = socket.channel("rooms:lobby", {})
chatInput.on("keypress", event => {
if(event.keyCode === 13){
chan.push("new_msg", {body: chatInput.val()})
chatInput.val("")
}
})
chan.on("new_msg", payload => {
messagesContainer.append(`<br/>[${Date()}] ${payload.body}`)
})
chan.join().receive("ok", chan => {
console.log("Welcome to Phoenix Chat!")
})
chan.on
を使って"new_msg"
イベントを待ちうけ,DOMにメッセージの内容を追加します.さて,全体像を完成させるためにサーバーの受信と発信イベントを処理しましょう.
Incoming Events
handle_in/3
で受信イベントを扱います."new_msg"
のようなイベント名でパターンマッチすることができ,それからクライアントがチャンネル越しに渡してくる内容を取得することができます.私たちのチャットアプリケーションでは,rooms:lobby
にいる他の購読者へ通知するのに単にbroadcast!/3
で新しいメッセージを知らせるだけでよいです.
defmodule HelloPhoenix.RoomChannel do
use Phoenix.Channel
def join("rooms:lobby", auth_msg, socket) do
{:ok, socket}
end
def join("rooms:" <> _private_room_id, _auth_msg, socket) do
{:error, %{reason: "unauthorized"}}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast! socket, "new_msg", %{body: body}
{:noreply, socket}
end
def handle_out("new_msg", payload, socket) do
push socket, "new_msg", payload
{:noreply, socket}
end
end
broadcast!/3
はこのsocket
のトピックに接続されている全てのクライアントに通知し,各々の handle_out/3
コールバックを実行します.handle_out/3
は必須のコールバックではありませんが,ブロードキャストを各クライアントに届ける前にカスタマイズやフィルタするためにあります.handle_out/3
のデフォルトでは,私たちが定義したのと同じように単にクライアントにメッセージを送るようになっています.この内容をここに含めたのは,発信イベントをフックすることでメッセージを強力にカスタマイズ/フィルタリングできるからです.どのように行うか見てみましょう.
Intercepting Outgoing Events
私たちのアプリケーションではこれを実装しませんが,ユーザーが新規にルームに参加するユーザーに関するメッセージを無視することができると想像してみてください.私たちはこの振舞いを,送信イベントを止めたいとフェニックスへと明示的に伝えることで実装することができ,そこでこれらのイベントのためにhandle_out/3
コールバックを定義しました.(もちろん,これはUser
モデルにignoring?/2
という関数があり,ユーザーをassigns
マップから渡す前提で書いています)
intercept ["user_joined"]
def handle_out("user_joined", msg, socket) do
if User.ignoring?(socket.assigns[:user], msg.user_id) do
{:noreply, socket}
else
push socket, "user_joined", msg
{:noreply, socket}
end
end
これが私たちの基本的なチャットアプリの全てとなります.複数のブラウザタブを立ち上げてメッセージを送るとブロードキャストされて全てのタブでメッセージが表示されるのを目撃することになるでしょう!
Socket Assigns
connection構造体と似たように,%Plug.Conn{}
はチャンネルソケットへ値を割り当てることができます.Phoenix.Socket.assign/3
は便利なように assign/3
としてチャンネルモジュールにインポートされています:
socket = assign(socket, :user, msg["user"])
ソケットは割り当てられた値をsocket.assigns
の中のマップに保持します.
Example Application
作成したアプリケーションの例をみるには,このプロジェクト (https://github.com/chrismccord/phoenix_chat_example) をチェックアウトしてください.
ライブデモは (http://phoenixchat.herokuapp.com/) で見ることができます.