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

====> チャットWebアプリ Live!(書き込み禁止)

 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の作成が終わりです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.