loading...
gumi TECH Blog

Elixir: PhoenixでWebsocketをつないで簡単なチャットアプリケーションをつくる

gumitech profile image gumi TECH ・3 min read

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

Phoenixプロジェクトをつくる

Phoenixのインストールについては、公式サイトの「Installation」をお読みください。まずは、mix phx.newコマンドでPhoenixプロジェクトをつくります(プロジェクト名chat)。--databaseオプションはEctoに用いるデータベースアダプタの定めです。今回はmysqlとしました(デフォルト値はpostgres)。なお、このチャットアプリケーションでは、データベースの機能は使いません。

mix phx.new chat --database mysql

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

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)。

cd chat
mix ecto.create
mix phx.server

図001■Phoenixプロジェクトのひな形ページ

1904001_001.png

channelを使う

Phoenixプロジェクトのlib/chat_web/endpoint.exには、つぎのようにPhoenix.Endpointビヘイビアを用いてすでにエンドポイントが加えられています。

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ビヘイビアが使われています。このモジュールで、メッセージを適切なChannelにルーティングしなければなりません。そのために、channel/3が呼び出されるコードのコメントアウトを外してください。

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ビヘイビアを用い、クライアントがトピックに接続することを認証しなければなりません。認証のために実装するのが、join/3です。

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

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」をご参照ください。

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(): ソケットに接続します。
  • channel(): 第1引数のトピックのChannelを初期化します。第2引数はChannelに渡されるパラメータです。
  • join(): Channelに接続します。戻り値はPushです。
  • receive(): 第1引数のステータスに対して、第2引数のコールバックを呼び出します。

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

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

<ul id="messages"></ul>
<input id="chat-input" type="text">

図002■テキスト入力フィールドが加わったページ

1904001_004.png

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

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

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です。第1引数のイベントでパターンマッチングし、第2引数にマップでメッセージ本文を受け取ります。第3引数はPhoenix.Socketです。コールバックはlib/chat_web/channels/room_channel.exに以下のように定めます。

handle_in/3から呼び出しているbroadcast!/3は、リスナーにイベントを送ります。第1引数はソケット、第2引数がイベントで、第3引数は送るメッセージです。

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.jschannel.on()により定められたリスナー関数が呼び出されるのです。メッセージとして送られた入力フィールドのテキストがコンソールに出力されるでしょう。lib/chat_web/channels/room_channel.exの中身は、つぎのコード002にまとめました。

コード002■lib/chat_web/channels/room_channel.ex

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

// 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

Posted on by:

gumitech profile

gumi TECH

@gumitech

gumi TECH は、株式会社gumiのエンジニアによる技術記事公開やDrinkupイベントなどの技術者交流を行うアカウントです。 gumi TECH Blog: http://dev.to/gumi / gumi TECH Drinkup: http://gumitech.connpass.com

gumi TECH Blog

株式会社gumiのエンジニアによる技術記事を公開しています。

Discussion

markdown guide