JavaScript
Phoenix
React
channel

Phoenix Channelで作る最先端Webアプリ - Reactチャット編

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を設定する
2.Phoenix channelのPresenceを実装する(mix phx.gen.presence)
3.PhoenixでReactクライアントの設定を行う
4.ReactでChannelに接続する
5.Reactでchatアプリを作る

 Phoenix Channelは次世代のWebアプリの骨格を与えてくれると言われています。その言葉を信じて、Phoenix Channelを中心として、ElixirとJavaScriptでのアプリ開発を追求していきたいと思います。「Phoenix Channelで作る最先端Webアプリ」というタイトルで何回かに分けて記事を書きたいと思います。少し恥ずかしいタイトルですが、最先端を探求したい、という意味合いで取って頂ければ幸いです。初回はReactチャット編です。今回作成するプロジェクトをベースとして、本シリーズを続けていくつもりです。

 Phoenix channelとElmクライアントでチャットプログラムを作る記事を「Phoenix Channelとelm-phoenixについて - Qiita」で書きました。今回はElmクライアントをReactクライアントで置き換えます。

 Phoenix channelのクライアントとしてElmでなく、Reactを使う利点は2つあります。まず第1はassets管理のBrunchをそのまま使うことができる点です。追加のパッケージは必要ありません。第2はchannelのライブラリが、標準のJavaScriptのものをそのまま使える点です。これも追加のパッケージは必要ありません。

1.Phoenixの設定を行う

 プロジェクトを作成し、ReactChat Applicationを作っていきます。

mix phx.new react_chat --no-ecto
cd react_chat

さてmix.exsファイルで、このプロジェクトのスタート地点を確認します。modはこのApplicationのトップのmoduleを示しています。(ちなみにextra_applicationsはこのApplicationが実行されるときに、すでに動作している必要があるApplicationのリストです。動作していなければ起動されます。)

mix.exs
#
  def application do
    [
      mod: {ReactChat.Application, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end
#

 Phoenix channelにはPresenceという機能があります。Phoenix.Presence。これはchannelのtopicをsubscribeしているクライアントを記録するためのものです。つまりチャットに参加しているクライアントを記録します。一般的には分散環境などでも安全に記録するためにPresenceが提供されているようです。以下のコマンドでlib/react_chat_web/channels/presence.exが作成されます

mix phx.gen.presence

 作成されたファイルの中身です。ReactChatWeb.Presence が定義されています。以下はコメントを削除した後のシンプルなものですが、コメントにはpresenceの実装のガイドがありますので一読の価値はあると思います。

lib/react_chat_web/channels/presence.ex
defmodule ReactChatWeb.Presence do
  use Phoenix.Presence, otp_app: :react_chat,
                        pubsub_server: ReactChat.PubSub
end

 modで指定されたReactChat.Applicationを確認してみましょう。今回はPresence機能を使いたいので、childrenにReactChatWeb..Presenceを追加します。これでReactChatWeb.ApplicationはsupervisorとしてReactChatWeb.EndpointとReactChatWeb.Presenceをスタートするようになります。Presenceはあるchannelのあるtopicに参加しているユーザをtrackし続ける機能です。

lib/react_chat/application.ex
#
    children = [
      supervisor(ReactChatWeb.Endpoint, []),
      supervisor(ReactChatWeb.Presence, []),
    ]
#

 ReactChatWeb.Presenceを追加したことにより、join後にpresencesがtrackされるようになります。

 次にReactChatWeb.Endpointをみてみます。このプロジェクトのEndpointで、socketへのrouteがReactChatWeb.UserSocketへ振り分けられていることを確認します。

lib/react_chat_web/endpoint.ex
defmodule ReactChatWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :react_chat

  socket "/socket", ReactChatWeb.UserSocket
#

 次にReactChatWeb.UserSocketを確認します。以下のようにchannel行のコメントを外します。これで"room:"ではじまるtopicに関連したメッセージは全てReactChatWeb.RoomChannelを通るようになります。

lib/react_chat_web/channels/user_socket.ex
  ## Channels
  channel "room:*", ReactChatWeb.RoomChannel
#
  def connect(_params, socket) do
    {:ok, socket}
  end
#

 ちなみにここで定義してあるconnect/2は、クライアントからのsocket接続のリクエストが必ず通る関数です。この関数は認証などを行う場所として適していますが、今回はデフォルトのソースのままで修正は加えません。

 さて最後にchannel moduleを作ります。room_channel.exですが、これは記事「Phoenix Channelとelm-phoenixについて - Qiita」のものと全く同じです。チャットプログラムのコードとしては汎用性があります。

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

  def join("room:lobby", %{"user_name" => user_name}, socket) do
    send(self(), {:after_join, user_name})
    {:ok, socket}
  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

 まずjoin/3についてです。クライアントがChannel通信を利用するためには、最初にtopic-subtopicを指定してjoinする必要があります。joinに対するサーバ側の処理はここでコーディングしています。"room:lobby"はtopicが"room"でsubtopicが"lobby"であることを示しています。presenceを使うので以下のコードを追加しています。

send(self(), {:after_join, user_name})

 これは自分自身(self())にメッセージを送り、handle_info/3を起動しています。

  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

 クライアント側はPresenceの状態を維持するために、'presence_state'と'presence_diff'のイベントをリッスンしています。

 push/3はjoinしてきたクライアントに対して'presence_state'イベントを通知し、現在のPresenceの状態(joined ユーザ一覧)を伝えます。クライアントは初期状態としてこれを記憶します。

 またPresence.track/3はこのchannel processをuser_nameに対するpresenceとして登録します。これ以降のPresenceの変更は'presence_diff'イベントとして、自動的にクライアントに通知されます。初期状態が変更され、Presenceの状態が最新のものに維持され続けます。ここではこれ以上Presenceに深入りしません(できません)が、このようなコーディングで入退室時のユーザ一覧を管理できるようになります。

 handle_in/3でチャットメッセージを受取、他のユーザにブロードキャストします。

2.Reactの設定を行う

 assetsディレクトリで、reactに必要なパッケージをインストールします。

cd assets
npm install react react-dom material-ui babel-preset-react --save

 brunch-config.jsを修正します。

assets/brunch-config.js
#
plugins: {
  babel: {
    presets: ["es2015", "react"],
    // Do not use ES6 compiler in vendor code
    ignore: [/web\/static\/vendor/]
  }
},
#
npm: {
  enabled: true,
  whitelist: ["phoenix", "phoenix_html", "react", "react-dom"]
}
#

 これでPhoenixでReactを開発するための環境が整いました。プログラムを作成していきます。

 まず、テンプレートファイルを修正します。Reactアプリにマウントポイントを作ります。

lib/react_chat_web/templates/page/index.html.eex
<div id="app"></div>

 次にReactアプリのメインを作ります。通常のReactの作法通りですが、Material-UIを使うように設定しています。

assets/js/app.js
import "phoenix_html";
import React from "react";
import ReactDOM from "react-dom";
import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import getMuiTheme from 'material-ui/styles/getMuiTheme';

import Chat from "./Chat";

class App extends React.Component {
  render() {
    return (
      <Chat />
    )
  }
}

const target = document.getElementById('app');
const node =(
  <MuiThemeProvider muiTheme={getMuiTheme(lightBaseTheme)}>
    <App />
  </MuiThemeProvider>
);

ReactDOM.render( node, target )

 最後にReactアプリの本体であるChat.jsを示します。channelに関する設定はほとんど handleJoin()で行っています。コメントを付けてありますので、ちょっと長いですが、理解しやすいのではないでしょうか?

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: "",    //ユーザ名の入力
      inputMessage: "", //メッセージの入力
      messages: [],     //受け取ったメッセージの配列
      presences: {}     //presence(参加ユーザ)の状態
    }
  }

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

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

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

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

        this.channel = this.socket.channel("room:lobby",  {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;
          <RaisedButton type="submit" primary={true} label="Join" />
        </form>
       );
    } else {
       form_jsx = (
         <div>
           <Paper  style={style1}>
             <label>参加者 : {this.state.inputUser}</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

3.チャット画面

joinする直前の画面です
image.png

joinした直後の画面です
image.png

別ブラウザから「はすける」さんも参戦してきました
image.png

「はすける」さんに話しかけてみます
image.png

「はすける」さんから返事がありました。よしよしです。
image.png

「佐藤花子」さんも参戦してきました
image.png

「はすける」さんが退室しました
image.png

わたしは疲れました。

以上でReactChat Applicationの作成が終わりです。