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

[Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita](https://qiita.com/sand/items/d5d77db657027abe6141)
[Phoenix Channelで作る最先端Webアプリ - ETS編 - Qiita](https://qiita.com/sand/items/1fa3f728f4fd579102bc)
[Phoenix Channelで作る最先端Webアプリ - Fault Tolerance編 - Qiita](https://qiita.com/sand/items/a0e070b49e726e1005f2)
[Phoenix Channelで作る最先端Webアプリ - 地図（Geo）拡張編 - Qiita](https://qiita.com/sand/items/ed8320872fc508bb0e8e)
[Phoenix Channelで作る最先端Webアプリ - DynamicSupervisor編 - Qiita](https://qiita.com/sand/items/c2f089e218a0e9878abd)
[Phoenix Channelで作る最先端Webアプリ - Elixier Application編 - Qiita](https://qiita.com/sand/items/5ae4495154dacb4bea71)
[Phoenix Channelで作る最先端Webアプリ - Reactチャット編 - Qiita](https://qiita.com/sand/items/e4940eaf83daa69d8a59)

■ **Phoenix Channelをテーマにした過去記事**
[Phoenix Channelとelm-phoenixについて -Qiita](https://qiita.com/sand/items/f7f6fd8d93e68d651667)
[東京電力電力供給状況監視 - Phoenix Channel - Qiita](https://qiita.com/sand/items/5aa0d8ff778854411a42)




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

```:ポイント
１．Phoenix Channelを設定する
２．Phoenix channelのPresenceを実装する(mix phx.gen.presence)
３．PhoenixでReactクライアントの設定を行う
４．ReactでChannelに接続する
５．Reactでchatアプリを作る
```




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


　Phoenix channelとElmクライアントでチャットプログラムを作る記事を「[Phoenix Channelとelm-phoenixについて - Qiita](https://qiita.com/sand/items/f7f6fd8d93e68d651667)」で書きました。今回はElmクライアントをReactクライアントで置き換えます。

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


# １．Phoenixの設定を行う

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

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

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

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

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


```sh
mix phx.gen.presence
```

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

```elixir: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し続ける機能です。

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


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


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


```elixir: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を通るようになります。


```elixir: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](https://qiita.com/sand/items/f7f6fd8d93e68d651667)」のものと全く同じです。チャットプログラムのコードとしては汎用性があります。


```elixir: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を使うので以下のコードを追加しています。

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

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

```elixir
  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でチャットメッセージを受取、他のユーザにブロードキャストします。




# ２．Reactの設定を行う

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

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

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

```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アプリにマウントポイントを作ります。

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

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

```js: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()で行っています。コメントを付けてありますので、ちょっと長いですが、理解しやすいのではないでしょうか？


```js: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
```



# ３．チャット画面


joinする直前の画面です
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/8db1aedb-f1c4-e433-ca2c-df326e2cab4d.png)


joinした直後の画面です
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/fe4d6c63-dfe4-8d74-f4ad-b6d2a8b46149.png)


別ブラウザから「はすける」さんも参戦してきました
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/85b54de0-f95d-81a5-eee5-11fa2f4b4ca9.png)


「はすける」さんに話しかけてみます
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/4e94496f-f9dc-1770-b83f-2b6008cd4f83.png)


「はすける」さんから返事がありました。よしよしです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/fdc99d5f-205f-39b3-bc64-e7fad8cbf47a.png)


「佐藤花子」さんも参戦してきました
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/6321e120-99bc-6dbd-0a1b-a09e60e69309.png)


「はすける」さんが退室しました
![image.png](https://qiita-image-store.s3.amazonaws.com/0/61915/d0a47f5e-33c8-4f0c-1c10-b14ead84ccee.png)


わたしは疲れました。

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