読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Phoenix入門3 - WebSocketのチャット機能

phoenix elixer

f:id:nipe880324:20151011193641p:plain:w420

前々回の記事は「Phoenix環境のセットアップから、静的ページを作成し、表示」させました。
前回の記事では、「Phoenixで認証機能を実装」しました。
今回の記事では、入門最後として「Phoenixでチャット機能を実装」します。
Phoenixでソケット、チャネル、トークン、API作成、モデルのアソシエーションなどを行っていきます。


サンプル

動作確認


1. ソケットの基礎用語

ソケットの基本的な用語について簡単に記載します。

ソケットハンドラ(Socket Handlers)

ソケットハンドラは、ソケット接続の認証や識別を行うモジュールです。
そして、すべてのチャネルで使用されるデフォルトのソケットを設定します。
デフォルトでweb/channels/user_socket.exというソケットハンドラが用意されています。

チャネルルート(Channel Routes)

ソケットハンドラ内で定義され、トピック文字列にマッチしたリクエストを、特定のチャネルモジュールにルートさせます。
また、*ワイルドカードを示します。
例えば次のようにチャネルルートを定義した場合、rooms:musicrooms:sportsRoomChannelにディスパッチされます。

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に返信するためのユニーク文字列で保持。

クライアントライブラリ

PhoenixJavascriptクライアントを提供しています。また、iOSAndroidC#クライアントもVer. 1.0から提供しています。


2. チャット機能の追加

基礎用語をさくっと記載しましたので、チャット機能を実装します。

ソケットルートを定義

Phoenixアプリを新規作成すると次のようにendpoint.exUserSocketというソケットハンドラを使うように定義されています。

# 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.eexjQueryを読み込むようにします。

<!-- 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.jsmy_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 MySocket

my_socket.jsを保存すると自動的にコンパイルされます。
画面をリロードし、UsernameとMessengerに値をいれて、Enterキーを押すと、JSコンソールに内容が表示されると思います。
f:id:nipe880324:20151016022228p:plain:w420



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キーを押すとリアルタイムでメッセージが表示されます。
f:id:nipe880324:20151016022846p:plain:w420



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_userlogged_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)にアクセスすると次のようにログイン画面にリダイレクトされるようになります。
f:id:nipe880324:20151016023042p:plain:w420


ソケットとチャネルの認証

画面としては、ログインしていない場合チャット画面を開けないようにしました。
しかし、まだ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
end

Routerモジュールで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
end

RoomChannelの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が入力フィールドに設定された状態で表示されます。
f:id:nipe880324:20151016023423p:plain:w420



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モデルのschemahas_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している)
f:id:nipe880324:20151016023622p:plain:w420


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により他の人の投稿がリアルタイムにメッセージが表示されます。
f:id:nipe880324:20151016023811p:plain:w420


以上です。これで終わりです。


参考文献