Elixir/PhoenixとReactをWebsocketでつないでみる

### tl;dr
* PhoenixでWebsocket使ったことなかったので使ってみた
* Phoenixのガイド通りにやってみたらすごい簡単にできた
* せっかくだったのでReactで作ったアプリからつないでみた

### バージョン
今回使用した言語と主なフレームワークのバージョンは下記の通りです。

* Elixir: 1.7.3
* Phoenix: 1.4.0
* Node.js: 10.13.0
* React: 16.6.0

### Websocketを使ってみる
PhoenixではChannelを使うことで簡単にWebsocketを使ったアプリケーションが実装できます。

Channelについては[ガイド](https://hexdocs.pm/phoenix/channels.html)がとても親切なので詳しい内容はそちらを参照してください。
今回は実装した内容だけ紹介します。

#### Phoenixプロジェクトをつくる
まずはPhoenixのプロジェクトを作成します。
今回データベースは使いませんが、手元に入っているのがMySQLなので`--database`オプションでMySQLの設定で作成しています。
デフォルトではPostgreSQLで設定されます。

``` console
$ mix phx.new rechat --database mysql
```

``` console
$ cd rechat
$ mix ecto.create
$ mix phx.server
```

↓のような画面が開ければ成功です。

![uploading-0](https://qiita-image-store.s3.amazonaws.com/0/267599/3f0ebe5f-fd59-a063-6852-5f53a657e2c8.png)

#### channelを使ってみる

まずはchannelのルーティングを用意してあげます。
`lib/${appname_web}/channels/user_socket.ex`の5行目あたりにあるコメントアウトを外してあげます。

```elixir
channel "room:*", RechatWeb.RoomChannel
```

次にルーティングする先を用意してあげます。
`lib/${appname_web}/channels/room_channel.ex`を作成して下記のように記載します。

```elixir
  defmodule RechatWeb.RoomChannel do
    use Phoenix.Channel

    def join("room:lobby", _message, socket) do
      {:ok, socket}
    end

    def join("room:" <> _private_room_id, _params, _socket) do
      {:error, %{reason: "authorized"}}
    end

    def handle_in("new_msg", %{"body" => body}, socket) do
      broadcast!(socket, "new_msg", %{body: body})
      {:noreply, socket}
    end
  end
```

これだけでPhoenix側はWebsocketの通信を受け取ることができます。(簡単)

次にブラウザからWebsocketでつなぎに来る部分を実装します。
`assets/js/socket.js`の55行目付近を以下のように書き換えます。

```javascript
  socket.connect()

  // Now that you are connected, you can join channels with a topic:
  const channel = socket.channel('room:lobby', {})
  const chatInput = document.querySelector('#chat-input')
  const messagesContainer = document.querySelector('#messages')

  chatInput.addEventListener('keypress', event => {
    if (event.keyCode === 13) {
      channel.push('new_msg', { body: chatInput.value })
      chatInput.value = ''
    }
  })

  channel.on('new_msg', payload => {
    const messageItem = document.createElement('li')
    messageItem.innerText = `[${Date()}] ${payload.body}`
    messagesContainer.appendChild(messageItem)
  })

  channel
    .join()
    .receive('ok', resp => {
      console.log('Joined successfully', resp)
    })
```

最後に`lib/${appname}_web/templates/page/index.html`に↓の2行を追記して完成です。

```html
<div id="messages"></div>
<input id="chat-input" type="text"></input>
```

Phoenixサーバを立ち上げて、`http://localhost:4000` に接続すると簡単なチャットアプリが使えます。

```console
$ mix phx.server
```

### Reactで接続する
Phoenixのtemplateで実装していたWebアプリをReactで実装したものに置き換えていきます。

Reactの初期設定などについては、`create-react-app`を使うなどして簡単に用意できるので今回は省略します。
今回のアプリケーションは、React+Reduxの標準的な構成にしています。

```console
$ tree assets/js

assets/js
├── actions
│   └── index.js
├── components
│   └── index.js
├── configureStore.js
├── containers
│   └── index.js
├── main.js
└── reducers
    ├── Form.js
    └── index.js

4 directories, 7 files
```

実装した内容の一部を抜粋します。

```javascript
// actions/index.js
  import { Socket } from 'phoenix'

  export const TEXT_CHANGE = 'TEXT_CHANGE'
  export const RECIEVE_COMMENT = 'RECIEVE_COMMENT'

  const socket = new Socket('/socket', { params: { token: window.userToken } })
  socket.connect()
  const channel = socket.channel('room:lobby', {})

  export const join = dispatch => {
    channel
      .join()
      .receive('ok', resp => {
        console.log('Join successfully', resp)
        subscribe(dispatch)
      })
      .receive('error', resp => {
        console.log('Unable to join', resp)
      })
  }

  export const subscribe = dispatch => {
    channel.on('new_msg', payload => {
      dispatch({
        type: RECIEVE_COMMENT,
        comment: payload.body
      })
    })
  }

  export const push = message => {
    channel.push('new_msg', { body: `[${Date()}] ${message}` })
  }
```

```javascript
// containers/index.js
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'

import AppComponent from '../components'
import { textChange, join, push } from '../actions'

class App extends React.Component {
  constructor() {
    super()
    this.onClickHandle = this.onClickHandle.bind(this)
    this.onChangeHandle = this.onChangeHandle.bind(this)
  }

  componentDidMount() {
    const { dispatch } = this.props
    join(dispatch)
  }

  onChangeHandle(event) {
    const { dispatch } = this.props
    dispatch(textChange('text', event.target.value))
  }

  onClickHandle() {
    const { text } = this.props
    push(text)
  }

  render() {
    const { text, comments } = this.props
    return (
      <AppComponent
        text={text}
        comments={comments}
        onClick={this.onClickHandle}
        onChange={this.onChangeHandle}
      />
    )
  }
}

App.propTypes = {
  dispatch: PropTypes.func.isRequired,
  text: PropTypes.string,
  comments: PropTypes.array
}

const mapStateToProps = state => ({
  text: state.Form.text,
  comments: state.Form.comments
})

export default connect(mapStateToProps)(App)
```

```javascript
// reducers/index.js
import { RECIEVE_COMMENT } from '../actions'

const initialState = {
  text: '',
  comments: []
}
export default (state = initialState, action) => {
  switch (action.type) {
    case RECIEVE_COMMENT:
      return {
        ...state,
        comments: state.comments.concat([action.comment]),
        text: ''
      }
    default:
      return state
  }
}
```

ポイントになる部分を見ていくと、
今回のサンプルの場合やりたいことは下記の3つです。
1. ページがロードされたときにWebsocketで接続する
2. メッセージの受信イベントをsubscribeする
3. ボタンが押されたときにメッセージを送る

1.のWebsocketに接続するのはReactの`componentDidMount`で呼び出すことで実現できます。

```javascript
// containers/index.js
  componentDidMount() {
    const { dispatch } = this.props
    join(dispatch)
  }

// actions/index.js
  import { Socket } from 'phoenix'

  const socket = new Socket('/socket', { params: { token: window.userToken } })
  socket.connect()
  const channel = socket.channel('room:lobby', {})

  export const join = dispatch => {
    channel
      .join()
      .receive('ok', resp => {
        ...
```

正確にはjsファイルがロードされたタイミングで接続しているのですが、今回はそこをハンドリングしたいという欲求はなかったのでファイルのスコープに変数をおいています。
ハンドリングしたい場合には、connectのラッパーを書いてあげるとよさそうです。

`join`も成功・失敗のハンドリングを今の所していないので、joinのハンドリングを外側でやりたい場合には`Promise`でラップしてあげるといいかもしれません。

```javascript
export const join = () => {
  return new Promise((resolve, reject) => {
    channel
      .join()
      .receive('ok', resp => {
        resolve(resp)
      })
      .receive('error', resp => {
        reject(resp)
      })
  })
}

// ハンドリングする関数
async function handler() {
  try {
    resp = await join()
    // 正常処理
  } catch(err) {
    // 例外処理
  }
}
```

また、今回の場合はsubscribeのタイミングを任意にする必要がなかったので、joinと同時にsubscribeをしています。
joinに成功した時にのみsubscribeしたかったので`"ok"`のコールバックで呼び出しています。
`react-redux`の`dispatch`を`subscribe`に渡しているあたりがポイントです。

こうすることでメッセージを受信するたびに`RECIEVE_COMMENT`アクションが`dispatch`されます。

```javascript
  export const join = dispatch => {
    channel
      .join()
      .receive('ok', resp => {
        console.log('Join successfully', resp)
        subscribe(dispatch)
      })
      .receive('error', resp => {
        console.log('Unable to join', resp)
      })
  }

  export const subscribe = dispatch => {
    channel.on('new_msg', payload => {
      dispatch({
        type: RECIEVE_COMMENT,
        comment: payload.body
      })
    })
  }
```

メッセージの送信はボタンが押されたときに`channel.push`を実行するようにしています。
メッセージがPhoenixサーバに着信するとbroadcastされて自分にもメッセージが届くので、送信時には特に要素の追加などはせず、↑でsubscribeしたイベントに任せています。

```javascript
  export const push = message => {
    channel.push('new_msg', { body: `[${Date()}] ${message}` })
  }
```

できあがったものがこちらになります
![gif](https://gyazo.com/5c130c85719938b7994d98e148e8cfb3/raw)

### devserverの設定

webpack-dev-serverからPhoenixにつなぐときに、websocketのproxyの設定は下記の通りです。
`devServer.proxy.${path}`に`target`に`ws://`から始まるproxy先を指定して、`ws`に`true`と書いてあげるだけ。
その他の細かいことがしたければ[http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware#options)を見に行くと幸せになれるかもしれません。

```javascript
  devServer: {
    proxy: {
      '/socket/websocket': {
        target: 'ws://localhost:4000',
        ws: true
      }
    }
  },
```

### まとめ

* Websocketがさくっと使えるPhoenixすごい。
* Phoenix.jsのAPIがイベント駆動な作りなので、fluxと相性がいい気がする。
* 今回のソースコードは[Github](https://github.com/hiromoon/ReChat)で公開しています。

### 参考
* [Phoenix.js](https://hexdocs.pm/phoenix/js/index.html)
* [Phoenix-Channels](https://hexdocs.pm/phoenix/channels.html)