前々回の記事は「Phoenix環境のセットアップから、静的ページを作成し、表示」させました。
前回の記事では、「Phoenixで認証機能を実装」しました。
今回の記事では、入門最後として「Phoenixでチャット機能を実装」します。
Phoenixでソケット、チャネル、トークン、API作成、モデルのアソシエーションなどを行っていきます。
サンプル
動作確認
- Erlang 7.1
- Elixir 1.1.1
- Phoenix 10.0.3
- Hex 0.9.0
- node.js 0.12.7
- npm 2.14.2
- PostgreSQL 9.4.4
1. ソケットの基礎用語
ソケットの基本的な用語について簡単に記載します。ソケットハンドラ(Socket Handlers)
ソケットハンドラは、ソケット接続の認証や識別を行うモジュールです。そして、すべてのチャネルで使用されるデフォルトのソケットを設定します。
デフォルトで
web/channels/user_socket.exというソケットハンドラが用意されています。チャネルルート(Channel Routes)
ソケットハンドラ内で定義され、トピック文字列にマッチしたリクエストを、特定のチャネルモジュールにルートさせます。また、
*はワイルドカードを示します。例えば次のようにチャネルルートを定義した場合、
rooms:musicやrooms:sportsはRoomChannelにディスパッチされます。
channel "rooms:*", HelloPhoenix.RoomChannel
チャネル(Channels)
チャネルはクライアントからのイベントを扱います。WebのMVCでいうコントローラのようなものです。パブサブ(PubSub)
出版-購読型モデル(Publish/Subscribe)で、あるチャネルに誰かがイベントを発行(Publish)すると、そのチャネルを購読(Subscribe)している人すべてにそのイベントが通知されるというモデルです。メッセージ
チャネルでやりとりされるデータ。Phoenix.Socket.Messageモジュールで定義されていて、下記のデータを保持しています。
- topic -
<トピック名>か<トピック名>:<サブトピック名>の文字列で保持。例:"rooms"、"rooms:sport" - event - イベント名の文字列で保持。。例: "new:message"
- payload - メッセージ本体をJSON形式の文字列で保持。
- ref - incoming evnetに返信するためのユニーク文字列で保持。
クライアントライブラリ
PhoenixはJavascriptクライアントを提供しています。また、iOS、Android、C#クライアントもVer. 1.0から提供しています。2. チャット機能の追加
基礎用語をさくっと記載しましたので、チャット機能を実装します。ソケットルートを定義
Phoenixアプリを新規作成すると次のようにendpoint.exにUserSocketというソケットハンドラを使うように定義されています。
# lib/chat_phoenix/endpoint.ex defmodule HelloPhoenix.Endpoint do use Phoenix.Endpoint, otp_app: :chat_phoenix # ソケットハンドラ # "/socket" につなぐと、ソケットハンドラ UserSocket に接続されます socket "/socket", ChatPhoenix.UserSocket ... end
ソケットハンドラのUserSocketでは、チャネルルートを定義します。
channel "rooms:*", ChatPhoenix.RoomChannelがコメントアウトされているのでコメントを外します。
# web/channels/user_socket.ex defmodule HelloPhoenix.UserSocket do use Phoenix.Socket ## Channels # クライアントが"rooms:"で始まるトピックにメッセージを送るとRoomChannelモジュールにルートされる channel "rooms:*", ChatPhoenix.RoomChannel ... end
チャット画面を追加
次にチャット画面を追加します。page/index.html.eexを下記に置き換えます。
<!-- web/templates/page/index.html.eex --> <div id="messages"></div> <br/> <div class="col-xs-3 form-group"> <label>Username</label> <input id="username" type="text" class="form-control" /> </div> <div class="col-xs-9 form-group"> <label>Messenger</label> <input id="message" type="text" class="form-control" /> </div>
次に、app.html.eexでjQueryを読み込むようにします。
<!-- web/templates/layout/app.html.eex --> ... </div> <!-- /container --> <script src="//code.jquery.com/jquery-2.1.4.min.js"></script> <script src="<%= static_path(@conn, "/js/app.js") %>"></script> </body> </html>
web/static/js/app.jsでmy_socket.jsを読み込むようにします。
Phoenixでは、デフォルトでは、ES6の文法ででJSを記載し、branch.ioでビルドしています。
// web/static/js/app.js
// ローカルファイルをインポート
//
// ローカルファイルを相対パス("./socket")か絶対パス("web/static/js/socket")で
// 指定してインポートできます。
// web/static/js/my_socket.js をインポート
import "./my_socket"そして、my_socket.jsを作成します。
// web/static/js/app.js
// Phoenisではデフォルトで"deps/phoenix/web/static/js/phoenix"に
// JSのSocketクラスが実装されています。そのSocketクラスをimportします。
import {Socket} from "deps/phoenix/web/static/js/phoenix"
// チャットを行うクラス
class MySocket {
// newのときに呼ばれるコンストラクタ
constructor() {
console.log("Initialized")
// 入力フィールド
this.$username = $("#username")
this.$message = $("#message")
// 表示領域
this.$messagesContainer = $("#messages")
// キー入力イベントの登録
this.$message.off("keypress").on("keypress", e => {
if (e.keyCode === 13) { // 13: Enterキー
// `${変数}` は式展開
console.log(`[${this.$username.val()}]${this.$message.val()}`)
// メッセージの入力フィールドをクリア(空)にする
this.$message.val("")
}
})
}
}
$(
() => {
new MySocket()
}
)
export default MySocketmy_socket.jsを保存すると自動的にコンパイルされます。
画面をリロードし、UsernameとMessengerに値をいれて、Enterキーを押すと、JSコンソールに内容が表示されると思います。
JSでソケットに接続
my_socket.jsでソケットに接続します。Socketクラスを作成し、connect()メソッドで接続できます。ソケット接続を
connectSocket()メソッドとして切り出しておきます。
// web/static/app.js // チャットを行うクラス class MySocket { constructor() { ... } // ソケットに接続 connectSocket(socket_path) { // "lib/chat_phoenix/endpoint.ex" に定義してあるソケットパス("/socket")で // ソケットに接続すると、UserSocketに接続されます this.socket = new Socket(socket_path) this.socket.connect() this.socket.onClose( e => console.log("Closed connection") ) } } $( () => { let my_socket = new MySocket() my_socket.connectSocket("/socket") } )
サーバーでチャネルモジュールを定義
ソケットに接続できましたので、チャネルのRoomChannelを定義します。クライアントがチャネルに入るためにはサーバーのチャネルモジュールで
join関数を実装する必要があり、{:ok, socket}を返ことでチャネルに入ることができます。また、
join関数の第一引数ではトピック名を指定し、トピックごとにjoin関数を定義します。
# web/channels/room_channel.ex
defmodule ChatPhoenix.RoomChannel do
use Phoenix.Channel
# "rooms:lobby"トピックのjoin関数
# {:ok, socket} を返すだけなのですべてのクライアントが接続可能
def join("rooms:lobby", message, socket) do
{:ok, socket}
end
end許可するには、{:ok, socket} か {:ok, reply, socket} を返します。
拒否するには、{:error, reply} を返します。
JSでチャネルに接続
いま作成したRoomChannelモジュールに接続します。接続するトピックは"rooms:lobby"です。socket.channel("<トピック名>", {})でチャネルを作成し、channel.join()でチャネルにジョインします。
// web/static/app.js
class MySocket {
...
// チャネルに接続
connectChannel(chanel_name) {
this.channel = this.socket.channel(chanel_name, {})
this.channel.join()
.receive("ok", resp => { // チャネルに入れたときの処理
console.log("Joined successfully", resp)
})
.receive("error", resp => { // チャネルに入れなかった時の処理
console.log("Unable to join", resp)
})
}
}
$(
() => {
// ソケット/チャネルに接続
let my_socket = new MySocket()
my_socket.connectSocket("/socket")
my_socket.connectChannel("rooms:lobby")
}
)画面をリロードすると、うまくいけばJSコンソールに次のように表示されると思います。
Initialized
Joined successfully Object {}
JSでチャネルにメッセージを送る
channel.push(event名, メッセージ)でチャネルにメッセージを送ります。
// web/static/app.js // キー入力イベントの登録 message.off("keypress").on("keypress", e => { if (e.keyCode === 13) { // 13: Enterキー // `${変数}` は式展開 console.log(`[${this.$username.val()}]${this.$message.val()}`) // サーバーに"new:messege"というイベント名で、ユーザ名とメッセージを送る this.channel.push("new:message", { user: this.$username.val(), body: this.$message.val() }) // メッセージの入力フィールドをクリア(空)にする this.$message.val("") } })
サーバーでIncoming eventsを処理する
クライアントからサーバーへ入ってくるイベントをIncoming eventsと呼びます。Incoming eventsは、チャネルに
handle_in関数を定義することで処理をすることができます。handle_in関数の第一引数にイベント名を記載し、送られてきたイベント名に対応したhandle_in関数が呼ばれます。
# web/channels/room_channel.ex
# イベント名"new:message"のIncoming eventsを処理する
def handle_in("new:message", message, socket) do
# broadcat!は同じチャネルのすべてのサブスクライバーにメッセージを送る
broadcast! socket, "new:message", %{user: message["user"], body: message["body"]}
{:noreply, socket}
end
JSでメッセージをサーバーから受け取る
// web/static/js/my_socket.js class MySocket { ... // チャネルに接続 connectChannel(chanel_name) { ... // チャネルの"new:message"イベントを受け取った時のイベント処理 this.channel.on("new:message", message => this._renderMessage(message) ) } // メッセージを画面に表示 _renderMessage(message) { let user = this._sanitize(message.user || "New User") let body = this._sanitize(message.body) this.$messagesContainer.append(`<p><b>[${user}]</b>: ${body}</p>`) } // メッセージをサニタイズする _sanitize(str) { return $("<div/>").text(str).html() } }
チャネルの動作確認
2つブラウザを開き、http://localhost:4000/にアクセスします。UsernameとMessengerを入力してEnterキーを押すとリアルタイムでメッセージが表示されます。
3. チャット機能をログイン機能と統合
チャット機能ができましたので、前回の記事で作成したログイン機能と統合します。具体的には、ログインしないとチャット機能を使えないようにします。
また、そのときに、UsernameにUserのemailを設定するようにしてみます。
チャット画面でログインを必須にする
チャット画面を開く前にログインをしているかチェックするauthenticate_user!関数を作成し、アクション前に呼びだすようにします。
# web/controllers/page_controller.ex
defmodule ChatPhoenix.PageController do
use ChatPhoenix.Web, :controller
# アクションの前に実行される
plug :authenticate_user!
@doc """
チャット画面を表示
"""
def index(conn, _params) do
render conn, "index.html"
end
# ログインしていない場合は、ログインページにリダイレクトさせる
defp authenticate_user!(conn, _params) do
unless logged_in?(conn) do
conn
|> put_flash(:info, "チャット機能を行うにはログインが必要です")
|> redirect(to: session_path(conn, :new))
end
conn # plug は connを返す必要がある
end
end
PageContorollerでcurrent_userとlogged_in?関数を使えるようにするために、web/web.exのcontrollerの箇所にimportを追加します。
# web/web.ex
def controller do
quote do
...
import ChatPhoenix.Router.Helpers
# Sessionモジュールのcurrent_userとlogged_in?をWebのcontrollerに追加
import ChatPhoenix.Session, only: [current_user: 1, logged_in?: 1]
end
end
ログインしていない状態でチャット画面(http://localhost:4000)にアクセスすると次のようにログイン画面にリダイレクトされるようになります。
ソケットとチャネルの認証
画面としては、ログインしていない場合チャット画面を開けないようにしました。しかし、まだJSでソケットにつなぎ、チャネルに入ることができます。
そのため、ソケットとチャネルもログインしていないと繋げないようにします。
まずは、Phoenix.Tokenモジュールを利用し、トークンを作成し、チャット画面に埋め込みます。
# web/router.ex
pipeline :browser do
...
// ブラウザの場合、ユーザーのトークンを設定
plug :put_user_token
end
...
// ログインしている場合、user_tokenキーにユーザーのトークンを設定します
defp put_user_token(conn, _) do
if logged_in?(conn) do
token = Phoenix.Token.sign(conn, "user", current_user(conn).id)
assign(conn, :user_token, token)
else
conn
end
endRouterモジュールでlogged_in?とcurrent_user関数を利用できるようにするために、web/web.exのrouterにimoprtを追加します。
# web/web.ex
def router do
quote do
use Phoenix.Router
# Sessionモジュールのcurrent_userとlogged_in?をWebのviewに追加
import ChatPhoenix.Session, only: [current_user: 1, logged_in?: 1]
end
endそして、レイアウトの箇所でuserTokenにトークン値を設定します。
<!-- web/templates/layout/app.html.eex --> </div> <!-- /container --> <script>window.userToken = "<%= assigns[:user_token] %>";</script> <script src="//code.jquery.com/jquery-2.1.4.min.js"></script> <script src="<%= static_path(@conn, "/js/app.js") %>"></script> </body> </html>
UserSocketのconnect関数を実装し、チャネルへの接続可否を制御します。
Phoenix.Token.verifyでトークン値を検証し、成功した場合は:ok(ソケットに接続)を返し、:error(ソケットに接続拒否)を返します。
# web/channels/user_socket.ex
def connect(%{"token" => token}, socket) do
# Max age of 2 weeks (1209600 seconds)
case Phoenix.Token.verify(socket, "user", token, max_age: 1209600) do
{:ok, user_id} ->
{:ok, assign(socket, :user_id, user_id)}
{:error, _} ->
:error
end
endRoomChannelのjoin関数でソケット接続の接続可否を制御します。
userがある場合は :ok(接続許可)、userがない場合は :error(接続拒否) を返します。
# web/channels/room_channel.ex
defmodule ChatPhoenix.RoomChannel do
use Phoenix.Channel
alias ChatPhoenix.Repo
alias ChatPhoenix.User
# "rooms:lobby"トピックのjoin関数
def join("rooms:lobby", message, socket) do
user = Repo.get(User, socket.assigns[:user_id])
if user do
{:ok, %{email: user.email}, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
end
サーバー側の認証処理を実装したので、JS側の処理を追加します。
my_socket.jsのソケット接続時に画面から受け取ったトークンを送るようにします。
// web/static/js/my_socket.js class MySocket { // ソケットに接続 // トークンを受け取り、トークンがない場合はアラートを表示 // new Socketで接続するときにトークンをサーバー側に送る connectSocket(socket_path, token) { if (!token) { alert("ソケットにつなぐにはトークンが必要です") return false } // "lib/chat_phoenix/endpoint.ex" に定義してあるソケットパス("/socket")で // ソケットに接続すると、UserSocketに接続されます this.socket = new Socket(socket_path, { params: { token: token } }) this.socket.connect() this.socket.onClose( e => console.log("Closed connection") ) } // チャネルに接続 connectChannel(chanel_name) { this.channel = this.socket.channel(chanel_name, {}) this.channel.join() .receive("ok", resp => { console.log("Joined successfully", resp) // Username入力フィールドにユーザのemailを自動的にセットするようにする this.$username.val(resp.email) }) .receive("error", resp => { console.log("Unable to join", resp) }) } $( () => { // userTokenがある場合のみソケットにつなぐ // 本来は、app.html.eexでこのJSを読み込まなくするほうがよさそう // そのためにはJSを分割し、PageControllerのindexアクションで読みこむように // render_existingを行う必要がある if (window.userToken) { let my_socket = new MySocket() // app.html.eexでセットしたトークンを使ってソケットに接続 my_socket.connectSocket("/socket", window.userToken) my_socket.connectChannel("rooms:lobby") } } ) export default MySocket
画面をリロードすると、ログインしているユーザのemailが入力フィールドに設定された状態で表示されます。
4. チャットメッセージの永続化
チャットで送信したメッセージ文を保持するMessageモデルを作成し、メッセージを永続化できるようにします。こうすることで画面をリロードしても、投稿したメッセージが表示された状態になります。
Messageモデルを作成
mix phoenix.gen.modelコマンドでMessageモデルを作成します。UserモデルとMessageモデルは1対n関係を作成します。そのとき、
user_id:references:usersと記載します。
$ mix phoenix.gen.model Message messages content:string user_id:references:users * creating priv/repo/migrations/20151015152654_create_message.exs * creating web/models/message.ex * creating test/models/message_test.exs
mix ecto.migrateでマイグレーションを実行し、messagesテーブルを作成します。
$ mix ecto.migrate 00:30:08.602 [info] == Running ChatPhoenix.Repo.Migrations.CreateMessage.change/0 forward 00:30:08.602 [info] create table messages 00:30:08.637 [info] create index messages_user_id_index 00:30:08.644 [info] == Migrated in 0.3s
UserモデルとMessageモデルのアソシエーション
UserモデルとMessageモデルは、1対N関連です。Userモデルの
schemaでhas_manyを追加します。
# web/models/user.ex
defmodule ChatPhoenix.User do
schema "users" do
field :email, :string
field :crypted_password, :string
# passwordフィールドを追加。virtual: trueとすることでデータベースには保存されない
field :password, :string, virtual: true
has_many :messages, ChatPhoenix.Message
timestamps
end
...
Messageモデルのschemaにはbelongs_toがあります。
これは、mix phoenix.gen.modelコマンドのときにreferencesを指定していたためです。
# web/models/message.ex
defmodule ChatPhoenix.Message do
use ChatPhoenix.Web, :model
schema "messages" do
field :content, :string
belongs_to :user, ChatPhoenix.User
timestamps
end
...
モデルに1対Nのアソシエーションが定義できたので、軽くアソシエーションの使い方を説明します。
インタラクティブコンソールを開きます。
$ iex -S mix phoenix.server # aliasでChatPhoneixを省略可能にしておきます > alias ChatPhoenix.Repo > alias ChatPhoenix.User > alias ChatPhoenix.Message # 登録されているユーザを取得(自分の登録したユーザのemailを入力してください) > user = Repo.get_by(User, email: "test@example.com") # 関連するメッセージを作成 > message = Ecto.Model.build(user, :messages, content: "How are you?") # メッセージをDBにインサートする > Repo.insert!(message) # ユーザと関連するメッセージを取得 > user = Repo.get_by(User, email: "test@example.com") |> Repo.preload(:messages) > user.messages #=> メッセージが表示される
その他、モデルのCRUDのメソッドを確認したい場合、Elixir Phoenixのデータベース操作モジュールEcto入門2を参考にしてください。
Message APIコントローラを作成
Messageモデルを作成したので、Messageの一覧を取得するAPIコントローラを作成します。まず、ルートを作成しておきます。
scope "/api"内にルートを追加します。また、上の方に、pipeline :apiが記載されており、jsonと記載されています。これは、このAPIはJSON形式でやりとりすることを意味しています。
# web/router.ex
pipeline :api do
plug :accepts, ["json"]
end
# Other scopes may use custom stacks.
scope "/api", ChatPhoenix do
pipe_through :api
# メッセージ一覧取得(:index)
get "/messages", MessageController, :index
end
MessageControllerを作成します。いまはアクションは未定義でおいておきます。
# web/controllers/message_controller.ex
defmodule ChatPhoenix.MessageController do
use ChatPhoenix.Web, :controller
alias ChatPhoenix.Repo
alias ChatPhoenix.Message
@doc """
メッセージ一覧取得API
"""
def index(conn, _params) do
# TODO: 実装する
end
# TODO: authentication(本記事で実施しない)
end
空のMessageViewも作成しておきます。
# web/views/message_view.ex
defmodule ChatPhoenix.MessageView do
use ChatPhoenix.Web, :view
end
Message 一覧取得API
Repo.all(Message)関数ですべてのメッセージをDBから取得して、render関数でViewに渡します。
# web/controllers/message_controller.ex
@doc """
メッセージ一覧取得API
"""
def index(conn, _params) do
# すべてのメッセージを取得。userも一緒にロードしておく
messages = Repo.all(Message) |> Repo.preload(:user)
render conn, :index, messages: messages
end
MessageViewでは、JSONに変換します。
# web/views/message_view.ex
defmodule ChatPhoenix.MessageView do
use ChatPhoenix.Web, :view
def render("index.json", %{messages: messages}) do
# messagesの各messageを下記のmessage.jsonで表示する
%{messages: render_many(messages, ChatPhoenix.MessageView, "message.json")}
end
def render("message.json", %{message: message}) do
# messageのid, content, messageのuserのemail をJSON形式で表示する
%{id: message.id, body: message.content, user: message.user.email}
end
end今のままだとログインしていなくてもメッセージ一覧取得APIにアクセスできてしまいますが、認証機能はここでは割愛します。
Plugを作成し、router.exのpipelineに追加する流れです。
参考: Authenticating Users using a Token with Phoenix
JSでメッセージ一覧を取得/表示
若干雑ですが、MySocketクラスにメッセージの一覧を取得するall()メソッドを定義し、呼び出します。
// web/static/js/my_socket.js
class mySocket {
// メッセージを取得
all() {
$.ajax({
url: "/api/messages"
}).done((data) => {
console.log(data)
// 取得したデータをレンダーする
data.messages.forEach((message) => this._renderMessage(message))
}).fail((data) => {
alert("エラーが発生しました")
console.log(data)
})
}
}
$(
() => {
if (window.userToken) {
...
// メッセージを取得
my_socket.all()
}
}
)これで画面をリロードすると、次のように画面上部にDBのメッセージが表示されます
(DBのメッセージをJSで取得して、JSがappendしている)

Message 作成API
メッセージの作成は、RoomChannelのnew:messageイベント側でメッセージを作成します。
# web/channel/room_channel.ex
# イベント名"new:message"のIncoming eventsを処理する
def handle_in("new:message", message, socket) do
# メッセージを作成
user = Repo.get(User, socket.assigns[:user_id]) |> Repo.preload(:messages)
message = Ecto.Model.build(user, :messages, content: message["body"])
Repo.insert!(message)
# broadcastする値も、作成した値を使用するようにする
broadcast! socket, "new:message", %{user: user.email, body: message.content}
{:noreply, socket}
endこれで画面からメッセージを投稿するとDBにメッセージが書き込まれ、画面をリロードしてもメッセージが表示されるようになります。もちろん、WebSocketにより他の人の投稿がリアルタイムにメッセージが表示されます。

以上です。これで終わりです。
参考文献