JavaScript
Elixir
chat
Phoenix
channel

Phoenix Channelで作る最先端Webアプリ - topic-subtopic編

Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita
Phoenix Channelで作る最先端Webアプリ - ETS編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Fault Tolerance編 - Qiita
Phoenix Channelで作る最先端Webアプリ - 地図(Geo)拡張編 - Qiita
Phoenix Channelで作る最先端Webアプリ - DynamicSupervisor編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Elixier Application編 - Qiita
Phoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiita

 今回の技術的なポイントは以下の通りです。

ポイント
1.Phoenix Channelのtopic/subtopicで独立した複数の部屋を作る
2.Phoenix Channelでauthorized?(join名の2重チェック、人数制限)を行う

 channelにjoinするときにsubtopicを指定することで、チャットルームを別々にすることができます。Presenceも別々に管理されます。今回はPhoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiitaに少し修正を加え、topic-subtopicの仕様を確認しました。

1.実験結果を画像で確認する

 ソースコードに簡単な修正を加え、実験した結果です。

 ユーザ名とサブトピック名を入力してjoinします。
image.png

 2つブラウザを開いて、それぞれ「ジョン」と「ポール」というユーザ名で、「ビートルズ」というサブトピック名を入力しjoinします。参加者の横の「ジョン @ ビートルズ」が自分自身で「ビートルズ」というサブトピックに参加しているのがわかります。
image.png

 もう2つブラウザを開き、それぞれ「山田隆夫」と「新井康弘」というユーザ名で、「ずうとるび」というサブトピック名を入力しjoinします。サブトピック名が異なると、Presence(参加者)も異なることがわかります。期待通り「ビートルズ」と「ずーとるび」というサブトピックのPresence(参加者)は独立しています。

image.png

 ジョンが「ハロー」と言います。
image.png

 同じサブトピックのポールには伝わっています。
image.png

 しかし山田隆夫には伝わっていません。サブトピックが違うからですね。
image.png

2.ソースコードの修正

 開発手順はPhoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiitaと同じです。ソースコードもほぼ同じですので、修正を加えた部分のみ説明します。但しプロジェクト名はReactChatからReactChatTopicに変えています。

 まずchannel moduleですが、joinのtopic-subtopic引数を、subtopicの部分だけ変数で受けるようにします。subtopicは任意の文字列でokになります。

lib/react_chat_web/channels/room_channel.ex
#
  def join("room:"<>subtopic, %{"user_name" => user_name}, socket) do
    IO.puts ("subtopic=#{subtopic}")
    send(self(), {:after_join, user_name})
    {:ok, socket}
  end
#

 以下Chat.jsですが、画面通りに修正した個所のみをピックアップします。

assets/js/Chat.js
#
    this.state = {
#
      inputUser: "",    //ユーザ名の入力
      inputSubtopic: "",    //サブトピックの入力
      inputMessage: "", //メッセージの入力
#
    }
  }

#
  handleInputSubtopic(event) {
    this.setState({
      inputSubtopic: event.target.value
    })
  }
#
  // join処理
  handleJoin(event) {
    event.preventDefault();
    if(this.state.inputUser!="" && this.state.inputSubtopic!="") {
#
        this.channel = this.socket.channel("room:"+this.state.inputSubtopic,  {user_name: this.state.inputUser});
#
  // 画面表示
  render() {
 #
   let form_jsx;
    if(this.state.isJoined===false) {
       form_jsx = (
        <form onSubmit={this.handleJoin.bind(this)} >
          <label>ユーザ名を指定してJoin</label>&nbsp;&nbsp;&nbsp;&nbsp;
          <TextField hintText="ユーザ名" value = {this.state.inputUser} onChange = {this.handleInputUser.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
          <TextField hintText="サブトピック名" value = {this.state.inputSubtopic} onChange = {this.handleInputSubtopic.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
          <RaisedButton type="submit" primary={true} label="Join" />
        </form>
       );
    } else {
       form_jsx = (
         <div>
           <Paper  style={style1}>
             <label>参加者 : {this.state.inputUser}  {this.state.inputSubtopic}</label>
             <ul>
               {presences_list}
             </ul>
#
           <Paper  style={style1}>
             <form onSubmit={this.handleSubmit.bind(this)}>
               <label>チャット</label>&nbsp;&nbsp;&nbsp;&nbsp;
               <TextField hintText="Chat Text" value = {this.state.inputMessage} onChange = {this.handleInputMessage.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
               <RaisedButton type="submit" primary={true} label="Submit" />
             </form>
#

3.5人目のビートルズは要らない

 今度はチャットルームに入場制限をかけたいと思います。同じ名前でのjoinを禁止し、更に5人以上はjoinできないようにします。これらの制限がsubtopic単位で行われることに注意してください。

 channel moduleに以下のようなコードを追加します。if authorized?(socket, user_name) doでjoinする前にチェックしています。下手な説明をするよりはソースを見ていただければわかりやすい思います。

lib/react_chat_web/channels/room_channel.ex
#
  def join("room:"<>subtopic, %{"user_name" => user_name}, socket) do
    IO.puts ("subtopic=#{subtopic}")
    if authorized?(socket, user_name) do
      send(self(), {:after_join, user_name})
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  defp authorized?(socket, user_name) do
    number_of_players(socket) < 4 && !existing_player?(socket, user_name)
  end

  defp number_of_players(socket) do
    socket
    |> Presence.list()
    |> Map.keys()
    |> length()
  end

  defp existing_player?(socket, user_name) do
    socket
    |> Presence.list()
    |> Map.has_key?(user_name)
  end
#

 実際に動作を確認します。まず4人制限を見ます。画面でエラー処理をするのが面倒なので、iexシェルでエラー処理を確認します。最後の行でエラーになっているのが確認できます。5人目(ブライアン)を追加しようとしてエラーになっています。ビートルズに5人目は要りませんね。

subtopic=ビートルズ
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョン"}
[info] Replied room:ビートルズ :ok
subtopic=ビートルズ
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ポール"}
[info] Replied room:ビートルズ :ok
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "リンゴ"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
subtopic=ビートルズ
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョージ"}
[info] Replied room:ビートルズ :ok
subtopic=ビートルズ
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ブライアン"}
[info] Replied room:ビートルズ :error

 今度は5人目(ブライアン)が「ずーとるび」に参加してみます。これはokですので、この人数制限はサブトピック単位で行われていることがわかります。

[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョン"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 412μs
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ポール"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 418μs
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "リンゴ"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 377μs
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョージ"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 415μs
[info] JOIN "room:ずーとるび" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ブライアン"}
subtopic=ずーとるび
[info] Replied room:ずーとるび :ok: 

 今度はジョンというユーザ名で「ビートルズ」サブトピックに2重にjoinしてみます。これはエラーになります。しかし「ずーとるび」サブトピックにならjoinできました。これもsubtopic単位で行われていることがわかりました。

[info] Sent 200 in 59ms
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョン"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 386μs
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ポール"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :ok
[info] GET /
[debug] Processing with ReactChatTopicWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
[info] Sent 200 in 520μs
[info] JOIN "room:ビートルズ" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョン"}
subtopic=ビートルズ
[info] Replied room:ビートルズ :error
[info] Sent 200 in 414μs
subtopic=ずーとるび
[info] JOIN "room:ずーとるび" to ReactChatTopicWeb.RoomChannel
  Transport:  Phoenix.Transports.WebSocket (2.0.0)
  Serializer:  Phoenix.Transports.V2.WebSocketSerializer
  Parameters: %{"user_name" => "ジョン"}
[info] Replied room:ずーとるび :ok: 

 今回は以上となります。

4.修正ソースコードの全リスト

今回修正したソースの全リストを掲載します。

lib/react_chat_web/channels/room_channel.ex
defmodule ReactChatTopicWeb.RoomChannel do
  use ReactChatTopicWeb, :channel
  alias ReactChatTopicWeb.Presence

  def join("room:"<>subtopic, %{"user_name" => user_name}, socket) do
    IO.puts ("subtopic=#{subtopic}")
    if authorized?(socket, user_name) do
      send(self(), {:after_join, user_name})
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

  defp authorized?(socket, user_name) do
    number_of_players(socket) < 4 && !existing_player?(socket, user_name)
  end

  defp number_of_players(socket) do
    socket
    |> Presence.list()
    |> Map.keys()
    |> length()
  end

  defp existing_player?(socket, user_name) do
    socket
    |> Presence.list()
    |> Map.has_key?(user_name)
  end

  def handle_in("new_msg", %{"msg" => msg}, socket) do
    user_name = socket.assigns[:user_name]

    broadcast(socket, "new_msg", %{msg: msg, user_name: user_name})
    {:reply, :ok, socket}
  end

  def handle_info({:after_join, user_name}, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _ref} = Presence.track(socket, user_name, %{online_at: now()})
    {:noreply, assign(socket, :user_name, user_name)}
  end

  def terminate(_reason, socket) do
    {:noreply, socket}
  end

  defp now do
    System.system_time(:seconds)
  end
end
assets/js/Chat.js
import React from "react";
import {Socket, Presence} from "phoenix"
import RaisedButton from 'material-ui/RaisedButton';
import Paper from 'material-ui/Paper';
import Divider from 'material-ui/Divider';
import TextField from 'material-ui/TextField';

class Chat extends React.Component {
  constructor() {
    super();
    this.state = {
      isJoined: false,  //joinしているかどうか。画面を切り替える。
      inputUser: "",    //ユーザ名の入力
      inputSubtopic: "",    //サブトピックの入力
      inputMessage: "", //メッセージの入力
      messages: [],     //受け取ったメッセージの配列
      presences: {}     //presence(参加ユーザ)の状態
    }
  }

  handleInputUser(event) {
    this.setState({
      inputUser: event.target.value
    })
  }

  handleInputSubtopic(event) {
    this.setState({
      inputSubtopic: event.target.value
    })
  }

  handleInputMessage(event) {
    this.setState({
      inputMessage: event.target.value
    })
  }

  // join処理
  handleJoin(event) {
    event.preventDefault();
    if(this.state.inputUser!="" && this.state.inputSubtopic!="") {

        // assets/js/socket.jsのデフォルトの定義と同じ
        this.socket = new Socket("/socket", {params:
          {token: window.userToken}
        });
        this.socket.connect();

        this.channel = this.socket.channel("room:"+this.state.inputSubtopic,  {user_name: this.state.inputUser});

        // Presences:現在のサーバの状態を初期状態として設定
        this.channel.on('presence_state', state => {
          let presences = this.state.presences;
          presences = Presence.syncState(presences, state);
          this.setState({ presences: presences })
          console.log('state', presences);
        });

        // Presences:初期状態からの差分を更新していく
        this.channel.on('presence_diff', diff => {
          let presences = this.state.presences;
          presences = Presence.syncDiff(presences, diff);
          this.setState({ presences: presences })
          console.log('diff', presences);
        });

        // メッセージを受け取る処理
        this.channel.on("new_msg", payload => {
          let messages = this.state.messages;
          messages.push(payload)
          this.setState({ messages: messages })
        })

        // channelにjoinする
        this.channel.join()
          .receive("ok", response => { console.log("Joined successfully", response) })
          .receive('error', resp => { console.log('Unable to join', resp); });

        this.setState({ isJoined: true })
    }
  }
  // 退室の処理 socketを切断するだけ。これでいいのか?
  handleLeave(event) {
    event.preventDefault();
    this.socket.disconnect();
    this.setState({ isJoined: false })
  }

  // メッセージ送信の処理
  handleSubmit(event) {
    event.preventDefault();
    this.channel.push("new_msg", {msg: this.state.inputMessage})
    this.setState({ inputMessage: "" })
  }

  // 画面表示
  render() {
    const style1 = { margin: '16px 32px 16px 16px', padding: '10px 32px 10px 26px',};
    const style2 = { display: 'inline-block', margin: '1px 8px 1px 4px',};

    const messages = this.state.messages.map((message, index) => {
        return (
            <div key={index}>
              <p><strong>{message.user_name}</strong> > {message.msg}</p>
            </div>
        )
    });

    let presences = [];
    Presence.list(this.state.presences, (name, metas) => {
        presences.push(name);
    });
    let presences_list = presences.map( (user_name, index) =>
      <li key={index} style={style2}>{user_name}</li>
    );
    let form_jsx;
    if(this.state.isJoined===false) {
       form_jsx = (
        <form onSubmit={this.handleJoin.bind(this)} >
          <label>ユーザ名を指定してJoin</label>&nbsp;&nbsp;&nbsp;&nbsp;
          <TextField hintText="ユーザ名" value = {this.state.inputUser} onChange = {this.handleInputUser.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
          <TextField hintText="サブトピック名" value = {this.state.inputSubtopic} onChange = {this.handleInputSubtopic.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
          <RaisedButton type="submit" primary={true} label="Join" />
        </form>
       );
    } else {
       form_jsx = (
         <div>
           <Paper  style={style1}>
             <label>参加者 : {this.state.inputUser}  {this.state.inputSubtopic}</label>
             <ul>
               {presences_list}
             </ul>
             <div align="right">
               <form onSubmit={this.handleLeave.bind(this)} >
                 <RaisedButton type="submit" primary={true} label="Leave" />
               </form>
             </div>
           </Paper>
           <Paper  style={style1}>
             <form onSubmit={this.handleSubmit.bind(this)}>
               <label>チャット</label>&nbsp;&nbsp;&nbsp;&nbsp;
               <TextField hintText="Chat Text" value = {this.state.inputMessage} onChange = {this.handleInputMessage.bind(this)} />&nbsp;&nbsp;&nbsp;&nbsp;
               <RaisedButton type="submit" primary={true} label="Submit" />
             </form>
             <Divider />
             <br />
             <div>
               {messages}
             </div>
           </Paper>
         </div>
      );
    }

    return (
      <div>
        {form_jsx}
      </div>
    )
  }
}
export default Chat

以上です。