Elixir: PhoenixでWebsocketをつないで簡単なチャットアプリケーションをつくる
Tags: elixir, webdev, tutorial, phoenix
Published: 2019-05-24 06:57:30


---
title: Elixir: PhoenixでWebsocketをつないで簡単なチャットアプリケーションをつくる
published: true
description: Phoenixは、Elixirでつくられたwebフレームワークです。Channelの機能を使えば、リアルタイム通信ができます。本稿は、PhoenixプロジェクトでWebsocketをつないだ、簡単なチャットアプリケーションのつくり方についてご説明します。
tags: #elixir #webdev #tutorial #phoenix
---

[Phoenix](https://phoenixframework.org/)は、Elixirでつくられたwebフレームワークです。Channelの機能を使えば、リアルタイム通信ができます。昨年の[gumi Inc. Advent Calendar 2018](https://qiita.com/advent-calendar/2018/gumi)の「[Elixir/PhoenixとReactをWebsocketでつないでみる](https://qiita.com/hiromoon/items/331b7c15a77600a9c3de)」
では、Websocketをつないだ簡単なチャットアプリケーションが紹介されました。本稿はその前半部分、Phoenixプロジェクトによるチャットルームのつくり方について少し説明を加えます。今回は、Elixir 1.8.0とPhoenix 1.4.2を使いました。

# Phoenixプロジェクトをつくる
Phoenixのインストールについては、公式サイトの「[Installation](https://hexdocs.pm/phoenix/installation.html)」をお読みください。まずは、[`mix phx.new`](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html)コマンドでPhoenixプロジェクトをつくります(プロジェクト名`chat`)。`--database`オプションはEctoに用いるデータベースアダプタの定めです。今回は[`mysql`](https://github.com/xerions/mariaex)としました(デフォルト値は[`postgres`](https://github.com/elixir-ecto/postgrex))。なお、このチャットアプリケーションでは、データベースの機能は使いません。

```terminal
mix phx.new chat --database mysql
```

アプリケーションがつくられ始めると、`Fetch and install dependencies? [Yn]`と尋ねられます。`Y`を入力すると、依存関係もインストールされるのでお手軽です。

```terminal
Fetch and install dependencies? [Yn] y
* running mix deps.get
* running mix deps.compile
* running cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development
```

イントールの終わりに、ローカルサーバーでアプリケーションを立ち上げる手順が示されます。指示にしたがってコマンドを入力したあと`http://localhost:4000`を開けば、ひな形のページが表示されるでしょう(図001)。

```terminal
cd chat
mix ecto.create
mix phx.server
```

#### 図001■Phoenixプロジェクトのひな形ページ
![1904001_001.png](https://thepracticaldev.s3.amazonaws.com/i/c06hk8nixrvpq9k2loyt.png)

# `channel`を使う
Phoenixプロジェクトの`lib/chat_web/endpoint.ex`には、つぎのように[`Phoenix.Endpoint`ビヘイビア](https://hexdocs.pm/phoenix/Phoenix.Endpoint.html)を用いてすでにエンドポイントが加えられています。

```elixir
defmodule ChatWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :chat

  socket "/socket", ChatWeb.UserSocket,
    websocket: true,
    longpoll: false

end
```

そして、エンドポイントのモジュール`ChatWeb.UserSocket`を定めているのが、`lib/chat_web/channels/user_socket.ex`です。[`Phoenix.Socket`ビヘイビア](https://hexdocs.pm/phoenix/Phoenix.Socket.html)が使われています。このモジュールで、メッセージを適切なChannelにルーティングしなければなりません。そのために、[`channel/3`](https://hexdocs.pm/phoenix/Phoenix.Socket.html#channel/3)が呼び出されるコードのコメントアウトを外してください。

```elixir
defmodule ChatWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", ChatWeb.RoomChannel  # コメントアウトを外す

end
```

これで、クライアントから"room:"ではじまるトピックでメッセージを送れば、`ChatWeb.RoomChannel`モジュールにルーティングされます。


# クライアントの接続を認証する
チャットルームのメッセージを管理する`ChatWeb.RoomChannel`は、`lib/chat_web/channels/room_channel.ex`として新たに定めます。モジュールは[`Phoenix.Channel`ビヘイビア](https://hexdocs.pm/phoenix/Phoenix.Channel.html)を用い、クライアントがトピックに接続することを認証しなければなりません。認証のために実装するのが、[`join/3`](https://hexdocs.pm/phoenix/Phoenix.Channel.html#c:join/3)です。

チャットルーム"room:lobby"には、誰でも接続できるようにします。そのほかのチャットルームは、プライベートです。実際には、データベースなどによる認証を行うことになるでしょう。ここでは、エラーを返すだけにします。接続を認証する戻り値は、`{:ok, socket}`です。拒否する場合には、`{:error, reply}`を返します。これで、Channelの準備は整いました。

```elixir
defmodule ChatWeb.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
end
```

Phoenixプロジェクトは、`assets/js/socket.js`にソケットを実装した簡単なクライアントが定められています。接続するには、正しいルーム名(`"room:lobby"`)に書き替えるだけです。ソケットの接続について詳しくは「[Socket Connection](https://hexdocs.pm/phoenix/js/#phoenix)」をご参照ください。

```js
socket.connect()

// Now that you are connected, you can join channels with a topic:
// let channel = socket.channel("topic:subtopic", {})  // 以下に修正
const channel = socket.channel("room:lobby", {});
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket
```

- [`connect()`](https://hexdocs.pm/phoenix/js/#socketconnect): ソケットに接続します。
- [`channel()`](https://hexdocs.pm/phoenix/js/#socketchannel): 第1引数のトピックのChannelを初期化します。第2引数はChannelに渡されるパラメータです。
- [`join()`](https://hexdocs.pm/phoenix/js/#channeljoin): Channelに接続します。戻り値は[`Push`](https://hexdocs.pm/phoenix/js/#push)です。
- [`receive()`](https://hexdocs.pm/phoenix/js/#pushreceive): 第1引数のステータスに対して、第2引数のコールバックを呼び出します。

ソケットへの接続準備が整いましたので、アプリケーションの`assets/js/app.js`に`import`します。コメントアウトされているつぎのコードを有効にしてください。

```js
import socket from "./socket"  // コメントアウトを外す
```

これで、クライアントとサーバーが接続されます。ブラウザのコンソールには、"Joined successfully"と示されているはずです。`channel()`の第1引数に渡すトピックを違う名前に書き替えれば、"Unable to join"というエラーが表示されるでしょう。


# チャットメッセージを送る
チャットができるように、ページにテキスト入力フィールドを加えます(図002)。テンプレートは、`lib/chat_web/templates/page/index.html.eex`です。もとの中身はすべて消してしまって構いません。つぎのコード001の2行に置き替えます。`<ul>`要素は、送られてきたメッセージをあとで加える場所です。

#### コード001■`lib/chat_web/templates/page/index.html.eex`
```html
<ul id="messages"></ul>
<input id="chat-input" type="text">
```

#### 図002■テキスト入力フィールドが加わったページ
![1904001_004.png](https://thepracticaldev.s3.amazonaws.com/i/03h16rl89zghr6s7xmaf.png)

`assets/js/socket.js`には、サーバーとやりとりするためのコードを以下のように書き加えます。テキスト入力フィールドで[return]/[Enter]キーが押されたら、[`push()`メソッド](https://hexdocs.pm/phoenix/js/#channelpush)でメッセージを送ります。第1引数はイベント名で、第2引数のオブジェクトが入力フィールドのテキストを納めたメッセージ本文です。

[`on()`メソッド](https://hexdocs.pm/phoenix/js/#channelon)は、メッセージを受け取ります。第1引数にやはり待ち受けるイベント名を与え、第2引数のリスナー関数で受け取ったメッセージの処理を定めます。とりあえず、`console.log()`メソッドで中身を確かめることにしました。

```js
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) => {
  console.log(payload.body);  // 確認用
});
```

Channel経由で送られてきたメッセージを扱うのはコールバック[`handle_in/3`](https://hexdocs.pm/phoenix/Phoenix.Channel.html#c:handle_in/3)です。第1引数のイベントでパターンマッチングし、第2引数にマップでメッセージ本文を受け取ります。第3引数は[`Phoenix.Socket`](https://hexdocs.pm/phoenix/Phoenix.Socket.html)です。コールバックは`lib/chat_web/channels/room_channel.ex`に以下のように定めます。

`handle_in/3`から呼び出している[`broadcast!/3`](https://hexdocs.pm/phoenix/Phoenix.Channel.html#broadcast!/3)は、リスナーにイベントを送ります。第1引数はソケット、第2引数がイベントで、第3引数は送るメッセージです。


```elixir
defmodule ChatWeb.RoomChannel do

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

ページのフィールドに入力したテキストをクライアントから送ると、`handle_in/3`が呼び出す`broadcast!/3`によりイベントとして配信されます。すると、`assets/js/socket.js`で`channel.on()`により定められたリスナー関数が呼び出されるのです。メッセージとして送られた入力フィールドのテキストがコンソールに出力されるでしょう。`lib/chat_web/channels/room_channel.ex`の中身は、つぎのコード002にまとめました。

#### コード002■`lib/chat_web/channels/room_channel.ex`
```elixir
defmodule ChatWeb.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
```

`assets/js/socket.js`のリスナー関数を完成させましょう。`channel.on()`の第2引数に渡すアロー関数式を、確認用のステートメントからつぎのコード003のように書き替えます。動的につくられた要素にメッセージ本文のテキストが加わって、ページに差し込まれ、簡単なチャットアプリケーションの動きができ上がりました(図003)。

#### コード003■`assets/js/socket.js`
```js
// Now that you are connected, you can join channels with a topic:
// let channel = socket.channel("topic:subtopic", {})  // 以下に修正
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) })
  .receive("error", resp => { console.log("Unable to join", resp) })
```

#### 図003■入力フィールドから送ったテキストがページに加わる
![1904001_005.png](https://thepracticaldev.s3.amazonaws.com/i/sxa78c0jfi9i2ja7zkyz.png)