Websocketを活用したチャットサービスの設計

Websocketを活用したチャットサービスの設計

1. はじめに

私は、複数の人が一つのチャットルームに集まり、メッセージをやり取りしたり写真を共有したりするリアルタイムチャットサービスを開発しています。一般的に使われているメッセンジャーのように、更新ボタンを押さなくても相手が送ったメッセージがすぐに自分の画面に表示されるサービスです。

リアルタイムチャットを作る際に、最初に直面する悩みは、「誰かがメッセージを送ったとき、同じ部屋にいるほかの人たちへ、どうすればすぐに知らせられるのか」ということです。一つの部屋に10人いて、そのうち1人がメッセージを送ったら、残り9人の画面にほぼ同時にそのメッセージが表示されなければなりません。

最も単純に思いつく方法は、「メッセージ本文をそのまま9人にリアルタイムで送ること」です。しかし、私たちはこの方法を選びませんでした。代わりに、メッセージ本文ではなく「何かが起きた」という軽い通知だけを送り、実際の内容はクライアントが再度取得するように設計しました。

この記事では、なぜこのような選択をしたのか、どのように適用したのか、その過程でどのような問題に遭遇し、どう解決したのかを共有したいと思います。

2. その前に、WebSocketとは何でしょうか

本題に入る前に、リアルタイム通信の基盤となるWebSocketについて、簡単に確認しておきます。

私たちが普段ウェブサイトを利用するときに使う一般的な通信方式は、手紙をやり取りすることに似ています。知りたいことがあるたびに、サーバーへ手紙、つまりリクエストを送り、サーバーはそれに対する返事、つまりレスポンスを返します。そして一度やり取りが終わると、その会話はそこで終了します。次にまた知りたいことが生じたら、新しい手紙を書いて送らなければなりません。

この方式の限界は、サーバーから先に私たちへ話しかけることができない点です。こちらから尋ねなければ、サーバーは新しい知らせがあっても伝える方法がありません。

そのため、リアルタイムチャットでこの方式を使うには、クライアントが「新しいメッセージはありますか?」と絶えず尋ねなければなりません。宅配便を待ちながら、1分ごとに玄関のドアを開けて確認するようなものです。新しいメッセージがなくても尋ね続ける必要があるため無駄が大きく、確認間隔の分だけ遅延も発生します。

一方、WebSocketは電話の通話に近いものです。一度電話をかけて接続されると、切断するまで通話が維持されます。その状態では、自分から先に話すことも、相手が先に話すこともできます。つまり、サーバーに新しい知らせが生じたとき、クライアントが尋ねなくてもサーバーから先に知らせることができるのです。

このように接続を開いたまま、双方が自由にデータをやり取りできる通信方式がWebSocketであり、リアルタイムチャットのように「サーバーから先にユーザーへ知らせる必要がある」サービスに適しています。

私たちはこのWebSocketの上に、「どのアドレスへ送信し、どのアドレスを購読するか」という取り決めを標準化したSTOMPというメッセージングプロトコルを重ねて使用しました。電話回線が敷かれているなら、STOMPは「誰に、どのような内容を、どのように伝えるか」という通話マナーのようなものだと考えると分かりやすいでしょう。

3. 技術選定:本文ではなく「通知」だけを送る

3.1. 接続時に、まず身元を確認する

チャットの内容は機密情報である可能性があるため、誰でも接続できるようにするわけにはいきません。そこで、WebSocket接続が最初に確立される瞬間に、ユーザーのトークンを検査するようにしました。トークンがない、形式が正しくない、または認証サーバーによる検証に通らない場合、その接続は直ちに遮断されます。

ここには、設計上の選択が一つありました。一般的なリクエストでは、通常サーバー手前のセキュリティフィルターで認証を遮断します。しかしWebSocketは、一度接続すると継続的に維持される通路です。そのため、毎回検査するよりも、接続を確立するまさにその瞬間に一度確実に検査するほうが、より自然で効率的でした。

先ほどの電話通話のたとえで言えば、通話を始めるときに相手が誰なのかを確認してから接続するのと同じです。

3.2. 重要な決定:本文ではなく「通知」を送る

ここで最も重要な設計上の決定について説明します。誰かがメッセージを送ったとき、私たちはそのメッセージ本文全体を同じ部屋の人々に配信しません。代わりに、「この部屋に新しいメッセージが登録された」という信号だけを送ります。その信号を受け取ったクライアントは、その時点で初めて、普段どおりの一般的な取得方式で新しいメッセージを取得し、画面に表示します。

たとえるなら、一般的なメッセンジャーのプッシュ通知に似ています。プッシュ通知自体には「○○さんがメッセージを送信しました」という短い信号だけが含まれており、私たちがアプリを開いたときに初めて実際の会話内容を読み込むのと同じ方式です。

最初は、「せっかくリアルタイムで送るのなら、本文も一緒に送れば一度で済むのではないか?」と考えるかもしれません。私自身も直感的には、そのほうが単純に見えました。しかし、通知だけを送る方式には明確な利点があり、検討の結果、この方式を採用しました。

第一に、ソケットを流れるデータが軽量になります。写真が添付された大きなメッセージであっても、長いテキストであっても、ソケット上を流れるのは「新しいメッセージがあります」という小さな信号だけです。そのおかげで、多くの人が同時に会話するときも、リアルタイム通信の経路にかかる負荷を抑えられます。

第二に、データの正確性と権限処理を一つの経路に集約できます。実際のデータは常に、検証済みの取得経路を通じてのみ返します。もしソケットでも本文を返すと、ソケット経路と一般的な取得経路の2か所から、異なる形式のデータが送信される可能性があります。その結果、管理が複雑になり、権限検査などのロジックも2か所で重複して考慮しなければなりません。

第三に、クライアントが必要なものだけを取得できます。通知には「何が変わったのか」だけが含まれるため、クライアントは現在表示している画面の状態に応じて、本当に必要なデータだけを再取得できます。例えば、その部屋を表示していない場合はメッセージ本文を取得せず、「未読表示」だけを更新することもできます。

4. 適用の過程:イベントの種類を定義する

4.1. 通知には「何が起きたのか」だけを含める

通知でやり取りするデータには、メッセージ本文全体を含めません。代わりに、どの部屋で、どの対象について、何が起きたのかという程度の情報だけを含めます。具体的には、送信者、部屋識別子、影響を受けた対象の識別子、そしてイベントの種類などです。

ここで重要なのが「イベントの種類」です。私たちは、チャットで発生しうるイベントをあらかじめ明確に分類しておきました。例えば、次のようなものです。

  • 新しいメッセージが登録された場合
  • メッセージを読んだ場合
  • メッセージを編集または削除する場合
  • メッセージを固定したり、返信したり、絵文字でリアクションしたりする場合
  • 部屋に招待したり、部屋を作成または削除したり、部屋から退出したりする場合

このようにイベントの種類を十数種類に分け、あらかじめ定めた値だけで表現するようにしました。この方式の利点は、サーバーとクライアントが「このようなイベントが起きたら、このように処理する」という取り決めを明確に共有できる点です。

通知に任意の文字列を含めるのではなく、あらかじめ定義したイベントの種類だけを使用するため、誤字や漏れによる混乱を減らせます。

代表的な流れは次のとおりです。あるユーザーがメッセージを送ると、サーバーは同じ部屋の人々へ、「新しいメッセージが登録された」というイベントの種類と、その部屋の識別子、新しいメッセージの識別子だけを含む通知を送ります。通知を受け取ったクライアントは、「この部屋に新しいメッセージが届いたのだな」と判断し、その部屋のメッセージを取得して画面を更新します。

4.2. 「既読」処理も同じ通知で流す

この通知構造の利点は、新しいメッセージだけでなく、ほかの状態変化にも同じように適用できる点です。例えばユーザーがメッセージを読むと、サーバーはそのユーザーが最後に読んだ位置を更新した後、同じ部屋に「メッセージを読んだ」という通知を送ります。すると、ほかの人の画面でも「既読」表示が同じ仕組みでリアルタイムに更新されます。

ここで一つ注意したのは、すでに読んだ位置より後ろまで読んだ場合にのみ更新し、通知を送るという点です。ユーザーが以前のメッセージをもう一度見ても、既読位置が後戻りしないようにするためです。

結果として、新しいメッセージであれ、メッセージの編集であれ、既読であれ、すべての変化が「通知を送る → クライアントが再度取得する」という一貫したパターンに統一されました。新しい種類のイベントが発生しても、同じ枠組みに組み込むだけでよいため、機能を拡張していくのも容易でした。

5. オンラインユーザーにのみ送る

ソケット通知は、現在接続していて接続を開いたままにしている人に対してのみ意味があります。先ほどの電話通話のたとえで言えば、電話を切っている人にいくら話しかけても聞こえないのと同じです。そのため、通知を送る直前に、対象となる各ユーザーが現在接続中かどうかをまず確認します。

サーバーは、誰が現在接続されているかという情報を保持しています。そのため通知を送る際には、その一覧を参照し、接続中のユーザーにのみ信号を届けます。接続していないユーザーにソケットで送っても受け取る人がいないため、不要な信号を流さないように除外するのです。

このように「送る価値のある対象」をあらかじめ絞り込む処理は、人の多い部屋ほど効果を発揮します。一つの部屋に多くの人がいても、実際に接続中なのは一部だけかもしれません。接続者だけを選んで送ることで、無駄な送信を減らせるからです。

6. 遭遇した問題と解決策:部屋を退出するとき、誰に知らせるのか

通知構造を適用する中で、実際に直面した問題を一つ共有します。

ほとんどの通知は、「この部屋の現在の全メンバー」に送れば済みます。そのため最初は、通知を送るたびに部屋識別子で現在のメンバー一覧を取得し、その人たちに送るように実装しました。新しいメッセージ、メッセージの編集、既読処理などは、すべてこの方式で問題なく動作しました。

しかし、部屋を削除したり、部屋から退出したりする場合に問題が発生しました。部屋が削除されたり、あるユーザーが部屋を退出したりすると、その処理が完了した時点では、すでにメンバー情報が整理されて消えた後です。その状態で「現在のメンバー一覧」を取得すると、本来「あなたが部屋から退出しました」「この部屋は削除されました」と知らせるべき対象を見つけられない状況が発生しました。

通知を送ろうとしたところ、受け取る人がすでに一覧から外れていたのです。

原因を詳しく調べてみると、通知対象を取得するタイミングと、メンバー情報が消えるタイミングがずれていたことが問題でした。普段は「今この部屋に誰がいるのか」をその都度取得すれば十分でしたが、部屋を退出したり削除したりする出来事は、その取得結果自体を変えてしまうものだったためです。

解決のため、出来事の種類に応じて通知対象を決める方法を二つに分けました。部屋の削除や退出のようにメンバー情報が整理される出来事の場合は、メンバーが消える前にあらかじめ確保しておいた対象リストをそのまま使って通知を送るようにしました。一方、新しいメッセージや編集などの一般的な出来事については、従来どおり現在のメンバーを取得して送信します。

つまり、「現在の状態を取得して送るのか、消える前に確保しておいた対象へ送るのか」を、出来事の性質に応じて分けたということです。

この経験を通じて、「誰に通知を送るか」を決める作業は、単に現在の状態を見るだけでは不十分だということを学びました。出来事の性質に応じて、データが消える前と後のどちらの時点の情報を使うべきかも、あわせて考える必要があるということです。

前もって出来事の種類を明確に分類しておいたおかげで、このように出来事ごとに異なる処理をきれいに分けられたことも大きな助けになりました。

7. 結果と振り返り

この設計を適用した結果、ソケットでは「何が起きたか」という軽量なシグナルだけが流れ、実際のデータの正確性は検証済みの取得経路が担保する構造に整理されました。通知の種類が増えても、新しい出来事をあらかじめ定義した分類に追加し、同じ送信方式を再利用すればよいため、拡張もシンプルです。

最も大きな学びは、「リアルタイム = すべてのデータをリアルタイムでプッシュする」という考えが、必ずしも正しいわけではないということです。むしろ、「変化はすばやく通知し、データは信頼できる経路から取得させる」というアプローチのほうが、よりシンプルで堅牢でした。リアルタイム通信経路と通常の取得経路が、それぞれ得意なことに集中できるよう、役割を分担したというわけです。

もちろん、まだ改善すべき点も残っています。たとえば、現在の接続者情報を判断する方法は1台のサーバーを前提としているため、サーバーを複数台に増やして運用する場合には、接続情報をサーバー間で共有する仕組みを追加する必要があります。

リアルタイムチャットという、一見すると単純そうな機能一つにも、「何を、いつ、誰に送るのか」という判断が緻密に絡み合っていることを、実際に作ってみて身をもって実感できました。この記事が、同じような悩みを抱えている方々にとって、少しでも参考になれば幸いです。

messi

Site footer