はじめに
「5秒ごとにAPIを叩いて更新を確認する」
これ、いつまで続けますか?
ポーリングは動く。でも、
- サーバーに無駄な負荷がかかる
- リアルタイム性が犠牲になる
- バッテリーを消耗する
本当にリアルタイムが必要なら、WebSocketを使え。
この記事では、WebSocketの本質から、実務での設計・実装パターン、スケールアウト時の課題と解決策までを解説する。
HTTPの限界:なぜWebSocketが必要か
HTTPはリクエスト・レスポンス型
sequenceDiagram
participant C as クライアント
participant S as サーバー
Note over C,S: HTTP: リクエスト・レスポンス型
C->>S: 1. リクエスト送信
S->>C: 2. レスポンス返却
Note over C,S: ❌ 接続終了(毎回接続を張り直す)
Note over C,S: <br/>問題:サーバーから能動的に送信できない
HTTPでは、クライアントからしかリクエストを送れない。
サーバーが「新しいメッセージが来たよ」と伝えたくても、クライアントが聞きに来るまで待つしかない。
ポーリングの問題
// ショートポーリング: 定期的にAPIを叩く
setInterval(async () => {
const messages = await fetch('/api/messages');
// 更新があっても、なくても、5秒ごとにリクエスト
}, 5000);
問題点:
| 問題 | 説明 |
|---|---|
| 無駄なリクエスト | 更新がなくても叩き続ける |
| 遅延 | 最大5秒の遅れ(インターバル依存) |
| サーバー負荷 | ユーザー数 × リクエスト/秒 |
| バッテリー消耗 | モバイルで顕著 |
ロングポーリング
// ロングポーリング: サーバーが更新まで待機
async function longPoll() {
const response = await fetch('/api/messages/wait'); // サーバーは更新まで応答しない
handleMessages(response);
longPoll(); // 再接続
}
改善点:
- 更新がない時のリクエストが減る
- リアルタイム性が向上
残る問題:
- 毎回HTTP接続を張り直す
- HTTPヘッダーのオーバーヘッド
- サーバー側のコネクション管理が複雑
WebSocketの解決策
sequenceDiagram
participant C as クライアント
participant S as サーバー
Note over C,S: WebSocket: 双方向常時接続
C->>S: ハンドシェイク(HTTP Upgrade)
S->>C: 101 Switching Protocols
Note over C,S: ✅ WebSocket接続確立
Note over C,S: 双方向でいつでも送信可能
C->>S: メッセージ1
S->>C: メッセージ2
S->>C: メッセージ3(サーバーから自発的に)
C->>S: メッセージ4
Note over C,S: 接続は維持される
一度接続すれば、双方向でいつでもデータを送れる。
WebSocketの仕組み
ハンドシェイク
WebSocketは、HTTPでハンドシェイクしてから、プロトコルを切り替える。
# クライアント → サーバー
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# サーバー → クライアント
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101 Switching Protocolsの後、HTTP接続はWebSocket接続に昇格する。
WebSocketフレーム
ハンドシェイク後は、軽量なフレーム形式でデータをやり取りする。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
HTTPヘッダー(数百バイト)に比べて、わずか2〜14バイトのオーバーヘッド。
WebSocket vs SSE vs ポーリング
比較表
| 方式 | 方向 | リアルタイム | 複雑さ | ユースケース |
|---|---|---|---|---|
| ショートポーリング | クライアント→サーバー | ❌ 低い | ✅ 簡単 | 更新頻度が低い |
| ロングポーリング | クライアント→サーバー | ⚪ 中程度 | ⚪ 普通 | レガシー対応 |
| SSE | サーバー→クライアント | ✅ 高い | ✅ 簡単 | 通知、フィード |
| WebSocket | 双方向 | ✅ 高い | ❌ 複雑 | チャット、ゲーム |
SSE(Server-Sent Events)
サーバーからクライアントへの一方向ストリーム。
// クライアント
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
console.log('New notification:', event.data);
};
# サーバー(Python/Flask)
@app.route('/api/notifications')
def notifications():
def generate():
while True:
notification = get_next_notification()
yield f"data: {json.dumps(notification)}\n\n"
return Response(generate(), mimetype='text/event-stream')
SSEが適しているケース:
- 株価更新
- ニュースフィード
- プッシュ通知
- ログのリアルタイム表示
SSEの利点:
- 実装が簡単
- 自動再接続
- HTTPのまま(プロキシを通りやすい)
使い分けの指針
flowchart TB
Start["リアルタイム通信が必要"] --> Q1{リアルタイム性が<br/>必要?}
Q1 -->|No| Polling["✅ ショートポーリング<br/>━━━━━━<br/>・実装が最も簡単<br/>・更新頻度が低い<br/>・レガシー環境"]
Q1 -->|Yes| Q2{通信方向は?}
Q2 -->|"サーバー→クライアントのみ"| SSE["✅ SSE<br/>(Server-Sent Events)<br/>━━━━━━<br/>・株価更新<br/>・ニュースフィード<br/>・通知<br/>・自動再接続機能"]
Q2 -->|双方向| Q3{レガシー環境<br/>対応が必要?}
Q3 -->|Yes| SocketIO["✅ Socket.IO<br/>━━━━━━<br/>・WebSocket非対応環境で<br/> 自動フォールバック<br/>・ルーム機能<br/>・自動再接続"]
Q3 -->|No| WS["✅ WebSocket<br/>━━━━━━<br/>・チャット<br/>・オンラインゲーム<br/>・共同編集<br/>・リアルタイムダッシュボード"]
style Polling fill:#e3f2fd
style SSE fill:#e8f5e9
style SocketIO fill:#fff3e0
style WS fill:#e8f5e9
【実装】基本的なWebSocketサーバー
Node.js(ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
// 接続管理
const clients = new Set();
wss.on('connection', (ws) => {
console.log('Client connected');
clients.add(ws);
// メッセージ受信
ws.on('message', (data) => {
const message = JSON.parse(data);
console.log('Received:', message);
// 全クライアントにブロードキャスト
broadcast(message);
});
// 切断
ws.on('close', () => {
console.log('Client disconnected');
clients.delete(ws);
});
// エラー
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
function broadcast(message) {
const data = JSON.stringify(message);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
Python(websockets)
import asyncio
import websockets
import json
clients = set()
async def handler(websocket, path):
clients.add(websocket)
try:
async for message in websocket:
data = json.loads(message)
print(f"Received: {data}")
# ブロードキャスト
await broadcast(data)
finally:
clients.remove(websocket)
async def broadcast(message):
data = json.dumps(message)
await asyncio.gather(
*[client.send(data) for client in clients if client.open]
)
start_server = websockets.serve(handler, "localhost", 8080)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
クライアント側(JavaScript)
const ws = new WebSocket('ws://localhost:8080');
// 接続成功
ws.onopen = () => {
console.log('Connected');
ws.send(JSON.stringify({ type: 'join', user: 'yamada' }));
};
// メッセージ受信
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
// 切断
ws.onclose = (event) => {
console.log('Disconnected:', event.code, event.reason);
};
// エラー
ws.onerror = (error) => {
console.error('Error:', error);
};
// メッセージ送信
function sendMessage(text) {
ws.send(JSON.stringify({ type: 'message', text }));
}
【実装】Socket.IOを使った実装
Socket.IOは、WebSocketをより使いやすくしたライブラリ。
特徴
- フォールバック: WebSocketが使えない環境でもポーリングにフォールバック
- 自動再接続: 切断時に自動で再接続
- ルーム機能: グループへのブロードキャストが簡単
- 名前空間: 機能ごとに接続を分離
サーバー(Node.js)
const { Server } = require('socket.io');
const io = new Server(3000, {
cors: {
origin: "http://localhost:8080",
methods: ["GET", "POST"]
}
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// ルームに参加
socket.on('join_room', (room) => {
socket.join(room);
console.log(`${socket.id} joined ${room}`);
// ルームの他のメンバーに通知
socket.to(room).emit('user_joined', { userId: socket.id });
});
// メッセージ送信
socket.on('send_message', (data) => {
const { room, message } = data;
// ルーム内にブロードキャスト(送信者以外)
socket.to(room).emit('receive_message', {
userId: socket.id,
message,
timestamp: new Date().toISOString()
});
});
// タイピング中
socket.on('typing', (room) => {
socket.to(room).emit('user_typing', { userId: socket.id });
});
// 切断
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
クライアント
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
// 接続
socket.on('connect', () => {
console.log('Connected:', socket.id);
socket.emit('join_room', 'room-1');
});
// メッセージ受信
socket.on('receive_message', (data) => {
console.log('Message:', data);
displayMessage(data);
});
// ユーザー参加
socket.on('user_joined', (data) => {
console.log('User joined:', data.userId);
});
// タイピング中表示
socket.on('user_typing', (data) => {
showTypingIndicator(data.userId);
});
// メッセージ送信
function sendMessage(message) {
socket.emit('send_message', {
room: 'room-1',
message
});
}
// タイピング通知
function notifyTyping() {
socket.emit('typing', 'room-1');
}
【実務】メッセージプロトコルの設計
メッセージ形式
// 基本形式
{
"type": "message_type",
"payload": { ... },
"timestamp": "2024-12-14T10:30:00Z",
"messageId": "uuid"
}
イベント型の設計
// チャットアプリの例
const MessageTypes = {
// クライアント → サーバー
JOIN_ROOM: 'join_room',
LEAVE_ROOM: 'leave_room',
SEND_MESSAGE: 'send_message',
TYPING_START: 'typing_start',
TYPING_END: 'typing_end',
READ_MESSAGES: 'read_messages',
// サーバー → クライアント
USER_JOINED: 'user_joined',
USER_LEFT: 'user_left',
NEW_MESSAGE: 'new_message',
USER_TYPING: 'user_typing',
MESSAGES_READ: 'messages_read',
ERROR: 'error',
};
// 使用例
{
"type": "send_message",
"payload": {
"roomId": "room-123",
"content": "Hello!",
"replyTo": null
},
"messageId": "msg-456"
}
ACK(確認応答)パターン
sequenceDiagram
participant C as クライアント
participant S as サーバー
participant DB as データベース
Note over C,S: 正常フロー
C->>C: messageId生成<br/>(UUID)
C->>C: タイムアウトタイマー起動<br/>(5秒)
C->>S: send_message<br/>{messageId, content}
S->>DB: メッセージ保存
DB-->>S: 保存成功 (savedId)
S->>C: ack:messageId<br/>{success: true, savedId}
C->>C: タイムアウトキャンセル
C->>C: ✅ 送信完了
Note over C,S: <br/>エラーフロー(タイムアウト)
C->>C: messageId生成
C->>C: タイムアウトタイマー起動
C->>S: send_message<br/>{messageId, content}
Note over S: ❌ ネットワーク遅延で<br/>ACKが届かない
C->>C: ⏰ タイムアウト発生
C->>C: リトライまたは<br/>エラー通知
Note over C,S: <br/>エラーフロー(保存失敗)
C->>S: send_message
S->>DB: メッセージ保存
DB-->>S: ❌ 保存失敗
S->>C: ack:messageId<br/>{success: false, error}
C->>C: エラーハンドリング
// クライアント側: メッセージ送信と確認
function sendMessageWithAck(content) {
const messageId = generateUUID();
// タイムアウト付きで確認を待つ
const timeout = setTimeout(() => {
console.error('Message not acknowledged:', messageId);
retryOrFail(messageId);
}, 5000);
socket.emit('send_message', { messageId, content });
socket.once(`ack:${messageId}`, (response) => {
clearTimeout(timeout);
console.log('Message acknowledged:', response);
});
}
// サーバー側: ACKを返す
socket.on('send_message', async (data) => {
try {
const saved = await saveMessage(data);
socket.emit(`ack:${data.messageId}`, {
success: true,
savedId: saved.id
});
// 他のユーザーに配信
socket.to(data.roomId).emit('new_message', saved);
} catch (error) {
socket.emit(`ack:${data.messageId}`, {
success: false,
error: error.message
});
}
});
【実務】再接続とハートビート
自動再接続
flowchart TB
Start["🔌 WebSocket接続開始"] --> Connect["接続試行"]
Connect --> Success{"接続成功?"}
Success -->|Yes| Connected["✅ 接続確立<br/>━━━━━━<br/>・再接続カウンタをリセット<br/>・ハートビート開始"]
Success -->|No| Error["❌ 接続失敗"]
Connected --> Disconnect{"切断発生"}
Disconnect -->|"正常終了<br/>(code 1000)"| End["🏁 終了"]
Disconnect -->|異常終了| CheckAttempts{"最大再接続回数<br/>に達した?"}
Error --> CheckAttempts
CheckAttempts -->|Yes| Failed["⛔ 再接続諦め<br/>エラー通知"]
CheckAttempts -->|No| CalcDelay["⏱️ 再接続遅延を計算<br/>━━━━━━<br/>指数バックオフ:<br/><code style='color: white'>interval × 2^(attempts-1)</code><br/><br/>ジッター追加:<br/><code style='color: white'>delay × (0.5 + random × 0.5)</code>"]
CalcDelay --> Wait["⌛ 待機<br/>(1秒 → 2秒 → 4秒 → 8秒...)"]
Wait --> Retry["🔄 再接続試行<br/>(attempts + 1)"]
Retry --> Connect
style Start fill:#e3f2fd
style Connected fill:#e8f5e9
style Disconnect fill:#fff3e0
style Failed fill:#ffebee
style CalcDelay fill:#fff3e0
style Retry fill:#e3f2fd
style End fill:#e0e0e0
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.reconnectInterval = options.reconnectInterval || 1000;
this.maxReconnectInterval = options.maxReconnectInterval || 30000;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.reconnectInterval = 1000;
this.startHeartbeat();
};
this.ws.onclose = (event) => {
console.log('Disconnected:', event.code);
this.stopHeartbeat();
if (event.code !== 1000) { // 正常終了以外
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error('Error:', error);
};
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
// 指数バックオフ + ジッター
const delay = Math.min(
this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1),
this.maxReconnectInterval
) * (0.5 + Math.random() * 0.5);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
// ハートビート(後述)
startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
}
stopHeartbeat() {
clearInterval(this.heartbeatInterval);
}
}
ハートビート(死活監視)
// サーバー側
const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000;
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data) => {
const message = JSON.parse(data);
if (message.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
// 通常のメッセージ処理
});
});
// 定期的に死活確認
setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log('Client timed out, terminating');
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, HEARTBEAT_INTERVAL);
【実務】スケールアウトの課題
問題:サーバーが複数になると…
flowchart TB
User1["👤 User 1"] --> LB["⚖️ Load Balancer"]
User2["👤 User 2"] --> LB
LB --> ServerA["🖥️ Server A<br/>(User 1 接続)"]
LB --> ServerB["🖥️ Server B<br/>(User 2 接続)"]
Note1["❌ 問題:<br/>User 1 → Server A → ❓<br/>Server A は User 2 の接続を知らない<br/>メッセージが届かない!"]
style User1 fill:#e3f2fd
style User2 fill:#e3f2fd
style LB fill:#fff3e0
style ServerA fill:#ffebee
style ServerB fill:#ffebee
style Note1 fill:#ffebee
解決策1: Redis Pub/Sub
const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();
// サーバー間でメッセージを共有
sub.subscribe('chat_messages');
sub.on('message', (channel, message) => {
const data = JSON.parse(message);
// このサーバーに接続しているクライアントにのみ送信
broadcastToLocalClients(data);
});
// メッセージ送信時
function sendMessage(roomId, message) {
// Redisに発行 → 全サーバーが受信
pub.publish('chat_messages', JSON.stringify({
roomId,
message,
timestamp: Date.now()
}));
}
Redis Pub/Subによる解決
flowchart TB
User1["👤 User 1"] --> LB["⚖️ Load Balancer"]
User2["👤 User 2"] --> LB
LB --> ServerA["🖥️ Server A<br/>(User 1 接続)"]
LB --> ServerB["🖥️ Server B<br/>(User 2 接続)"]
ServerA <--> Redis["🗄️ Redis Pub/Sub<br/>━━━━━━<br/>全サーバーでメッセージ共有"]
ServerB <--> Redis
Flow["✅ メッセージフロー:<br/>User 1 → Server A → Redis Publish<br/>→ Redis Subscribe → Server B → User 2"]
style User1 fill:#e3f2fd
style User2 fill:#e3f2fd
style LB fill:#fff3e0
style ServerA fill:#e8f5e9
style ServerB fill:#e8f5e9
style Redis fill:#fff3e0
style Flow fill:#e8f5e9
解決策2: Socket.IO + Redis Adapter
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const io = new Server(3000);
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
// これだけで、複数サーバー間でルームやブロードキャストが動く
io.on('connection', (socket) => {
socket.on('join_room', (room) => {
socket.join(room); // 複数サーバーでも動く
});
socket.on('send_message', (data) => {
io.to(data.room).emit('new_message', data); // 全サーバーに配信
});
});
});
解決策3: Sticky Session
同じユーザーを常に同じサーバーに接続させる。
# nginx.conf
upstream websocket_servers {
ip_hash; # IPベースでスティッキー
server server1:3000;
server server2:3000;
}
server {
location /socket.io/ {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
注意: サーバーがダウンすると再接続時に別サーバーになる可能性あり。
【実務】認証
接続時の認証
// クライアント
const socket = io('http://localhost:3000', {
auth: {
token: 'jwt-token-here'
}
});
// サーバー
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = jwt.verify(token, SECRET_KEY);
socket.user = user;
next();
} catch (error) {
next(new Error('Authentication failed'));
}
});
io.on('connection', (socket) => {
console.log('Authenticated user:', socket.user.id);
});
ルームへのアクセス制御
socket.on('join_room', async (roomId) => {
// ユーザーがこのルームにアクセスする権限があるか確認
const hasAccess = await checkRoomAccess(socket.user.id, roomId);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}
socket.join(roomId);
socket.emit('joined_room', { roomId });
});
【実務】よくあるユースケースと実装
1. チャットアプリ
// ルーム管理
const rooms = new Map();
socket.on('join_room', (roomId) => {
socket.join(roomId);
// オンラインユーザーを通知
const users = getRoomUsers(roomId);
io.to(roomId).emit('room_users', users);
});
socket.on('send_message', async (data) => {
const message = await saveMessage({
roomId: data.roomId,
userId: socket.user.id,
content: data.content,
timestamp: new Date()
});
io.to(data.roomId).emit('new_message', message);
});
socket.on('typing', (roomId) => {
socket.to(roomId).emit('user_typing', {
userId: socket.user.id,
userName: socket.user.name
});
});
2. 通知システム
// ユーザーごとの個別ルームに参加
socket.on('authenticate', (userId) => {
socket.join(`user:${userId}`);
});
// 特定ユーザーに通知を送る
async function sendNotification(userId, notification) {
// DBに保存
await saveNotification(userId, notification);
// リアルタイム配信
io.to(`user:${userId}`).emit('notification', notification);
}
// 使用例
await sendNotification(123, {
type: 'new_message',
title: '新しいメッセージがあります',
body: '山田さんからメッセージが届きました',
url: '/messages/456'
});
3. リアルタイムダッシュボード
// サーバー側: 定期的にデータを配信
setInterval(async () => {
const metrics = await getSystemMetrics();
io.to('dashboard').emit('metrics_update', metrics);
}, 1000);
// クライアント側
socket.emit('subscribe', 'dashboard');
socket.on('metrics_update', (metrics) => {
updateChart(metrics.cpu);
updateChart(metrics.memory);
updateChart(metrics.requests);
});
4. リアルタイム共同編集
// Operational Transformation(OT)の簡易版
socket.on('document_change', (data) => {
const { documentId, changes, version } = data;
// 変更を他のユーザーにブロードキャスト
socket.to(`document:${documentId}`).emit('remote_change', {
userId: socket.user.id,
changes,
version
});
});
// カーソル位置の共有
socket.on('cursor_move', (data) => {
socket.to(`document:${data.documentId}`).emit('remote_cursor', {
userId: socket.user.id,
position: data.position
});
});
5. オンラインゲーム(位置同期)
// 60FPSで位置を同期
const TICK_RATE = 1000 / 60;
setInterval(() => {
rooms.forEach((room, roomId) => {
const gameState = {
players: Array.from(room.players.values()).map(p => ({
id: p.id,
x: p.x,
y: p.y,
rotation: p.rotation
})),
timestamp: Date.now()
};
io.to(roomId).emit('game_state', gameState);
});
}, TICK_RATE);
// クライアントからの入力
socket.on('player_input', (input) => {
const player = getPlayer(socket.user.id);
player.applyInput(input);
});
【実務】パフォーマンス最適化
1. メッセージの圧縮
const { Server } = require('socket.io');
const io = new Server(3000, {
perMessageDeflate: {
threshold: 1024, // 1KB以上のメッセージを圧縮
zlibDeflateOptions: {
chunkSize: 16 * 1024
}
}
});
2. バイナリプロトコル
// JSONの代わりにMessagePackを使用
const msgpack = require('msgpack-lite');
// 送信
socket.send(msgpack.encode({ type: 'update', data: largeData }));
// 受信
socket.on('message', (buffer) => {
const data = msgpack.decode(buffer);
});
3. メッセージのバッチ処理
// 高頻度の更新をバッチ化
class MessageBatcher {
constructor(socket, interval = 50) {
this.socket = socket;
this.queue = [];
this.interval = interval;
setInterval(() => this.flush(), interval);
}
add(message) {
this.queue.push(message);
}
flush() {
if (this.queue.length === 0) return;
this.socket.emit('batch', this.queue);
this.queue = [];
}
}
4. 接続数の制限
const MAX_CONNECTIONS_PER_IP = 10;
const connectionCounts = new Map();
io.use((socket, next) => {
const ip = socket.handshake.address;
const count = connectionCounts.get(ip) || 0;
if (count >= MAX_CONNECTIONS_PER_IP) {
return next(new Error('Too many connections'));
}
connectionCounts.set(ip, count + 1);
socket.on('disconnect', () => {
connectionCounts.set(ip, connectionCounts.get(ip) - 1);
});
next();
});
WebSocket実装チェックリスト
設計
- リアルタイムが本当に必要か(SSEで十分では?)
- メッセージプロトコルは定義されているか
- 認証・認可は考慮されているか
- スケールアウト時の設計はあるか
実装
- 再接続ロジックは実装されているか
- ハートビートは実装されているか
- エラーハンドリングは適切か
- メッセージのACKは必要か
運用
- 接続数の監視は設定されているか
- メッセージのログは取れているか
- 異常切断の検知はできるか
まとめ
WebSocketの本質は、常時接続による双方向通信だ。
- HTTP: リクエスト・レスポンス(クライアントが主導)
- WebSocket: 双方向(サーバーからも送れる)
- SSE: サーバー→クライアント(シンプルで十分な場合)
使い分け:
- 双方向が必要 → WebSocket
- サーバーからの配信だけ → SSE
- リアルタイム不要 → ポーリング
スケールアウト時は、Redis Pub/Subなどでサーバー間の同期が必要。
「リアルタイムっぽい」ではなく、本当にリアルタイムを実現するために、WebSocketを正しく理解して使おう。