【Phoenix】Elixir + Websocketで実装するリアルタイム通信

今回は、業務でちょこちょこ目にする機会があったのにあまり本腰を入れて勉強したことがなかったRedis, WebSocketを使ってチャットアプリを作ってみました。

1. 実装モチベーション

業務中、色々な案件での携わる中でコミュニケーションツールやデザインツールなどさまざまなものを扱う機会があるのですが、そうしたアプリケーションの中で

  • 同じページを閲覧しているユーザーが誰か分かる
  • 他のユーザーが入力中に「xxxさんが入力中...」といった文言が出る
  • 他のユーザーが入力した変更内容が、1文字単位で自ページにリアルタイム反映される

といった、リアルタイムなユーザビリティを提供しているものに出会う機会も多くあります。

今回は、そうしたアプリケーションがどのように実装されているのか、そうした実装をするためにどういった技術が必要になるのかを学ぶことを目標に据えて実装を行ってみました。

2. 今回の実装方針+実装概要

構成概要

コンテナ 概要
WebSocketサーバ Elixirで実装 FWはPhoenix
データベース PostgreSQL
I/Oが遅くても良いデータの保存用
Redisサーバ 高速なI/Oを期待するデータの保存用
フロントエンドアプリケーション Reactで実装
WebSocketクライアントとして"phoenix"パッケージを使用

実装機能概要

  • リアルタイムのチャット機能
  • 同じチャットルームにいるユーザーの表示
  • 他のユーザーの入力有無のリアルタイム表示

やりたいこと

Elixirの基本的な文法の学習と、WebSocketによるリアルタイム通信を利用したアプリケーションの実装。

やりたかったけどできなかったこと

AWSへのデプロイと、負荷分散などの学習も行いたかったのですが、今回の記事では対応できませんでした。
希望としては、Kubernetesと後述するElastiCacheを使って、リクエストの負荷に応じた自動スケーリングなどやってみたかったなぁと思っています。

3. 利用技術

WebSocket

  • サーバー↔️クライアント間での双方向通信が可能
  • リクエストのサイズが小さい
  • TCPコネクションを常時接続状態にする必要がある

HTTPと異なり通信のたびにTCPハンドシェイクを行う必要がなく、一度サーバーとのコネクションを確立するとそのコネクションで継続的に通信を行うことができます。HTTPと比較してヘッダのサイズが小さいため、1リクエストあたりのサイズが小さいのも特徴。

これにより、HTTPよりも軽量な通信を実現でき、サーバー・クライアント双方のイベントを起点として通信を行うことができます。

通常のHTTP通信

WebSocket通信

デメリットとして、一度コネクションを確立するとTCPコネクションを張りっぱなしにする必要があるため、Blocking I/Oのサーバーとは相性が悪いということが挙げられます。
(Blocking I/Oのサーバーは1クライアントとしかコネクションを張れないので、クライアントの数だけサーバープロセスが必要になる。)

ちなみに、GraphQLのSubscriptionもサーバーからクライアントに通信することができる機能として知られていますが、内部的にはWebSocketを利用しています。

その他、リアルタイム性を重視したアプリケーションを実装する際に、以下のような選択肢もあるかと思います。
アプリケーションの要件に合わせて、使い分けると良さそうですね。

ポーリング
クライアントから一定間隔でHTTPリクエストを送信し、レスポンス結果を取得する。
サーバーのイベントを起点とするわけではないので、無駄な通信が発生したり、リアルタイム性という意味ではWebSocketに劣る。
React用のnpmパッケージ"SWR"などはこの方法でデータの同期を行う実装になっている。

Firestore
GCPで利用できるNoSQL。
専用のクライアントパッケージを使って、データの更新があった際にリアルタイムに変更内容を取得できる。データの更新が前提になるので、データの保存はせずにメッセージだけ送るという使い方は(おそらく)できない(*1)

Redis

  • インメモリのKVS
  • オンディスク・データベースよりも高速なI/Oを実現できる
  • AWS ElastiCacheなどを使うと自動スケーリングができるらしい

通常、ブラウザ↔️サーバーのHTTP通信はステートレスで行われるので、リクエスト間で共通の状態を持つことができません。
そこで、Webアプリケーションであればデータベースにデータを保存することによって、データの永続化を行うのが一般的です。
しかし、MySQL等のオンディスク・データベースを用いる場合、データがHDDに保存されるため 保存・参照処理が遅くなる可能性があります。

Redisは、MySQL,PostgreSQL等のデータベースと異なり、データをRAMに保存するため高速なI/Oを実現することができます。
(ただし、RedisはRDBとは根本的に構造が異なるので、RDBに置き換わるものではありません。用途に応じて使い分けます。)

WebSocketにより高速な双方向通信を実現できても、データの保存・参照がボトルネックとなってしまう可能性があるので、今回はRedisとPostgreSQLを併用して実装しました。

Elixir

  • Erlang VM上で動作する
  • Erlangと同様に並列処理が得意
  • Rubyに近いシンタックスで書くことができる関数型言語

stackoverflowのアンケート結果で「最も愛されている言語」2位に輝いたこともある(*2)、隠れた人気者言語。
登場時期は2012年と新しめで、一言で特徴を説明するなら、「Rubyライクに書くことができる並列処理に優れた関数型言語」です。

Erlangという歴史ある言語の仮想マシン(VM)上で動作する実装となっており、Erlangの特徴となっている分散処理・耐障害性に優れた特徴を引き継いでいます。

実際、Discordなどで採用されており、秒間2,600万のWebSocketイベントを1,200万ユーザーに同時配信を実現している(*3)ことから、リアルタイム性を重視した開発に向いているということができそうです。

「Discord」は実装当時はGoで書かれていたそうなのですが、GCの実行時に非常に大きなオーバーヘッドがあることなどからElixirでの実装に切り替えられたという経緯があるそうです。(*4)
   

また、国内で有名なチャットツール「LINE」では、ユーザーの増加に伴ってリバースプロキシをNginxからErlangの実装に切り替えたという歴史もあります。(*5)

Phoenix

  • ElixirのWebアプリケーションフレームワーク
  • Channel機能を使うことでWebSocketを利用した実装が簡単にできる

ElixirでのWebアプリケーションの実装ではPhoenixというフレームワークが主流です。

Ruby on Railsに大きく影響を受けているフレームワークで、シンプルな記述で各種機能を実装することができたり、テンプレートの生成機能が豊富。

また、WebSocketの機能を抽象化したChannel機能を利用することで、WebSocketを使用したアプリケーションを簡単に実装することができます。

Channel機能では、Topicと呼ばれる単位ごとに通信をグループ化しており、クライアントから特定のTopicにメッセージを送信したりすることができます。
また、サーバーから同じTopicに参加しているクライアントに対しメッセージをブロードキャストすることによって、サーバーからクライアントへのメッセージ送信も可能となります。

たとえば、"room:*"というトピックを用意しておき、チャットルームのIDに応じてトピックを振り分けたい場合は、def join("room:" <> room_id, ...) do というような関数を定義しておくことでパターンマッチングできます。

また、クライアントからプッシュされた"join_room", "leave_room"等の各イベントに対するハンドリングは以下のように記述することができ、"broadcast!()" 関数によって、おなじTopicに参加しているクライアント全てにメッセージを送信することができます。

まとめ


・Elixir+PhoenixでWebSocketが簡単に使える
・Elixirはリアルタイム性が求められるアプリケーションに採用検討できる

今回は時間がなくてローカルでのアプリケーション開発で精一杯でしたが、引き続き、AWSへのデプロイやKubernetes, ElastiCacheを利用した自動スケーリングなどやってみたいと思います。

今回のソースコード

※ 添付予定

参考

前へ

【Django】Django REST Frameworkを使用して理想の体重計算APIを作ってみた

次へ

新人研修課題を通して学んだことと今後の展望