チャットの双方向通信の仕組み
はじめに
この記事は、大きく2つの読者に向けて書いています。前半は非エンジニアの方にも読める説明、後半は実装者向け(Go)の内容です。
「SlackやLINEのようなチャットアプリは、なぜメッセージが即座に届くのか?」気になっていても、仕組みまでは分からないという人は多いと思います。この記事はそういう人向けに、HTTPとWebSocketの違いを図解しながら書き始めています。途中からは前回のポストで触れた、個人開発の匿名チャットをGoで実装した具体的な話に変わるので、その認識でお読みください。
普通のWebページはHTTPのリクエスト/レスポンスで動いていますが、チャットのようなサーバーからのプッシュはHTTPだけでは実現が難しいです。この記事では、HTTPとWebSocketの仕組みの違いを図解しつつ、個人開発で作った匿名チャットの実装を題材に「どう使い分けたか」を書きます。
題材にするサービスの前提を先に置いておきます。ここが違うと設計判断も変わるからです。
| ルームの人数 | 2人か3人(トピックごとにマッチング) |
| 同時接続の上限 | 1,000(アプリ層でハードキャップ) |
| サーバー | 小さいインスタンス2台(Goのプロセスが1台に1つ) |
| 言語 / ライブラリ | Go / gorilla/websocket |
| 補助 | Redis(Pub/Sub・在室管理・待機キュー)、PostgreSQL |
この記事の内容は次のとおりです。
- HTTPの通信モデル:リクエスト/レスポンス
- WebSocketの通信モデル:双方向・常時接続
- 比較表:HTTP vs WebSocket
- 実装:匿名チャットではどう分けたか
- 使い分けの判断基準
- まとめ
- 参考
HTTPの通信モデル:リクエスト/レスポンス
HTTPは基本的に「クライアントがリクエストを送る → サーバーがレスポンスを返す」通信です。手紙のやり取りのようなもので、こちらから出さないと返事は来ません。
図の読み方: 矢印はすべて「クライアント → サーバー」から始まっています。サーバーからクライアントへの矢印は、必ずリクエストへの「返事」です。
- 通信は常にクライアント起点。サーバーから勝手にデータを送れない
- ステートレス。各リクエストは独立していて、サーバーは前のリクエストを覚えていない
- リクエストのたびにヘッダー(認証情報やCookieなど、数百バイト〜数KB)を送る
HTTPでリアルタイムっぽくやる方法(と限界)
| 方式 | 仕組み | 問題点 |
|---|---|---|
| ポーリング | 定期的にGET /messagesを繰り返す | 新着がなくてもリクエストが飛ぶ。間隔分だけ遅延する |
| ロングポーリング | 新着があるまでレスポンスを保留する | 接続が滞留する。タイムアウト処理が複雑 |
「相手が送った瞬間に届く」体験をこれらで作るのは、効率・遅延・実装の複雑さのすべてで厳しくなります。
WebSocketの通信モデル:双方向・常時接続
WebSocketは、一度コネクションを確立したら双方向にいつでもメッセージを送れる状態を維持するプロトコルです(RFC 6455)。手紙ではなく「繋ぎっぱなしの電話」です。
ハンドシェイクは普通のHTTPリクエストから始まる
GET /ws/rooms/0193f0a1-... HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
クライアントがUpgrade: websocketで切り替えを要求し、サーバーが承諾すると101 Switching Protocolsを返します。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
図の読み方: ハンドシェイク後はS->>Cの矢印がクライアントのリクエストなしで飛んでいます。ここがWebSocketの本質です。
ハンドシェイク後の通信はバイナリの「フレーム」になり、ヘッダーは最小2バイトです。毎回数百バイトのヘッダーを送るHTTPとの差が、頻繁なやり取りで効いてきます。
比較表:HTTP vs WebSocket
| 比較項目 | HTTP | WebSocket |
|---|---|---|
| 通信方向 | クライアント → サーバー | 双方向 |
| 接続 | リクエストごとに確立・切断 | 一度確立したら維持 |
| サーバーからのプッシュ | 不可(ポーリング等で代替) | 標準で可能 |
| ヘッダーオーバーヘッド | 毎回数百バイト〜数KB | 初回のみ。以降は2〜14バイト |
| 状態 | ステートレス | ステートフル(接続を保持) |
| 実装・運用の難易度 | 低い | 高い(再接続・スケーリング等) |
| 向いている用途 | CRUD、ページ表示 | チャット、通知、ライブ更新 |
実装:匿名チャットではどう分けたか
ここからは実際に作ったものの話です。今回工夫した設計を、各節で説明します。
HTTPとWebSocketの分かれ方
「全部WebSocket」にはしていません。エンドポイントは実際にこう分かれました。
| 機能 | エンドポイント | 方式 |
|---|---|---|
| 匿名セッションの発行・延長 | POST /api/session | HTTP |
| 待機キューに入る / 状態確認 / 離脱 | /api/matching | HTTP |
| トピック一覧 | GET /api/topics | HTTP |
| ルーム内の会話 | GET /ws/rooms/{roomID} | WebSocket |
WebSocketは1本だけです。マッチングの待機中はHTTPのポーリングで済ませています。成立を待つだけなら数秒の遅延が許容できるからで、ここでWebSocketを張ると、まだ会話もしていない待機中の全員分の接続を抱えることになります。
もう一点、一般的なチャットと違うのはメッセージ履歴の取得APIが無いことです。メッセージはDBに保存していますが、目的は通報対応と開示請求への備えで、参加者に見せる履歴画面がありません。結果として「HTTPで履歴、WebSocketで新着」というよくある構成にはならず、会話に関する通信はWebSocketだけになりました。
ハンドシェイクで何を確認しているか
WebSocketの認証は、接続後にヘッダーを送れないのでハンドシェイクのHTTPリクエストで行います。実装ではこの順番になっています。
func (h *Handler) handleRoomSocket(w http.ResponseWriter, r *http.Request) {
// 1. Origin
if !h.checkOrigin(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// 2. 同時接続数
release, err := h.acquire(r)
if err != nil {
writeCapacityError(w, err)
return
}
defer release()
// 3. セッション
token, err := h.sessions.Authenticate(r)
...
// 4. この会話の参加者か
adm, err := h.svc.Admit(r.Context(), r.PathValue("roomID"), token)
...
// 5. ここで初めて 101 Switching Protocols
ws, err := h.upgrader.Upgrade(w, r, nil)
h.svc.Serve(r.Context(), ws, adm)
}
順番それぞれに理由があります。
- Originを最初に見る: WebSocketにはCORSが効かないためです(次項)。
- 接続数のカウントを認証より先に置く: 上限に達して断る接続は、Redis1コールだけで断れます。
- 参加者チェック(
Admit): 自分が入っていない会話IDを叩かれても繋がせません。存在しない会話と他人の会話は、どちらも同じ403で返します。区別できると「その会話が存在するか」が漏れるためです。
なぜOriginを最初に見るのか
WebSocketには、HTTPのCORS(同一オリジンポリシー)が効かないからです。
fetch()で他サイトのAPIを叩くとCORSに阻まれますが、new WebSocket(...) にその仕組みはありません。どのサイトのページからでも接続を張れてしまいます。
// 攻撃者のページに置かれたコード。CORSに阻まれない
const ws = new WebSocket("wss://our-chat.example/ws/rooms/0193f0a1-...");
ws.onmessage = (e) => { /* 会話の中身が読める */ };
ws.send("..."); // なりすまして発言もできる
できることは2方向あります。
- 読み(
ws.onmessage→fetchで転送): ルームの会話が攻撃者のサーバーに流れます。匿名チャットなので身元までは出ませんが、会話の中身そのものが漏れます。同じルームの相手には、盗み見られていることを知る手段がありません。 - 書き(
ws.send): 被害者の接続から発言できます。他の参加者からは被害者が言ったこととして表示されるので、荒らしや、規約違反の発言をさせて被害者を通報対象にする、といった使い方ができてしまいます。
ここで重要なのは、攻撃者はCookieを盗む必要がないことです。Cookieの値は攻撃者からは読めません(HttpOnlyなのでJavaScriptからも読めない)。にもかかわらず接続が通ってしまうのは、接続先ドメイン宛のリクエストにはそのドメインのCookieを自動で付ける、というブラウザの仕様のためです。攻撃者のページは「our-chat.exampleに繋げ」と指示するだけで、認証情報は被害者のブラウザが勝手に添えます。サーバー側から見ると、正規の利用者本人が普通に接続してきたのと区別がつきません。他人のCookieを手に入れているのではなく、被害者自身のブラウザの中で、被害者自身のCookieが使われているわけです。CSRFと同じ構図で、WebSocket版なのでCross-Site WebSocket Hijackingと呼ばれます。
誤解しやすいのですが、攻撃者が自分のブラウザから被害者になりすまして入ってくるわけではありません。攻撃者はCookieの値を持っていないので、自分の環境では同じ接続を作れません。接続を張っているのは常に被害者のブラウザで、攻撃者のスクリプトは受け取った会話を自分のサーバーへ転送しているだけです。したがって、この攻撃が成立するのは被害者が攻撃者のページを開いている間だけで、そのタブを閉じれば接続も終わります。(トークンそのものを盗まれてなりすまされるのは、XSSなどの別のリスクです。CookieをHttpOnlyにしているのはそちらへの対策です)
fetch()との違いもここに出ます。CORSは「リクエストは飛ぶが、レスポンスをJavaScriptから読ませない」仕組みです。WebSocketにはそれが無いので、返ってきた会話の中身をそのまま読めてしまいます。
実際には、防御は3枚ある
ただし、今回の実装でこの攻撃が単独で成立するわけではありません。
| # | 防御 | 効き方 |
|---|---|---|
| 1 | セッションCookieがSameSite=Lax | 他サイトのページから張る接続にはCookieが付かない |
| 2 | 会話IDが推測できないUUID | 攻撃者は接続先のURLを知れない |
| 3 | Originチェック | 自分のフロントエンド以外からの接続を403で切る |
それでも3枚目を置くのは、1枚目が消える構成があり得るからです。フロントエンドを別のオリジンから配信する場合、CookieはSameSite=Noneにせざるを得ません。その瞬間、1枚目の防御は無くなります。OriginチェックはCookieの設定に依存しないので、そこでも残ります。
Originはブラウザが付けるヘッダーで、ページのJavaScriptからは書き換えられません。自分のフロントエンドのオリジンと一致しなければ、101 Switching Protocolsを返す前に403で切ります。
順番の理由も一つあります。この後に来るセッションの検証は、有効なセッションの期限を延長します。Originチェックを後ろに置くと、攻撃者のページを開いているだけで他人のセッションが延命され続けます。だから「まずOrigin、それから認証」の順です。
上限に達したときのレスポンスは、サービス全体が満杯なら503、そのIPが張りすぎなら429と分けています。クライアントから見て「混んでいる」のか「自分のせい」なのかが区別できるようにするためです。どちらにもRetry-After: 10を付けています。
接続1本を、3つのgoroutineで持つ
WebSocketの接続が確立すると、その接続1本に対して3つのループが並行して回りはじめます。
書き込みをwriteLoopに一本化しているのには理由があります。gorilla/websocketは1つの接続に同時に1つのWriterしか許しません。2本以上のgoroutineが同じ接続に同時にWriteMessageすると、送りかけのフレームに別のフレームが割り込んで壊れます。そこで送信内容はすべてチャネル経由でwriteLoopに渡し、実際にソケットへ書くのはこの1本だけにしています。
writeLoopは3つのことを同時に待ちます。
select {
case msg := <-c.out: // ルームからイベントが来た
...
case <-ping.C: // 30秒ごとの ping
...
case <-c.done: // 接続が終了した
return
}
配信は「ルーム単位」+Redis Pub/Sub
よくあるサンプルは「接続中の全クライアントにブロードキャスト」ですが、今回は会話ごとのルーム(2〜3人)にしか配りません。
ここで問題になるのが、各プロセスは自分が受け付けた接続しか持っていないことです。今回はサーバーを2台に分けている(=Goのプロセスが2つ動いている)ので、同じ会話の相手が別のプロセスに繋がっていることがあります。プロセス1のメモリ上には、プロセス2が握っている接続は存在しません。
そこで使うのがRedisです。
Redisとは
データをメモリ上に置く、非常に高速なデータストアです。ディスクに書くPostgreSQLと違ってプロセスが落ちたり再起動したりすると中身は基本的に消えますが、そのぶん読み書きが桁違いに速く、複数のプロセスから同じデータを触れます。
今回は「消えても困らないが、プロセスをまたいで共有したいもの」の置き場所として、3つの用途で使っています。
| 用途 | 使っているRedisの機能 |
|---|---|
| ルームへのメッセージ配信 | Pub/Sub |
| 今このルームに誰がいるか | Sorted Set(スコアに最終確認時刻) |
| マッチングの待機キュー | Sorted Set(スコアに待ち始めた時刻) |
逆に、会話の記録やトピックのマスタなど消えると困るものはPostgreSQLに置いています。
Pub/Subとは
Redisの機能の一つで、Publish / Subscribe(発行 / 購読)の略です。
- チャンネルという名前付きの通り道がある
- publish(発行): チャンネルにデータを投げる
- subscribe(購読): チャンネルを指定して待ち受けておくと、そこに投げられたデータが流れてくる
ポイントは、発行する側は誰が受け取るかを知らなくてよいことです。プロセス1は「この会話のチャンネルに投げる」だけで、そのチャンネルを購読しているプロセスすべて(自分自身を含む)にデータが届きます。相手がどのプロセスにいるかを調べる必要がありません。
チャンネル名は会話IDから作っていて、chat:room:{会話ID} という形です。会話が違えばチャンネルも違うので、他の会話のメッセージが混ざることはありません。
図の読み方: 参加者1と参加者2は同じ会話なので、別のプロセスに繋がっていても同じチャンネルchat:room:xxxを購読しています。だから参加者1の発言がプロセス1からRedisに発行され、プロセス2に流れて参加者2に届きます。右下の別会話はchat:room:yyyを購読しているので、この発言を受け取りません。
発行したプロセス自身にも戻ってくるので、送信者への配信も同じ経路を通ります。送信者だけ特別扱いしないほうが、コードが1本で済みます。
プロセス内側の構造
// 会話IDごとに room を持ち、その room が Redis の購読を1本持つ
func (h *hub) attach(ctx context.Context, conversationID string, c *conn) error {
r, ok := h.rooms[conversationID]
if !ok {
sub, err := h.store.Subscribe(context.WithoutCancel(ctx), conversationID)
if err != nil {
return err
}
r = &room{conversationID: conversationID, sub: sub, conns: ...}
h.rooms[conversationID] = r
go r.run() // 購読したチャンネルを読み続けて、配下の接続に配る
}
r.conns[c] = struct{}{}
return nil
}
購読はプロセスごとに1会話1本です。同じ会話に3人が同じプロセスへ繋がっても購読は1本で、その会話への接続が1つも無くなった時点で閉じます。
context.WithoutCancelを使っているのは、購読の寿命が「それを作ったHTTPリクエスト」より長いためです。リクエストのコンテキストをそのまま渡すと、接続が生きているのに購読だけ先に死にます。
在室管理もRedisに置いています。プロセスをまたいで「今このルームに誰がいるか」を数える必要があるためで、会話IDごとのSorted Set(スコアが最終確認時刻)に入れ、TTLを過ぎたエントリは読むときに捨てます。こうするとプロセスが落ちたときに残った在室情報も自然に消えます。
遅い接続には配らずに切る
3人ルームで、こういう場面を考えます。
- Aさん: 普通に会話している
- Bさん: 地下鉄に入って電波が細くなり、データを受け取りきれていない
- Cさん: 普通に会話している
Aさんが発言すると、プロセスはB・Cの両方に配ろうとします。ここでBさんの回線が詰まっているとき、「Bさんが受け取れるまで待つ」という作りにすると、Cさんにも届きません。関係のないCさんの画面が、Bさんの電波が戻るまで止まります。会話が3人とも成立しなくなるわけです。
そこで今回は、待たないことにしました。Bさんへの送信待ちが32件たまった時点で、Bさん自体の接続を切ります。
func (c *conn) enqueue(msg outgoing) {
select {
case c.out <- msg:
case <-c.done:
default:
// バッファ(32件)が埋まっている = 追いつけていない
slog.Warn("closing a connection that is not keeping up", ...)
c.close()
}
}
利用者から見ると、こうなります。
| 起きること | |
|---|---|
| Aさん・Cさん | 何も起きない。会話はそのまま続く |
| Bさん | 接続が切れる。再接続すれば会話に戻れるが、切れている間の発言は表示されない |
「1件だけ捨てて接続は繋いだままにする」という選択肢もありますが、採りませんでした。Bさんの画面には歯抜けの会話が表示され続け、しかも本人が気づけないからです。切ってしまえば、クライアント側は切断を検知できます。
なお、切れてから30秒以内に再接続すれば、会話そのものは終了しません(会話は、参加者が1人の状態が30秒続いたら終了する作りです)。
使い分けの判断基準
- ページ表示、フォーム送信、CRUD、ファイル転送 → HTTP
- チャット、通知、ライブ更新、共同編集 → WebSocket
- サーバーからの一方向プッシュだけでよい → Server-Sent Events(WebSocketより軽く、HTTPの上に乗るためLB周りが楽)
今回のサービスでも、実際にWebSocketにしたのは会話の1本だけでした。「リアルタイムなサービス」と「全部WebSocketで作るサービス」は別物というのが、作ってみていちばん実感した点です。
まとめ
- HTTPはクライアント起点の一方向通信で、サーバーからのプッシュはできない
- WebSocketはHTTPハンドシェイクでアップグレードした後、双方向・常時接続・軽量フレームで通信する
- リアルタイムが必要な部分だけをWebSocketにし、残りはHTTPに置くのが現実的。今回は待機・セッション・トピックがHTTP、会話だけWebSocketになった
- その代わり、ハンドシェイクでの認証、複数プロセスへの配信、遅い接続の扱いなど、決めることが増える
- 再接続の猶予・ping/pong・ロードバランサの設定といった運用面は、それだけで長くなるのでこの記事では割愛した
「WebSocket = 繋ぎっぱなしの電話」「HTTP = その都度かけ直す電話」と覚えておくと、使い分けで迷うことは少なくなるはずです。