Webアプリケーションでチャット機能やリアルタイム通知を実装する際、HTTP通信だけでは限界があります。HTTPはリクエスト・レスポンスモデルに基づいており、サーバーからクライアントへ能動的にデータを送信することができません。
この記事では、WebSocketによる双方向通信の仕組みを基礎から解説します。WebSocketハンドシェイクのプロセス、Server-Sent Events(SSE)との比較、そしてSocket.IOやwsライブラリを使った実践的な実装例を通じて、リアルタイム通信の技術を理解できます。
HTTP通信の限界とリアルタイム通信の必要性#
HTTPのリクエスト・レスポンスモデル#
HTTP通信は「クライアントがリクエストを送信し、サーバーがレスポンスを返す」という一方向のモデルに基づいています。この仕組みは多くのWebアプリケーションで問題なく機能しますが、リアルタイム性が求められる場面では課題があります。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: HTTPリクエスト
Server-->>Client: HTTPレスポンス
Note over Client,Server: クライアントが要求しない限り<br>サーバーからデータを送れないリアルタイム通信が必要なユースケース#
以下のようなアプリケーションでは、サーバーから能動的にデータを送信する必要があります。
| ユースケース |
必要な理由 |
| チャットアプリ |
新着メッセージを即座に表示する必要がある |
| リアルタイム通知 |
プッシュ通知をブラウザに即時配信する |
| 株価・為替レート表示 |
価格変動を即座に反映する |
| オンラインゲーム |
プレイヤー間の操作をリアルタイムに同期する |
| 共同編集ツール |
複数ユーザーの編集を即座に反映する |
従来の解決策:ポーリング#
WebSocket登場以前は、ポーリングという手法でリアルタイム性を擬似的に実現していました。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
loop 一定間隔で繰り返し
Client->>Server: 新しいデータある?
Server-->>Client: レスポンス(あり/なし)
endポーリングの問題点#
- サーバー負荷: 定期的なリクエストがサーバーに負担をかける
- 遅延: ポーリング間隔分の遅延が発生する
- 無駄な通信: データがなくても通信が発生する
- スケーラビリティ: 接続数が増えると問題が顕著になる
WebSocketとは何か#
WebSocketの定義と特徴#
WebSocketは、クライアントとサーバー間で永続的な双方向通信を実現するプロトコルです。RFC 6455として標準化されており、HTTP/1.1のUpgradeメカニズムを使用して接続を確立します。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: WebSocketハンドシェイク
Server-->>Client: ハンドシェイク完了
Note over Client,Server: 永続的な双方向接続が確立
Client->>Server: メッセージ送信
Server->>Client: メッセージ送信
Server->>Client: サーバーからプッシュ
Client->>Server: メッセージ送信WebSocketの主な特徴#
| 特徴 |
説明 |
| 双方向通信 |
クライアント・サーバー双方から任意のタイミングでデータ送信可能 |
| 永続的接続 |
一度接続すれば、明示的に切断するまで維持される |
| 低オーバーヘッド |
HTTP通信と比較してヘッダーサイズが小さい |
| 全二重通信 |
送信と受信を同時に行える |
| テキスト/バイナリ対応 |
テキストデータとバイナリデータの両方を送信可能 |
HTTPとWebSocketの比較#
| 項目 |
HTTP |
WebSocket |
| 通信方式 |
リクエスト・レスポンス |
双方向通信 |
| 接続 |
都度接続・切断 |
永続的接続 |
| 通信開始 |
クライアントのみ |
双方から可能 |
| オーバーヘッド |
毎回ヘッダー送信 |
最小限のフレームヘッダー |
| プロトコル |
http:// / https:// |
ws:// / wss:// |
WebSocketハンドシェイクの仕組み#
ハンドシェイクの流れ#
WebSocket接続は、HTTPのUpgradeリクエストを使用して確立されます。このプロセスをハンドシェイクと呼びます。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: HTTP Upgrade リクエスト
Note right of Client: Connection: Upgrade<br>Upgrade: websocket<br>Sec-WebSocket-Key: xxx
Server-->>Client: HTTP 101 Switching Protocols
Note left of Server: Upgrade: websocket<br>Connection: Upgrade<br>Sec-WebSocket-Accept: yyy
Note over Client,Server: WebSocket接続が確立<br>以降はWebSocketプロトコルで通信ハンドシェイクリクエストの詳細#
クライアントからサーバーへ送信されるハンドシェイクリクエストの例を示します。
1
2
3
4
5
6
7
|
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com
|
主要なヘッダーの説明#
| ヘッダー |
役割 |
Upgrade: websocket |
WebSocketプロトコルへのアップグレードを要求 |
Connection: Upgrade |
接続のアップグレードを示す |
Sec-WebSocket-Key |
サーバー検証用のランダムな値(Base64エンコード) |
Sec-WebSocket-Version |
WebSocketプロトコルのバージョン(現在は13) |
Origin |
リクエスト元のオリジン(CORS対策) |
ハンドシェイクレスポンスの詳細#
サーバーが接続を受け入れる場合、以下のようなレスポンスを返します。
1
2
3
4
|
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
|
Sec-WebSocket-Acceptの計算#
Sec-WebSocket-Acceptの値は、クライアントから送信されたSec-WebSocket-Keyに対して以下の処理を行い生成されます。
Sec-WebSocket-KeyにGUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 を連結
- SHA-1ハッシュを計算
- Base64エンコード
この仕組みにより、サーバーがWebSocketプロトコルを正しく理解していることを検証できます。
ブラウザでのWebSocket API#
WebSocketインターフェース#
ブラウザには標準でWebSocket APIが実装されています。基本的な使い方を確認しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// WebSocket接続の作成
const socket = new WebSocket('wss://example.com/socket');
// 接続が開いたときの処理
socket.addEventListener('open', (event) => {
console.log('WebSocket接続が開きました');
socket.send('Hello Server!');
});
// メッセージを受信したときの処理
socket.addEventListener('message', (event) => {
console.log('サーバーからのメッセージ:', event.data);
});
// エラーが発生したときの処理
socket.addEventListener('error', (event) => {
console.error('WebSocketエラー:', event);
});
// 接続が閉じたときの処理
socket.addEventListener('close', (event) => {
console.log('WebSocket接続が閉じました');
console.log('コード:', event.code, 'reason:', event.reason);
});
|
WebSocketの状態管理#
WebSocketオブジェクトはreadyStateプロパティで接続状態を管理しています。
| 値 |
定数 |
説明 |
| 0 |
WebSocket.CONNECTING |
接続中 |
| 1 |
WebSocket.OPEN |
接続済み、通信可能 |
| 2 |
WebSocket.CLOSING |
切断処理中 |
| 3 |
WebSocket.CLOSED |
切断済み |
1
2
3
4
5
6
7
8
|
// 状態を確認してからメッセージを送信
function sendMessage(socket, message) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(message);
} else {
console.warn('WebSocketが開いていません。状態:', socket.readyState);
}
}
|
バイナリデータの送受信#
WebSocketはテキストだけでなくバイナリデータも扱えます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
const socket = new WebSocket('wss://example.com/socket');
// バイナリタイプの設定('blob' または 'arraybuffer')
socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => {
// ArrayBufferの送信
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setInt32(0, 12345);
view.setFloat32(4, 3.14);
socket.send(buffer);
// Blobの送信
const blob = new Blob(['バイナリデータ'], { type: 'application/octet-stream' });
socket.send(blob);
});
socket.addEventListener('message', (event) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data);
console.log('受信した整数:', view.getInt32(0));
}
});
|
Server-Sent Events(SSE)との比較#
SSEとは#
Server-Sent Events(SSE)は、サーバーからクライアントへの一方向のリアルタイム通信を実現する技術です。WebSocketと異なり、HTTP/1.1上で動作します。
sequenceDiagram
participant Client as クライアント
participant Server as サーバー
Client->>Server: HTTPリクエスト
Server-->>Client: イベントストリーム開始
Server-->>Client: data: メッセージ1
Server-->>Client: data: メッセージ2
Server-->>Client: data: メッセージ3
Note over Client,Server: サーバーからクライアントへの<br>一方向通信のみブラウザでのSSE実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// EventSourceの作成
const eventSource = new EventSource('/events');
// メッセージ受信
eventSource.addEventListener('message', (event) => {
console.log('受信:', event.data);
});
// カスタムイベントの受信
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log('通知:', data);
});
// エラー処理
eventSource.addEventListener('error', (event) => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('接続が閉じられました');
}
});
// 接続を閉じる
eventSource.close();
|
サーバー側のSSE実装(Node.js)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/events') {
// SSE用のヘッダー設定
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
// 定期的にイベントを送信
const intervalId = setInterval(() => {
const data = JSON.stringify({
time: new Date().toISOString(),
message: 'サーバーからのプッシュ'
});
res.write(`data: ${data}\n\n`);
}, 1000);
// 接続が閉じられたら停止
req.on('close', () => {
clearInterval(intervalId);
});
}
});
server.listen(3000);
|
WebSocketとSSEの比較表#
| 項目 |
WebSocket |
SSE |
| 通信方向 |
双方向 |
サーバー → クライアント |
| プロトコル |
ws:// / wss:// |
HTTP/HTTPS |
| データ形式 |
テキスト/バイナリ |
テキストのみ |
| 自動再接続 |
なし(手動実装) |
ブラウザが自動で再接続 |
| ブラウザ対応 |
全主要ブラウザ |
IE非対応(その他は対応) |
| プロキシ対応 |
設定が必要な場合あり |
HTTP標準のため問題なし |
| 同時接続数 |
制限なし |
HTTP/1.1はドメインあたり6接続 |
使い分けの指針#
flowchart TD
A[リアルタイム通信が必要] --> B{クライアントからの送信が必要?}
B -->|はい| C[WebSocket]
B -->|いいえ| D{バイナリデータを扱う?}
D -->|はい| C
D -->|いいえ| E{HTTPプロキシ経由?}
E -->|はい| F[SSE推奨]
E -->|いいえ| G{実装の簡易さ重視?}
G -->|はい| F
G -->|いいえ| CNode.jsでのWebSocketサーバー実装#
wsライブラリを使った実装#
wsはNode.js向けの軽量なWebSocketライブラリです。
前提条件#
- Node.js 18以上
- npm または yarn
プロジェクトセットアップ#
1
2
3
4
|
mkdir websocket-demo
cd websocket-demo
npm init -y
npm install ws
|
基本的なWebSocketサーバー#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
console.log('WebSocketサーバーがポート8080で起動しました');
wss.on('connection', (ws, req) => {
const clientIp = req.socket.remoteAddress;
console.log(`クライアント接続: ${clientIp}`);
// メッセージ受信時の処理
ws.on('message', (message) => {
console.log('受信:', message.toString());
// エコーバック
ws.send(`サーバーからの返信: ${message}`);
});
// 接続終了時の処理
ws.on('close', () => {
console.log('クライアント切断');
});
// エラー処理
ws.on('error', (error) => {
console.error('WebSocketエラー:', error);
});
// 接続時のウェルカムメッセージ
ws.send('WebSocketサーバーに接続しました');
});
|
ブロードキャスト機能の実装#
全クライアントにメッセージを送信するブロードキャスト機能を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// broadcast-server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
// 全クライアントにブロードキャスト
function broadcast(message, sender) {
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
// 送信者以外に送信(オプション)
if (client !== sender) {
client.send(message);
}
}
});
}
wss.on('connection', (ws) => {
console.log(`クライアント数: ${wss.clients.size}`);
ws.on('message', (message) => {
const text = message.toString();
console.log('受信:', text);
// 全クライアントにブロードキャスト
broadcast(text, ws);
});
ws.on('close', () => {
console.log(`クライアント数: ${wss.clients.size}`);
});
});
|
HTTPサーバーとの統合#
既存のHTTPサーバーとWebSocketサーバーを統合する方法です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// integrated-server.js
const http = require('http');
const { WebSocketServer } = require('ws');
const fs = require('fs');
const path = require('path');
// HTTPサーバーの作成
const server = http.createServer((req, res) => {
if (req.url === '/') {
const htmlPath = path.join(__dirname, 'index.html');
const html = fs.readFileSync(htmlPath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
// WebSocketサーバーをHTTPサーバーに統合
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('WebSocket接続');
ws.on('message', (message) => {
console.log('受信:', message.toString());
ws.send(`エコー: ${message}`);
});
});
server.listen(3000, () => {
console.log('サーバーが http://localhost:3000 で起動しました');
});
|
Socket.IOによる高機能リアルタイム通信#
Socket.IOとは#
Socket.IOは、WebSocketをベースとした高機能なリアルタイム通信ライブラリです。WebSocketが使用できない環境では自動的にHTTPロングポーリングにフォールバックします。
Socket.IOの主な機能#
| 機能 |
説明 |
| 自動再接続 |
接続が切れても自動的に再接続を試みる |
| フォールバック |
WebSocket非対応環境ではHTTPポーリングを使用 |
| ルーム機能 |
クライアントをグループ化してブロードキャスト |
| 名前空間 |
接続を論理的に分離 |
| ACK機能 |
メッセージの到達確認 |
サーバー側の実装#
1
|
npm install socket.io express
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
// socket-server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
app.use(express.static(path.join(__dirname, 'public')));
io.on('connection', (socket) => {
console.log('ユーザー接続:', socket.id);
// カスタムイベントの受信
socket.on('chat message', (msg) => {
console.log('メッセージ:', msg);
// 全クライアントにブロードキャスト
io.emit('chat message', {
id: socket.id,
message: msg,
timestamp: new Date().toISOString()
});
});
// ルームへの参加
socket.on('join room', (roomName) => {
socket.join(roomName);
console.log(`${socket.id} がルーム ${roomName} に参加`);
socket.to(roomName).emit('user joined', socket.id);
});
// ルーム内へのメッセージ
socket.on('room message', ({ room, message }) => {
io.to(room).emit('room message', {
id: socket.id,
message,
room
});
});
// ACK付きイベント
socket.on('request data', (params, callback) => {
// データを処理して結果を返す
const result = { success: true, data: params };
callback(result);
});
socket.on('disconnect', (reason) => {
console.log('ユーザー切断:', socket.id, reason);
});
});
httpServer.listen(3000, () => {
console.log('Socket.IOサーバーが http://localhost:3000 で起動しました');
});
|
クライアント側の実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Socket.IO チャット</title>
<style>
#messages { list-style: none; padding: 0; max-height: 300px; overflow-y: auto; }
#messages li { padding: 8px; margin: 4px 0; background: #f0f0f0; border-radius: 4px; }
#messageForm { display: flex; gap: 8px; }
#messageInput { flex: 1; padding: 8px; }
button { padding: 8px 16px; cursor: pointer; }
</style>
</head>
<body>
<h1>Socket.IO チャット</h1>
<ul id="messages"></ul>
<form id="messageForm">
<input type="text" id="messageInput" placeholder="メッセージを入力" required>
<button type="submit">送信</button>
</form>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
const messages = document.getElementById('messages');
const form = document.getElementById('messageForm');
const input = document.getElementById('messageInput');
// 接続イベント
socket.on('connect', () => {
console.log('接続成功:', socket.id);
});
// メッセージ受信
socket.on('chat message', (data) => {
const li = document.createElement('li');
li.textContent = `[${data.timestamp}] ${data.id}: ${data.message}`;
messages.appendChild(li);
messages.scrollTop = messages.scrollHeight;
});
// メッセージ送信
form.addEventListener('submit', (e) => {
e.preventDefault();
if (input.value.trim()) {
socket.emit('chat message', input.value);
input.value = '';
}
});
// ACK付きリクエスト
function requestWithAck() {
socket.emit('request data', { query: 'test' }, (response) => {
console.log('サーバーからの応答:', response);
});
}
// 再接続イベント
socket.on('reconnect', (attemptNumber) => {
console.log('再接続成功。試行回数:', attemptNumber);
});
</script>
</body>
</html>
|
チャットアプリの実装例#
プロジェクト構成#
チャットアプリの完全な実装例を示します。
chat-app/
├── package.json
├── server.js
└── public/
└── index.html
サーバー実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
// server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
app.use(express.static(path.join(__dirname, 'public')));
// ユーザー管理
const users = new Map();
io.on('connection', (socket) => {
// ユーザー登録
socket.on('register', (username) => {
users.set(socket.id, username);
io.emit('user list', Array.from(users.values()));
io.emit('system message', `${username} が参加しました`);
});
// チャットメッセージ
socket.on('chat message', (message) => {
const username = users.get(socket.id) || '匿名';
io.emit('chat message', {
username,
message,
timestamp: new Date().toLocaleTimeString('ja-JP')
});
});
// タイピング中の通知
socket.on('typing', () => {
const username = users.get(socket.id);
if (username) {
socket.broadcast.emit('typing', username);
}
});
socket.on('stop typing', () => {
socket.broadcast.emit('stop typing');
});
// 切断
socket.on('disconnect', () => {
const username = users.get(socket.id);
if (username) {
users.delete(socket.id);
io.emit('user list', Array.from(users.values()));
io.emit('system message', `${username} が退出しました`);
}
});
});
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(`チャットサーバーが http://localhost:${PORT} で起動しました`);
});
|
クライアント実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
|
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>リアルタイムチャット</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: sans-serif; background: #f5f5f5; }
.container { max-width: 800px; margin: 20px auto; display: flex; gap: 20px; }
.chat-area { flex: 1; background: white; border-radius: 8px; padding: 16px; }
.user-list { width: 200px; background: white; border-radius: 8px; padding: 16px; }
.messages { height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; margin-bottom: 10px; }
.message { margin-bottom: 10px; padding: 8px; background: #e3f2fd; border-radius: 4px; }
.message.system { background: #fff3e0; font-style: italic; }
.message .meta { font-size: 12px; color: #666; margin-bottom: 4px; }
.input-area { display: flex; gap: 8px; }
.input-area input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
.input-area button { padding: 10px 20px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; }
.typing-indicator { height: 20px; font-size: 12px; color: #666; font-style: italic; }
.user-list h3 { margin-bottom: 10px; }
.user-list ul { list-style: none; }
.user-list li { padding: 4px 0; }
.login-form { text-align: center; padding: 50px; }
.login-form input { padding: 10px; margin-right: 10px; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="login-form" id="loginForm">
<h2>チャットに参加</h2>
<input type="text" id="usernameInput" placeholder="ユーザー名を入力">
<button id="joinBtn">参加</button>
</div>
<div class="container hidden" id="chatContainer">
<div class="chat-area">
<h2>チャットルーム</h2>
<div class="messages" id="messages"></div>
<div class="typing-indicator" id="typingIndicator"></div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="メッセージを入力">
<button id="sendBtn">送信</button>
</div>
</div>
<div class="user-list">
<h3>参加者</h3>
<ul id="userList"></ul>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
// DOM要素
const loginForm = document.getElementById('loginForm');
const chatContainer = document.getElementById('chatContainer');
const usernameInput = document.getElementById('usernameInput');
const joinBtn = document.getElementById('joinBtn');
const messages = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const userList = document.getElementById('userList');
const typingIndicator = document.getElementById('typingIndicator');
let typingTimeout;
// ログイン
joinBtn.addEventListener('click', () => {
const username = usernameInput.value.trim();
if (username) {
socket.emit('register', username);
loginForm.classList.add('hidden');
chatContainer.classList.remove('hidden');
messageInput.focus();
}
});
usernameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') joinBtn.click();
});
// メッセージ送信
function sendMessage() {
const message = messageInput.value.trim();
if (message) {
socket.emit('chat message', message);
socket.emit('stop typing');
messageInput.value = '';
}
}
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// タイピング検知
messageInput.addEventListener('input', () => {
socket.emit('typing');
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
socket.emit('stop typing');
}, 1000);
});
// メッセージ受信
socket.on('chat message', (data) => {
const div = document.createElement('div');
div.className = 'message';
div.innerHTML = `<div class="meta">${data.username} - ${data.timestamp}</div>${data.message}`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
});
// システムメッセージ
socket.on('system message', (msg) => {
const div = document.createElement('div');
div.className = 'message system';
div.textContent = msg;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
});
// ユーザーリスト更新
socket.on('user list', (users) => {
userList.innerHTML = users.map(u => `<li>${u}</li>`).join('');
});
// タイピング表示
socket.on('typing', (username) => {
typingIndicator.textContent = `${username} が入力中...`;
});
socket.on('stop typing', () => {
typingIndicator.textContent = '';
});
</script>
</body>
</html>
|
実行方法#
1
2
3
4
5
|
# 依存関係のインストール
npm install express socket.io
# サーバーの起動
node server.js
|
ブラウザで http://localhost:3000 にアクセスすると、リアルタイムチャットアプリを使用できます。
リアルタイム通知システムの実装#
通知サービスの設計#
実際のアプリケーションで使用できる通知システムを実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
// notification-server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: '*' }
});
app.use(express.json());
// ユーザーとソケットのマッピング
const userSockets = new Map();
io.on('connection', (socket) => {
// ユーザー認証・登録
socket.on('authenticate', (userId) => {
userSockets.set(userId, socket);
socket.userId = userId;
console.log(`ユーザー ${userId} が接続`);
});
socket.on('disconnect', () => {
if (socket.userId) {
userSockets.delete(socket.userId);
console.log(`ユーザー ${socket.userId} が切断`);
}
});
});
// REST APIで通知を送信
app.post('/api/notify', (req, res) => {
const { userId, type, title, message, data } = req.body;
const notification = {
id: Date.now().toString(),
type,
title,
message,
data,
createdAt: new Date().toISOString(),
read: false
};
// 特定ユーザーへの通知
if (userId) {
const socket = userSockets.get(userId);
if (socket) {
socket.emit('notification', notification);
res.json({ success: true, delivered: true });
} else {
// ユーザーがオフラインの場合はDBに保存するなどの処理
res.json({ success: true, delivered: false, reason: 'User offline' });
}
} else {
// 全ユーザーへのブロードキャスト
io.emit('notification', notification);
res.json({ success: true, delivered: true, recipients: userSockets.size });
}
});
// 接続中のユーザー数を取得
app.get('/api/connections', (req, res) => {
res.json({ count: userSockets.size });
});
httpServer.listen(3000, () => {
console.log('通知サーバーが起動しました');
});
|
クライアント側の通知ハンドラー#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
// notification-client.js
class NotificationService {
constructor(serverUrl, userId) {
this.socket = io(serverUrl);
this.userId = userId;
this.listeners = new Set();
this.socket.on('connect', () => {
this.socket.emit('authenticate', this.userId);
});
this.socket.on('notification', (notification) => {
this.handleNotification(notification);
});
this.socket.on('disconnect', () => {
console.log('通知サーバーから切断されました');
});
}
handleNotification(notification) {
// 登録されたリスナーに通知
this.listeners.forEach(listener => listener(notification));
// ブラウザ通知を表示(許可されている場合)
if (Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/notification-icon.png'
});
}
}
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
disconnect() {
this.socket.disconnect();
}
}
// 使用例
const notificationService = new NotificationService('http://localhost:3000', 'user123');
notificationService.subscribe((notification) => {
console.log('新しい通知:', notification);
// UIを更新
updateNotificationBadge();
showNotificationToast(notification);
});
|
本番環境での考慮事項#
セキュリティ対策#
オリジンの検証#
1
2
3
4
5
6
7
|
const io = new Server(httpServer, {
cors: {
origin: ['https://example.com', 'https://www.example.com'],
methods: ['GET', 'POST'],
credentials: true
}
});
|
認証の実装#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('認証トークンがありません'));
}
try {
// JWTの検証
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.userId = decoded.userId;
next();
} catch (err) {
next(new Error('無効なトークンです'));
}
});
|
レート制限#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
const rateLimiter = new Map();
io.on('connection', (socket) => {
socket.on('message', (data) => {
const key = socket.id;
const now = Date.now();
const windowMs = 1000; // 1秒
const maxRequests = 10; // 1秒あたり10リクエストまで
const requests = rateLimiter.get(key) || [];
const recentRequests = requests.filter(time => now - time < windowMs);
if (recentRequests.length >= maxRequests) {
socket.emit('error', { message: 'レート制限を超えました' });
return;
}
recentRequests.push(now);
rateLimiter.set(key, recentRequests);
// メッセージ処理を続行
processMessage(data);
});
});
|
スケーリング戦略#
複数サーバーでWebSocket接続を分散する場合、Redis Adapterを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
async function setupRedisAdapter() {
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
}
setupRedisAdapter();
|
接続の監視とハートビート#
1
2
3
4
5
6
7
8
9
10
|
const io = new Server(httpServer, {
pingTimeout: 60000, // 60秒間応答がなければ切断
pingInterval: 25000 // 25秒ごとにping送信
});
// 接続状態の監視
setInterval(() => {
const connectedSockets = io.sockets.sockets.size;
console.log(`現在の接続数: ${connectedSockets}`);
}, 30000);
|
まとめ#
WebSocketは、Webアプリケーションにリアルタイム通信機能を追加するための強力な技術です。この記事で解説した内容を振り返ります。
- HTTP通信の限界: リクエスト・レスポンスモデルではサーバーからのプッシュ通信ができない
- WebSocketの仕組み: HTTPハンドシェイクを経て永続的な双方向通信を確立する
- SSEとの違い: SSEは一方向通信のみだがHTTP互換性が高い
- wsライブラリ: Node.jsで軽量なWebSocketサーバーを構築できる
- Socket.IO: 自動再接続やルーム機能など高機能なリアルタイム通信を実現
- 本番環境: セキュリティ、スケーリング、監視の考慮が必要
リアルタイム通信の技術を理解することで、チャット、通知、ライブ更新など、ユーザー体験を向上させる機能を実装できるようになります。
参考リンク#