Phoenix には Channel という概念があり、これを JavaScript と組み合わせて使うことでブラウザと WebSocket 通信ができるようになります。
今回は公式ドキュメントを参考に、Channel を使ったサンプルアプリケーションを作ってみます。
チャットだと地味なので、d3.js の[この Example] (http://mbostock.github.io/d3/talk/20111018/collision.html) を使って遊んでみようと思います。
Phoenix アプリケーションの作成についてはコチラを参照してください。
サーバサイドを実装する
channel_sample という名前でアプリケーションを作ります
$ mix phoenix.new channel_sample
実はこの時点ですでに Channel のエンドポイントは定義されていて、ご丁寧に Channel Route の雛形までも用意されています。
web/channels/user_socket.ex
を開き、以下のコメントアウトを解除しましょう。
defmodule ChannelSample.UserSocket do
use Phoenix.Socket
## Channels
# 以下の1行についてコメントアウトを解除
channel "rooms:*", ChannelSample.RoomChannel
## Transports
...
上記の設定だと、 rooms: という文字列で始まるトピックを持つすべてのメッセージが ChannelSample.RoomChannel というモジュールで処理されるようになります。
せっかくなのでこのままの名前で進めてみましょう。
次に、このモジュールを実装します。web/channels/room_channel.ex
というファイルを作成し、以下のように記述します。
defmodule ChannelSample.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("move", %{"x" => x, "y" => y}, socket) do
broadcast! socket, "move", %{x: x, y: y}
{:noreply, socket}
end
end
join/3
という関数は、サーバに接続したときに呼ばれます。
第1引数に渡されるトピックの値を元に処理を分岐させるのですが、今回は rooms:lobby というトピックのみを許容するようにします。この分岐は、チャットA、チャットBといったグループ分けをするのに使うのだと思います。
{:ok, socket}
を返すことで通信が確立されます。
handle_in/3
という関数は、メッセージを受信したときに呼ばれます。
こちらも join/3
と同様、第1引数に渡されるイベント名を元に処理を分岐させるのですが、今回は move というイベントを対象にします。move イベントが受け取ることを想定している内容は x, y という2つのキーを持つマップです。
broadcast!
は接続されている全クライアントに対してメッセージをプッシュする関数です。つまり、今回は受け取った値をそっくりそのまま全クライアントに対して配信するわけです。
以上でサーバサイドの実装は完了です。簡単ですね。
クライアントサイドを実装する
まずは HTML から作りましょう。
今回使う HTML はテンプレート類とは無縁です。少々乱暴ですが、web/templates/layout/app.html.eex
を直接以下のように書き換えてしまいましょう。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Channel Sample</title>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
元からある app.js の読み込みに加えて、d3.js の読み込みを追加しています。
これ以外の処理は一切不要ですので、スッキリ削除してあります。
次に、app.js
を書いていきます。
まずは元となる d3.js Example を移植しましょう。
var w = 640,
h = 480;
var nodes = d3.range(200).map(function() { return {radius: Math.random() * 12 + 4}; }),
color = d3.scale.category10();
var force = d3.layout.force()
.gravity(0.05)
.charge(function(d, i) { return i ? 0 : -2000; })
.nodes(nodes)
.size([w, h]);
var root = nodes[0];
root.radius = 0;
root.fixed = true;
force.start();
var svg = d3.select("body").append("svg:svg")
.attr("width", w)
.attr("height", h);
svg.selectAll("circle")
.data(nodes.slice(1))
.enter().append("svg:circle")
.attr("r", function(d) { return d.radius - 2; })
.style("fill", function(d, i) { return color(i % 3); });
force.on("tick", function(e) {
var q = d3.geom.quadtree(nodes),
i = 0,
n = nodes.length;
while (++i < n) {
q.visit(collide(nodes[i]));
}
svg.selectAll("circle")
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
});
svg.on("mousemove", function() {
var p1 = d3.mouse(this);
root.px = p1[0];
root.py = p1[1];
force.resume();
});
function collide(node) {
var r = node.radius + 16,
nx1 = node.x - r,
nx2 = node.x + r,
ny1 = node.y - r,
ny2 = node.y + r;
return function(quad, x1, y1, x2, y2) {
if (quad.point && (quad.point !== node)) {
var x = node.x - quad.point.x,
y = node.y - quad.point.y,
l = Math.sqrt(x * x + y * y),
r = node.radius + quad.point.radius;
if (l < r) {
l = (l - r) / l * .5;
node.x -= x *= l;
node.y -= y *= l;
quad.point.x += x;
quad.point.y += y;
}
}
return x1 > nx2
|| x2 < nx1
|| y1 > ny2
|| y2 < ny1;
};
}
本家に少しだけ調整を加えていますが、基本的にはそのままです。
おもむろにサーバを立ち上げ、http://localhost:4000/
にアクセスして、以下のような画面が出れば OK です。
このマルの集まりは一斉にマウスポインタを避けるという、非常に興味深い動きをするのですが、今回はずばりこのマウスポインタの動きをリアルタイムで共有してみようと思います。
では、先ほどの JS に以下の改修を加えましょう。
import {Socket} from "deps/phoenix/web/static/js/phoenix/"
var socket = new Socket("/socket");
socket.connect();
var channel = socket.channel("rooms:lobby", {});
channel.join();
var w = 640,
...
svg.on("mousemove", function() {
var p1 = d3.mouse(this);
// root.px = p1[0];
// root.py = p1[1];
// force.resume();
channel.push("move", {x: p1[0], y: p1[1]});
});
channel.on("move", function(dt) {
root.px = dt.x;
root.py = dt.y;
force.resume();
});
...
}
最初に Socket 通信に必要なライブラリをインポートし、接続用オブジェクトを生成します。Socket 通信のエンドポイント /socket は、lib/channel_sample/endpoint.ex
で定義されていて、これを編集することで変更可能です。( /ws も良く見かけますね)
Socket 通信が確立した後はチャンネルにつなぎます。前述した通り、rooms:lobby というトピックでつなげます。無事に join されると、 channel.push
でイベントの送信、channel.on
でイベントリスナの登録ができるようになります。
イベントの送信はズバリ、マウスオーバー時に発動させます。"move" イベントで x, y 座標を送ります。broadcast!
では送った本人にもメッセージが送られてくるため、マウスオーバー時の力点(?)更新処理はコメントアウトしておきます。
ここでコメントアウトした処理は、そっくりそのまま "move" イベント受信時に実行されるようにします。これにより、サーバからプッシュされてきた座標を使って力点が設定されるようになります。
以上でクライアントサイドの実装は完了です。
動かしてみる
ブラウザを複数立ち上げて、マウスを動かしてみましょう。
1つのブラウザでの動きが他のブラウザにも伝播すると思います。
感想
- 噂通り、極めてシンプルに WebSocket 通信ができた
- さすがに mouseover でイベントをプッシュすると重たい
- トピックの使い分けも実装してみたいと思った
追記(2015/09/21)
Redis を使った WebSocket アプリケーションのスケールアウトについて書きました。よろしければこちらもご参照ください。
Phoenix で Redis の Pub/Sub を使って WebSocket をスケールアウトさせる