2017.07.03

Elixir/Phoenix Framework と WebSocket で VR 環境上にプレゼンテーションルームをつくった話


Elixir, Phoenix, WebSocket

次世代システム研究室の データストア 好きの Y.I. です。
今回は、「VR環境に構築したプレゼンテーションルーム」 にて作成した WebSocket アプリケーションを ElixirPhoenix Framework を使ってどのように実現したかをご紹介します。

「VR でプレゼンテーションルームを作る!」 ->「発表者と複数のオーディエンスがログインできて、発表スライドや質問や音声をリアルタイムにやりとりする!」 をどのように実現するか検討した結果、

「WebSocket で実現できるのでは?」 注目していた「Phoenix Framework で簡単に WebSocket を実現出来そう」->「Elixir/Phoenix Framework で実装してみる」 という流れで今回のアプリケーションの実現となりました。


ですが、すんなり実現とはいかず苦労もありました。

Phoenix Framework では決まった通信フォーマットで WebSocket メッセージを投げる必要があるのですが、何を投げれば良いのか理解するまでに苦労しました。通信フォーマットに topic と event というパラメータがあり、こちらの値によってサーバーコードのどの関数が実行されるのか調査するために、Phoenix Framework 向けに WebSocket 通信できる付属の phoenix.js のコードを読んだり、通信してみて tcpdump で通信フォーマットや内容を調査したりしました。 通信フォーマットが分かれば、あとは実装していくのみで公式サイトを中心に調べながらアプリケーションを実装しました。


■ 仕様

今回作成したアプリケーションの仕様を簡単に説明します。
  • 概要
    • VR環境上にプレゼンテーションルームを構築
    • 発表者とオーディエンスとしてバーチャルルームへログイン可能
    • リアルタイム通信
  • 発表者
    • スライド資料をバーチャルモニターに表示
    • スライドページの送り・戻し
    • バーチャルキーボードでチャットが可能
    • 頭の動きにあわせてアバターの頭が動く
  • オーディエンス
    • スライド資料を見ることができる
    • 発表者の頭の動きが見える
    • チャットが見れる

■ 技術仕様/構成

archi

●Phoenix Framework

Channel について
Phoenix Framework には WebSocket を提供する Channel という機能があります。この Channel で WebSocket のルーティングや topic という単位での接続管理や同時メッセージ配信(ブロードキャスト)などを提供しています。


■ 通信内容

●WebSocket 接続

URI : /socket/websocket
通信例
wscat -c http://localhost:4000/socket/websocket?token=SFMyNTY.g3QAAAACZAAEZGF0YWFVZAAGc2lnbmVkbgYAZLT26FwB.4udfcTjmuxv3zbb57PYqrwbvyOmnZcGD2pO2ULyExGo

※以降は WebSocket 接続後の通信メッセージです。

●topic へ接続

・topic へ接続する 通信メッセージ
・Phoenix Framework の WebSocket 機能(Channel) では topic 単位で WebSocket に接続する
・ブロードキャスト通信すると 同一 topic に接続している全てのユーザーにメッセージが届く
・event "phx_join" で topic へジョインする 実際には room_channel.ex の def join("rooms:vr_presentation", xxx, socket) へ処理が渡される

{"topic":"rooms:vr_presentation","ref":1,"payload":{"room_name":"z"},"event":"phx_join"}
通信例
-- リクエスト
> {"topic":"rooms:vr_presentation","ref":1, "payload":{"room_name":"room1"},"event":"phx_join"}

-- レスポンス
< {"topic":"rooms:vr_presentation","ref":1,"payload":{"status":"ok","response":{"url":"/images/vr/0.png","seat_position":4,"result":1}},"event":"phx_reply"}

●スライド操作

発表スライドのページ送り/戻しの通信メッセージ(発表者が送信)
->受信したオーディエンスのVRアプリケーションが指定(オーディエンスが受信)
-- 送り(page_action:next)
{"topic":"rooms:vr_presentation", "ref":1, "payload":{"token":"","seat_position":0,"document_id":1,"page":0,"page_action":"next"}, "event":"presenter:page_action"}

-- 戻し(page_action:before)
{"topic":"rooms:vr_presentation", "ref":1, "payload":{"token":"","seat_position":0,"document_id":1,"page":0,"page_action":"before"}, "event":"presenter:page_action"}
通信例
-- リクエスト
> {"topic":"rooms:vr_presentation", "ref":1, "payload":{"token":"","seat_position":0,"document_id":1,"page":0,"page_action":"next"}, "event":"presenter:page_action"}

-- レスポンス
< {"topic":"rooms:vr_presentation","ref":null,"payload":{"screen_url":"/images/vr/1.png","page":1},"event":"presenter:page_action"}

●キャラクターの頭の動き

顔の位置や角度の座標を通知する通信メッセージ(発表者が送信)
->受信したオーディエンスのVRアプリケーションが発表者の顔の位置を座標にあわせて表示して頭の動きを再現します。(オーディエンスが受信)
{"topic":"rooms:vr_presentation", "ref":1, "payload":{ "token":"SFMyNTY.g3QAAAACZAAEZGF0YWEOZAAGc2lnbmVkbgYAWOnL9lgB.eFmZH_hdrl38McG44P8zRTnDghRCMVzEmCf9Pv7_464", "seat_position":0, "head_position": { "x": 0, "y": 0, "z": 0 }, "angle": { "x": 0, "y": 0, "z": 0 }},"event":"presenter:motion"}

メッセージ

チャットメッセージを送信する。
{"topic":"rooms:vr_presentation", "ref":1, "payload":{"seat":0,"body":"chat-message!"}, "event":"presenter:outmessage"}
通信例
-- リクエスト
> {"topic":"rooms:vr_presentation", "ref":1, "payload":{"user":"a@a","body":"chat-message!!!"}, "event":"new:message"}

-- レスポンス
< {"topic":"rooms:vr_presentation","ref":null,"payload":{"user":"user01","body":"chat-message!!!"},"event":"new:message"}


■ サーバー実装

WebSocket 通信時に実行される主なモジュールと順番はこちらになります。
<クライアント>
WebSocket 通信
 ↓

<サーバー>
lib/vr_presentation/endpoint.ex
 ↓
channels/user_socket.ex
 ↓
channels/room_channel.ex 


●lib/vr_presentation/endpoint.ex

全てのアクセスの起点です。

URL(/socket) へのリクエストを user_socket.ex へ処理を渡します
defmodule VrPresentation.Endpoint do
  socket "/socket", VrPresentation.UserSocket   


●channels/user_socket.ex

WebSocket の利用や認証を行います。

topic が rooms: で始まるメッセージを受信すると room_channel.ex へ処理を渡します
defmodule VrPresentation.UserSocket do
  use Phoenix.Socket

  ## Channels 
  channel "rooms:*", VrPresentation.RoomChannel

  ## Transports
  transport :websocket, Phoenix.Transports.WebSocket
  ...
end


●channels/room_channel.ex

WebSocket リクエストに対応する自作モジュールです。長いので分割して説明します。

▼宣言部

defmodule VrPresentation.RoomChannel do
  use Phoenix.Channel
  alias VrPresentation.Repo
  alias VrPresentation.User
  alias VrPresentation.DocumentUrl
  alias VrPresentation.Room
  alias VrPresentation.UserRoomRel
  require Logger

▼topic へジョイン

topic “rooms:vr_presentation” & event:”phx_join” を受信した際に実行される。
  def join("rooms:vr_presentation", message, socket) do
    join_user(message, socket)
  end

  defp join_user(message, socket) do
    room = Repo.get_by(Room, room_name: message["room_name"])
    # room = Repo.get_by(Room, room_name: 1)
    case room do
      nil ->
        {:ok, %{:result => 0, :reason => "roomがありませんでした"}, socket}

      _ ->
        # seat_position
        case UserRoomRel.entry_room(%{:user_id => socket.assigns[:user_id], :room_id => room.id}, Repo) do
          :error ->
            {:error, %{:result => 0, :reason => "roomへの入室ができませんでした"}, socket}
          {:ok, seat_position, url} ->
            {:ok, %{:result => 1, :seat_position => seat_position, :url => url}, socket}
        end
    end
  end

▼スライド操作

発表スライドとページ送り/戻りメッセージを受信した際に実行される。
次に表示するスライドページのURLをオーディエンスへブロードキャストする。
  def handle_in("presenter:page_action", message, socket) do

    # operation  document_url から 次のpage番号を選択
    next_page = if message["page_action"] == "next" do
      message["page"] + 1
    else
      message["page"] - 1
    end

    try do
      # DBから発表スライドとページ番号を取得
      document_url = Repo.get_by(DocumentUrl, page: next_page)
      # 取得できたら全てのオーディエンスへページをブロードキャスト
      broadcast! socket, "presenter:page_action", %{page: next_page, screen_url: document_url.url}
      {:noreply, socket}
    rescue
      # 次ページを取得できなかったら元のpageを返す
      other_error ->
        document_url = Repo.get_by(DocumentUrl, page: message["page"])
        broadcast! socket, "presenter:page_action", %{page: message["page"], screen_url: document_url.url}
        {:noreply, socket}
    end
  end

▼キャラクターの頭の動き

topic”presenter:motion”メッセージで受け取ったキャラクター座標をオーディエンスへブロードキャストする。
クライアントは座標にあわせてVR上の頭表示位置を変更する。
  def handle_in("presenter:motion", motions, socket) do
    # 発表者から受信したメッセージをそのままオーディエンスへブロードキャスト
    broadcast! socket, "presenter:motion", motions
    {:noreply, socket}
  end


▼メッセージ

topic”presenter:outmessage”で受け取ったチャットメッセージをオーディエンスへブロードキャストする。
  def handle_in("presenter:outmessage", message, socket) do
    # 全クライアントへ配信
    broadcast! socket, "presenter:outmessage", %{seat: message["seat"], body: message["body"]}
    {:noreply, socket}
  end


●おまけ


▼WebSocket タイムアウト値の変更方法


user_socket.ex にて WebSocket 使用宣言時に timeout: 秒数を指定することで変更できます。
(当初、設定方法が分からずにデフォルトタイムアウト60秒で接続が切れてしまい苦労しました)
  transport :websocket, Phoenix.Transports.WebSocket, timeout: 60_000 * 60

▼WebSocket リクエスト送信

wscat を使うと WebSocket 確認が簡単です。 オススメです。 WebSocket に必要な HTTP ヘッダなど指定しなくとも簡単に接続できます。
wscat -c {url} で WebSocket 接続できます。 
(例)
wscat -c http://localhost:4000/socket/websocket?token=SFMyNTY.g3QAAAACZAAEZGF0YWFVZAAGc2lnbmVkbgYAZLT26FwB.4udfcTjmuxv3zbb57PYqrwbvyOmnZcGD2pO2ULyExGo


■ 最後に

今回 WebSocket を作ってみて、通信の軽さやブロードキャストの有用性を感じました。また、 Phoenix Framework で容易に WebSocket アプリケーションが作れることが実感ので、今後も引き続き Elixir/Phoenix Framework をウォッチしていく予定です。

次世代システム研究室では、グループ全体のインテグレーションを支援してくれるアーキテクトを募集しています。アプリケーション開発の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。