Amazon Web Services ブログ
Amazon ElastiCache for Redis を使ったChatアプリの開発
Sam Dengler は、アマゾン ウェブ サービスのソリューションアーキテクトです。
このブログ記事では、チャットアプリケーションに関連する概念とアーキテクチャのパターンについて説明します。また、チャットクライアントとサーバーの実装の詳細、サンプルのチャットアプリケーションを AWS アカウントに展開する方法についても説明します。
背景情報
チャットアプリケーションを構築するには、クライアントがチャットルームの他の参加者に再配信されるメッセージを送信できる通信チャネルが必要となります。この通信は、一般に publish-subscribe パターン (PubSub) を使用して実装されます。このパターンでは、メッセージが中央トピックチャネルに送信されます。関係者は、このチャンネルをサブスクライブして更新の通知を受けることができます。このパターンでは、発行者の知識なしに受信者のグループを拡大または縮小できるように、発行者と受信者を切り離しています。
PubSubは、クライアントが WebSockets を使用して通信するバックエンドサーバーに実装されます。WebSockets は、クライアントとサーバー間で双方向にストリーミングされるデータのチャネルを提供する永続的な TCP 接続です。単一サーバアーキテクチャでは、1 つの PubSub アプリケーションが発行者と受信者の状態を管理し、WebSocket を介してクライアントにメッセージを再配布することもできます。次の図は、単一サーバー PubSub アーキテクチャ上の 2 つのクライアント間でメッセージが WebSocket を通過するパスを示しています。
単一サーバーアーキテクチャは、通信フローを説明するのに役立ちます。しかし、ほとんどのソリューションビルダーはマルチサーバーアーキテクチャで設計したいと考えています。マルチサーバーアーキテクチャは、信頼性を高め、伸縮性を高め、クライアントの数が増えるにつれてアプリケーションを水平的に拡大するのに役立ちます。
マルチサーバーアーキテクチャでは、クライアントはサーバープールにトラフィックを転送するロードバランサーに対して WebSocket 接続を行います。これらのサーバーは、WebSocket 接続とそれを経由してストリーミングされるデータを管理します。WebSocket 接続が PubSub アプリケーションサーバーとの間で確立されると、その接続は永続化され、データは両方向のアプリケーションにストリームされます。ロードバランサーは、WebSocket 接続のリクエストを健全なサーバーに配信します。つまり、2 つのクライアントが異なるアプリケーションサーバーに WebSocket 接続を確立できます。
複数のアプリケーションがクライアントの WebSocket 接続を管理するため、アプリケーションはメッセージを再配布するためにそれらの間で通信する必要があります。この通信が必要なのは、メッセージが WebSocket を介して 1 つのアプリケーションサーバーにストリームアップされ、別のアプリケーションサーバーに接続されたクライアントにストリームダウンされる必要があるためです。クライアント接続を管理しているアプリケーションから PubSub ソリューションを外部に出すことで、アプリケーション間の共有通信の要件を満たすことができます。
次の図は、マルチサーバー PubSub アーキテクチャ上の 2 つのクライアント間でメッセージが WebSocket を通過するパスを示しています。永続的な接続は、各クライアントと WebSocket サーバー間のロードバランサーを通じて確立されます。また、永続的な接続は、WebSocket サーバーと PubSub サーバー間で、すべてのクライアント間で共有されるサブスクリプショントピックごとに確立されます。
カスタムの PubSub ソリューションも可能ですが、既存のソフトウェアアプリケーションを使用してこの機能を提供することもできます。Redis は、高速なオープンソースのインメモリ型データストアおよびキャッシュで、PubSub をサポートしています。Amazon ElastiCache for Redis は、Redis 対応のインメモリサービスです。使いやすく、Redis の性能を利用でき、もっとも要求の厳しいアプリケーションに対応できる可用性、信頼性、パフォーマンスを提供します。
Using ElastiCache for Redis and the WebSocket support found in the that is part of Elastic Load Balancing の一部である Application Load Balancer にある ElastiCache for Redis と WebSocket サポートを使用して、サンプルのチャットアプリケーションを構築する方法を説明します。アプリケーションには、Node.js と AWS Elastic Beanstalk に基づくバックエンドと、Vue.js ウェブクライアントがあります。サンプルアプリケーションのすべてのコードは、すべて elasticache-redis-chatapp GitHub リポジトリにあります。
アーキテクチャ
次の図は、Redis、Application Load Balancer、Node.js Elastic Beanstalk アプリケーション、および Vue.js ウェブクライアント用の ElastiCache を使用した AWS の最終的なアーキテクチャを示しています。
チャットアプリケーションの実装を高いレベルで見てみましょう。
実装の概要
サンプルチャットアプリケーションは、以下のスクリーンショットに示す共有チャットルームで通信するメンバーとメッセージで構成されています。
このサンプルアプリケーションでは、メンバー登録、プロフィール管理、ログインを拒否しています。代わりに、ブラウザでチャットアプリケーションを開くと、ユーザーの代わりにランダムなユーザー名とアバターでメンバーが生成されます。この名前とアバターは、左側のメンバーリストに表示されます。他のメンバーがブラウザでアプリケーションを開いて参加したり離したりすると、その名前がウェブアプリケーションに表示されます。メンバーは、他のウェブクライアントに再配信されメインのチャットウィンドウに表示されるメッセージを送信できます。
次に、Vue.js ウェブクライアントの詳細を調べ、Node.js バックエンドアプリケーションを確認します。
Vue.js ウェブクライアント
ウェブクライアントは、ビューレイヤを管理する Vue.js、UI 用の Bootstrap、および WebSocket 通信用の Socket.io を使用して実装されています。初心者向けに複雑さを軽減するために、JavaScript バンドルは使用していません。ただし、本稼働アプリケーションでは webpack または類似のソフトウェアを検討する必要があります。Vue.js は、基礎となるデータモデルの更新に基づいて UI の変更をレンダリングします。これらのフレームワークとライブラリを、最新の単一ページの代表的なウェブアプリケーションとして選択しました。ただし、コミュニティには多くの類似した選択肢があり、毎日多くのものが出現しています。
次に、Vue.js アプリケーションコンポーネントを設定する HTML マークアップのコードスニペットと、メンバーを表示するイテレーターを示します。機能に焦点を当てるために、いくつかの中間的なマークアップと CSS スタイルを削除しています。。完全なサンプルは GitHub リポジトリにあります。
v-for パラメータは、イテレーターを定義するために使用されます。この場合は、この後で説明するメンバーオブジェクトデータモデルのキー値タプルです。反復されるループ内で Mustache テンプレートを使用して、各メンバーオブジェクトにアクセスし、ユーザー名を表示します。Mustache テンプレートは HTML 属性内では機能しないため、メンバーのアバター画像 URL を解決するためには v-bind 引数を使用する必要があります。
Vue.js は、スマート DOM の差の計算に基づいて UI の変更を最小限にレンダリングします。このアプローチにより、基になる Vue.js データモデルの状態変更に専念できます。サンプルアプリケーションでは、HTML 内で JavaScript コードをインライン展開しています。しかし、本番稼働用システムでは、外部の .vue ファイルを使用して UI コンポーネントをモジュール化し、ビルド時に webpack を使用して変換する可能性があります。Socket.io ライブラリも初期化されており、多少カバーされています。
ここでは、Vue.js アプリケーションを宣言し、アプリケーション ID で HTML div 要素にバインドしました。また、次の 3 つのデータモデルを宣言しました。
- message: フォームに入力されたメッセージテキスト
- messages: メッセージのリスト。メッセージを追加するだけなので、配列が使用されます。
- messages: メンバーのリスト。メンバーがチャットルームを離れたときにメンバーを見つけて削除できるように、オブジェクトが使用されています。
マークアップおよび Vue.js アプリケーション宣言に加えて、ウェブクライアントは WebSocket 接続を確立し、サブスクライブするトピックを宣言し、それらのトピックに発行されたメッセージが前に宣言されたデータモデルをどのように変更するかを確立します。このアプリケーションでは、コミュニケーションのための 5 つのトピックを確立します。それぞれのトピックが、トリガーイベントと対応するアクションとともに示されます。
[メッセージ]
トリガー: メッセージがチャットルームに送信される。
アクション: メッセージテキストおよびメンバーメタデータを使用して、メッセージのリストを更新します。
[member_add]
トリガー: メンバーがチャットルームに参加する。
アクション: メンバーのユーザー名とパスワードをメンバーのリストに追加します。
[member_delete]
トリガー: メンバーがチャットルームを離れる。
アクション: メンバーの一覧からメンバーを削除します。
[message_history]
トリガー: クライアントがメッセージのリストを初期化。
アクション: メッセージのリストを、最近の履歴メッセージの切り詰められたリストとして設定します。
[member_history]
トリガー: クライアントがメンバーのリストを初期化。
アクション: メンバーリストをチャットルームに参加しているメンバーのリストとして設定します。
以下に、これらのメソッドを実装するための JavaScript コードを示します。以前の Vue.js コードをリファレンスポイントとして維持しています。
以前のコードスニペットで宣言された Socket.io オブジェクトは、前述のトピックごとに 1 つずつ、socket.on を使用してデータトピックにサブスクライブします。メッセージがトピックに発行されると、コールバック関数が実行されます。データモデルは、アクション (セット、追加、削除) とターゲットデータモデル (配列、オブジェクト) に従って更新されます。バインド (this) 文が追加され、Vue.js データモデルがコールバック関数スコープに挿入されます (詳細については、Function.prototype.bind を参照してください)。
最後は、メッセージフォームの送信を処理する Vue.jsメソッドです。Vue.js は、フォーム提出をメソッドにバインドする便利なメソッドを提供します。このメソッドは、WebSocket 上にメッセージテキストを発行し、メッセージを空の文字列に設定します。この文字列は、Vue.js バインディングを使用して UI を更新します。
Node.js バックエンドアプリケーション
ここでは、ウェブクライアントの基本について説明しました。次に、Node.js バックエンドアプリケーションについて見ていきましょう。PubSub を使ってデータを保持し、WebSocket メッセージを再配布するために Redis がどのように使用されるかを見ていきます。
Redis と WebSockets の設定
ウェブクライアントがブラウザで開かれると、PubSub アプリケーションで WebSocket が確立されます。接続すると、アプリケーションは既存のメンバーとメッセージを新しいクライアントに発行するために、いくつかのデータをアセンブルする必要があります。また、新しいチャットルーム参加者について他のクライアントを更新する必要があります。次に、HTTP アプリケーションと WebSocket の宣言を示すコードスニペットを示します。
WebSocket リスナーの作成に加えて、アプリケーションは Redis クラスタへの複数の接続も確立する必要があります。 Redis データモデルを更新してトピックにメッセージを発行するには、1 つの接続が必要です。 トピックのサブスクリプションごとに追加の接続が必要になります。
このコードスニペットでは、
Redis コマンドチャネルは ioredis JavaScript クライアントを使用して確立されています。
また関数は、新しいトピックのチャンネルを初期化するためにも定義され、
チャンルのトピックについてキー入力されたすべての受信者のハッシュに追加されます。
各サブスクリプションチャネルは同じように機能します。
- Redis サブスクリプションチャンネルで JSON 文字列メッセージを受信します。
- JSON 文字列を JavaScript オブジェクトに解析します。
- Redis PubSub と同じトピックを使用して、JavaScript オブジェクトを WebSocket 接続に発行します。
この後で説明するように、JavaScript オブジェクトは、Redis データ型の値として保存され、PubSub トピックに発行されるときに JSON 文字列にシリアル化されることが重要です。WebSocket を介して発行する前に、JSON 文字列を JavaScript オブジェクトに逆シリアル化して戻す必要があります。Socket.io ライブラリがクライアントと通信するときには、オブジェクトをシリアル化したり逆シリアル化したりするため、この逆シリアル化が生じる必要があります。
ウェブクライアントがブラウザのチャットルームに参加すると、クライアントは WebSocket への新しい接続を確立します。次のような関数を定義することによって、この接続が確立されたときにアクションを実行できます。
ソケットは、クライアントへの各 WebSocket 接続を識別するために使用される ID プロパティを含むオブジェクトです。socket.id 値を使用して、メンバーを識別します。この識別により、Redis データモデルからメンバーを見つけて削除することができます。また、member_delete トピックを使用しているすべてのチャットルームクライアントにメンバーの削除を伝えることもできます。以下で説明する他の関数は、このコールバック関数のコンテキストにあります。
次のセクションでは、新しいクライアントが WebSocket を介して Node.js バックアップアプリケーションに接続すると何が起こるかを見ていきます。
新しいクライアント接続の初期化
新しいクライアントがチャットルームに参加すると、いくつかのことが起こります。
現在のメンバーリストが取得されます。
これがクライアントの再接続でない限り、ランダムなユーザー名とアバター URL で新しいメンバーが作成され、Redis Hash に保存されます。
最近の履歴メッセージの切り詰められたリストが検索されます。
これらのタスクを達成するためのコードを見てみましょう。まず、次のコードがあります。
ioredis JavaScript クライアントは、非同期実行処理で Promises を使用します。HGETALL (‘members’) 呼び出しは、キー ‘members’に格納されているハッシュのすべてのキーと値を返します。Redis はハッシュデータ型をサポートしていますが、1 レベルの深さしかありません。ハッシュの値は文字列でなければなりません。コールバック関数は、次のチェーンで逆シリアル化されたハッシュのキーと値のペアを反復して、メンバーを初期化します。
initialize_member Promise 関数は、メンバーが再接続ソケットであるかどうかをまずチェックします。再接続ソケットでない場合は、Faker を使用してランダムなユーザー名で新しいメンバーが生成されますこのユーザー名から、Adorable Avatars サービスを使用してランダムなアバター URL が生成されます。
クライアント初期化の最後のステップは、過去の最近のメッセージの切り捨てられたリストを取得することです。これを行うには、ソート対象セットと呼ばれる別の Redis データ型を利用することができます。このタイプは Redis セットに似ていますが、セット内の各要素のランクを含みます。ソート対象セットは、リーダーボードの一般的なデータ型です。タイムスタンプがランクとして使用されている場合は、時間順に並べられた要素のコレクションを格納するためにも使用できます。
We use a Redis method on the Sorted Set, called ZRANGE というソート対象セットでは、ランクに基づいて要素のリストを返す Redis メソッドを使用します。要素は最低スコアから最高スコアまで並べられます。したがって、初期化時に取得するメッセージの最大数 (-1 * channel_history_max) まで最後の要素 (-1) を取得する必要があります。繰り返しになりますが、各要素は JSON 文字列としてシリアル化されていますので、要素を JavaScript オブジェクトに対して逆シリアル化する必要があります。
要約すると、新しいクライアントがチャットルームに参加すると、いくつかのことが起こります。
- 現在のメンバーリストが取得されます。
- これがクライアントの再接続でない限り、ランダムなユーザー名とアバター URL で新しいメンバーが作成され、Redis Hash に保存されます。
- 最近の履歴メッセージの切り詰められたリストが検索されます。
これらの手順をそれぞれを確認しました。ここで、初期化とクライアントへのストリームデータの完了方法を見ていきましょう。ioredis は Promise を使用するため、非同期の実行を連鎖させて、すべてが完了するのを待ってから Promise.all を使用して結果を処理できます。
これですべての必要なデータが得られたので、WebSocket 接続を使用してデータをストリーミングして新しいクライアントを初期化し、新しいメンバーがチャットルームに参加したことをすべてのメンバーに伝える必要があります。
Socket.io の emit メソッドを使用して、初期化中のクライアントにメッセージとメンバーのリストをストリーミングします。1 つの WebSocket を使用して複数のメッセージを送信できます。ここでは、トピック (member_history, message_history) は先ほどのクライアントコードでレビューしたトピックリスナーに対応しています。新しいメンバーはすべての参加者に伝えなければなりません。これを行うには、Redis コマンドチャネルを使用して、シリアル化された JSON 文字列を member_add トピックに発行します。すでに行っているように、WebSocket を使用して同じトピックをリッスンしているクライアントにメッセージを再配布するため、3 つの Redis トピックを設定しました。
次のセクションでは、チャットルームで送信されたメッセージを処理するハンドラを設定する方法を見ていきましょう。
メッセージの処理
新しいクライアントが初期 WebSocket 接続を完了すると、新しいクライアントによって送信されるメッセージ用のメッセージハンドラも定義する必要があります。メッセージは、メッセージテキスト、メンバーのユーザー名、メンバーのアバター、メッセージの作成タイムスタンプで構成されます。
ZADD コマンドとメッセージ作成タイムスタンプをランクとして使用して、ソート対象セットに格納されたメッセージ履歴にメッセージを追加します。最後に、Redis コマンドチャネルを使用してメッセージトピックを発行しました。Redis/WebSockets の再配布は以前に定義しています。
クライアントを初期化し、チャットルームで送信されたメッセージを処理する方法について説明しました。最後に、クライアントがチャットルームを離れるときの対処方法を見てみましょう。
切断処理
Socket.io は、クライアントがサーバーに接続するときに確立された WebSocket のハートビートを作成します。ハートビートが失敗すると、クライアントで切断イベントが発生します。
クライアントが切断すると、メンバーの初期化中に実行されたアクションが取り消されます。まず、Redis HDEL メソッドを使用して、クライアントの WebSocket ソケット ID を使用するメンバーハッシュ Redis データ型からクライアントを削除します。同じメソッドをクライアントをハッシュに追加するのに使用します。すべての参加者に対してチャットルームに加わった新しいメンバーを通知するように、メンバーがチャットルームを離れることをすべての参加者に通知する必要があります。これは、member_delete Redis トピックを使用して行います。このトピックは、WebSocket を使用して残りのクライアントに再配布されます。
これでコードの確認は完了です。次に、AWS CloudFormation を使用してアプリケーションスタックを AWS にデプロイする方法を確認します
AWS CloudFormation を使用したアプリケーションスタックのデプロイ
CloudFormation は、開発者やシステム管理者は、関連する AWS リソースのコレクションを簡単に作成および管理する方法を提供します。CloudFormation は、整った予測可能な方法でリソースを提供し、更新します。チャットアプリケーションの CloudFormation スタックを起動するには、次のボタンをクリックします。
CloudFormation スクリプトは、Elastic Beanstalk 環境、アプリケーション、および構成テンプレートを作成します。Redis の ElastiCache クラスタと、ロードバランサ、アプリケーションサーバー、Redis クラスタの Amazon EC2 セキュリティグループも作成します。このようにして、アーキテクチャレイヤ間の最小権限セキュリティ構成にベストプラクティスを使用します。
ElisiCache for Redis の設定スニペットに関する 1 つの注意点AWS::EC2::SecurityGroup では進入セキュリティルールのインライン指定が可能ですが、そうすることで、CacheCluster と SecurityGroup の間に循環参照が作成されてしまいます。次のスニペットに示すように、進入ルールを別の AWS::EC2::SecurityGroupIngress に分割して循環参照を破棄する必要があります。
リソース:
次に、WebSocket をサポートするための Elastic Beanstalk Nginx プロキシ設定の設定を変更する方法を確認してみましょう。
WebSocket サポートのための AWS Elastic Beanstalk の Nginx 設定
AWS Elastic Beanstalk は、Java、.NET、PHP、Node.js、Python、Ruby、Go および Docker を使用して開発されたウェブアプリケーションやサービスを、Apache、Nginx、Passenger、IIS など使い慣れたサーバーでデプロイおよびスケーリングするための、使いやすいサービスです。
Elastic Beanstalk は、Elastic Load Balancer (ELB) と Application Load Balancer (ALB) の両方をサポートします。弊社のクライアントとサーバーは WebSocket を使用して通信するので、WebSockets サポートのための ALB を構成しますサンプルの Node.js バックエンドアプリケーションでは、Node.js ベースの Elastic Beanstalk の事前構成済みアプリケーションスタックを選択します。アプリケーションコードの前で、ウェブ層プロキシとして Nginx を使用します。
WebSocket がサポートされていない場合、Socket.io にはポーリング戦略に戻る手段があります。ただし、単純に ALB と Nginx に設定を変更することで、WebSocket のサポートを有効にし、サーバーからクライアントへのプッシュベースのデータストリームを使用することができます。ALB で WebSocket サポートを有効にするには、クライアントが 2 つの連続した HTTP リクエストを行って接続をアップグレードして Websocket を使用したときに、同じインスタンスが応答するように、スティッキーセッションを有効化する必要があります。Nginx で WebSocket サポートを有効にするには、Elastic Beanstalk の .ebextensions メカニズムを使用して、Nginx の設定を少し変更する必要があります。コンテナコマンドは、アプリケーションアーカイブが展開された後、アクティブアプリケーションとしてインストールされる前に、アプリケーションに変更を導入する方法を提供します。
前述のコードスニペットは、Nginx 設定ファイルを所定の位置で変更します。/tmp/deployment/config/#etc#nginx#conf.d#00_elastic_beanstalk_proxy.conf。これは、sed コマンドを使用して「プロキシセットヘッダー」行を検索し、WebSocket をサポートする設定に置き換えます。アプリケーションがインストールされると、Elastic Beanstalk は設定ファイルを /etc/nginx/conf.d/00_elastic_beanstalk_proxy.conf にコピーします。この手順を実行すると、Nginx サービスが再起動され変更がアクティブになります。
まとめ
このブログ記事では、publish-subscribe パターンについて見てきました。また、それを ElastiCache for Redis 内で使用して、チャットアプリケーションの複数のクライアントの双方向ストリーミング通信をサポートする方法についても説明しました。
リマインダーとして、awslabs GitHub リポジトリにこのサンプルアプリケーションの完全なソースがあります。このサンプルアプリケーションを起動したら、ユーザー認証、添付ファイル、または自分のチャットや PubSub アプリケーションに役立つその他の機能を追加して、チャットアプリケーションに独自のアイデアを組み込んで拡張することをお勧めします。