NodeJS 速習チュートリアル

Socket.IO

1. Socket.IOとは?

Socket.IOは、Webクライアントとサーバー間のリアルタイムで双方向、かつイベントベースの通信を可能にする強力なJavaScriptライブラリです。あらゆるプラットフォーム、ブラウザ、デバイスで動作するように設計されており、信頼性とスピードの両方に重点を置いています。

1.1 主な特徴

  • リアルタイム双方向通信: クライアントとサーバー間での即時のデータ転送を実現します。
  • 自動再接続: 切断が発生した際の再接続を自動的に処理します。
  • ルーム(Room)サポート: グループ通信用のチャネルを簡単に作成できます。
  • バイナリサポート: ArrayBuffer、Blob、Fileなどのバイナリデータを送受信可能です。
  • マルチプレクシング(Multiplexing): ネームスペースを使用して複数のソケットを管理できます。
  • フォールバックオプション: WebSocketが利用できない場合、自動的にHTTPロングポーリングに切り替えます。

1.2 ユースケース

  • リアルタイムチャットアプリケーション
  • ライブ通知(プッシュ通知)
  • 共同編集ツール
  • オンラインゲーム
  • リアルタイム分析(アナリティクス)
  • ドキュメントの同時編集
  • リアルタイムダッシュボード
  • IoTアプリケーション

Socket.IOは、ブラウザで動作するクライアントサイドライブラリと、Node.js用のサーバーサイドライブラリの2つの部分で構成されています。

2. Socket.IOのインストール

2.1 サーバーサイドのインストール

npmまたはyarnを使用して、Node.jsプロジェクトにSocket.IOをインストールします。

# npmを使用する場合
npm install socket.io

# Yarnを使用する場合
yarn add socket.io

2.2 クライアントサイドのセットアップ

クライアントライブラリを組み込むには、以下のいずれかの方法を選択してください。

オプション 1: CDN(クイックスタート)

<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>

オプション 2: NPM(本番環境推奨)

# クライアントライブラリをインストール
npm install socket.io-client

# または Yarn を使用
yarn add socket.io-client

オプション 3: ES6モジュールの使用

import { io } from 'socket.io-client';

2.3 バージョンの互換性

Socket.IO バージョンNode.js バージョンブラウザサポート
v4.xv12.22.0+Chrome 49+, Firefox 53+, Safari 10+
v3.xv10.0.0+Chrome 49+, Firefox 53+, Safari 10+
v2.xv6.0.0+Chrome 5+, Firefox 6+, Safari 5.1+

       注意: 本番環境では、クライアントとサーバーで同じバージョンを使用することをお勧めします。

3. Socket.IOを使用したシンプルなチャットアプリの作成

Node.jsとSocket.IOを使用して、シンプルなリアルタイムチャットアプリケーションを構築しましょう。この例ではログイン機能は不要で、基本的な機能のデモンストレーションに焦点を当てます。

3.1 サーバーの作成 (app.js)

app.js という名前の新しいファイルを作成し、以下の内容を記述します。

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// 静的ファイルの提供
app.use(express.static(path.join(__dirname, 'public')));

// シンプルなルート設定
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// Socket.IO 接続ハンドラー
io.on('connection', (socket) => {
  console.log('ユーザーが接続しました');

  // 新しいメッセージの処理
  socket.on('chat message', (msg) => {
    console.log('メッセージを受信:', msg);
    // 接続されているすべてのクライアントにメッセージをブロードキャスト
    io.emit('chat message', msg);
  });

  // 切断時の処理
  socket.on('disconnect', () => {
    console.log('ユーザーが切断しました');
  });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`サーバーがポート ${PORT} で起動しました`);
});

3.2 クライアントの作成 (public/index.html)

public ディレクトリを作成し、その中に以下の内容の index.html ファイルを追加します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>シンプルチャット</title>
  <style>
  body { margin: 0; padding: 20px; font-family: Arial, sans-serif; }
  #messages {
    list-style-type: none; margin: 0; padding: 0; margin-bottom: 20px;
    border: 1px solid #ddd; padding: 10px; height: 400px; overflow-y: auto;
  }
  #messages li { padding: 8px 16px; border-bottom: 1px solid #eee; }
  #messages li:last-child { border-bottom: none; }
  #form { display: flex; margin-top: 10px; }
  #input { flex-grow: 1; padding: 10px; font-size: 16px; }
  button {
    padding: 10px 20px; background: #4CAF50; color: white;
    border: none; cursor: pointer; margin-left: 10px;
  }
  button:hover { background: #45a049; }
  </style>
</head>
<body>
  <h1>シンプルチャット</h1>
  <ul id="messages"></ul>
  <form id="form" action="#">
    <input id="input" autocomplete="off" placeholder="メッセージを入力..." />
    <button>送信</button>
  </form>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    const form = document.getElementById('form');
    const input = document.getElementById('input');
    const messages = document.getElementById('messages');

    // フォーム送信時の処理
    form.addEventListener('submit', (e) => {
        e.preventDefault();
        const message = input.value.trim();
        if (message) {
            // サーバーにメッセージを送信(Emit)
            socket.emit('chat message', message);
            // 入力欄をクリア
            input.value = '';
        }
    });

    // メッセージ受信時の処理
    socket.on('chat message', (msg) => {
        const item = document.createElement('li');
        item.textContent = msg;
        messages.appendChild(item);
        // 最下部までスクロール
        messages.scrollTop = messages.scrollHeight;
    });
  </script>
</body>
</html>

3.3 アプリケーションの実行

  1. サーバーを起動します:node app.js
  2. ブラウザを開き、http://localhost:3000 にアクセスします。
  3. 複数のブラウザウィンドウを開き、リアルタイムに更新される様子を確認してください。

3.4 動作の仕組み

  • サーバーは Express を使用して静的ファイルを提供し、Socket.IO 接続を処理します。
  • クライアントが接続すると、メッセージを送信できるようになります。そのメッセージはすべての接続済みクライアントにブロードキャストされます。
  • クライアントサイドの JavaScript は、メッセージの送受信をリアルタイムで処理します。

4. 次のステップ

基本バージョンの動作を確認したら、以下の機能の追加を検討してください:

  • 各メッセージへのユーザー名の付与
  • ユーザーの入室/退室通知
  • 異なるチャットルームの作成
  • メッセージの永続化(データベース保存)
  • ユーザー認証

       注意: これはデモンストレーション用の基本的な例です。本番環境では、適切なエラーハンドリング、入力バリデーション、およびセキュリティ対策を追加する必要があります。

5. ユーザー名の追加

メッセージにユーザー名を追加してチャットを強化しましょう。まず、サーバー側を修正してユーザー名を扱えるようにします。

// app.js 内の接続ハンドラーを修正
io.on('connection', (socket) => {
  console.log('ユーザーが接続しました');

  // ソケットにユーザー名を保存(デフォルトは Anonymous)
  socket.username = 'Anonymous';

  // ユーザー名付きで新しいメッセージを処理
  socket.on('chat message', (msg) => {
    io.emit('chat message', {
      username: socket.username,
      message: msg,
      timestamp: new Date().toISOString()
    });
  });

  // ユーザー名の設定を処理
  socket.on('set username', (username) => {
    const oldUsername = socket.username;
    socket.username = username || 'Anonymous';
    io.emit('user joined', {
      oldUsername: oldUsername,
      newUsername: socket.username
    });
  });

  // 切断時の処理
  socket.on('disconnect', () => {
    console.log('ユーザーが切断しました');
    io.emit('user left', { username: socket.username });
  });
});

次に、クライアント側を更新してユーザー名を処理できるようにします。

<div id="username-container">
    <input type="text" id="username-input" placeholder="ユーザー名を入力" />
    <button id="set-username">名前を設定</button>
</div>

<script>
    // ユーザー名処理の追加
    const usernameInput = document.getElementById('username-input');
    const setUsernameBtn = document.getElementById('set-username');
    let currentUsername = 'Anonymous';

    setUsernameBtn.addEventListener('click', () => {
        const newUsername = usernameInput.value.trim();
        if (newUsername) {
            socket.emit('set username', newUsername);
            currentUsername = newUsername;
            usernameInput.value = '';
        }
    });

    // ユーザー名を表示するようにメッセージ表示を更新
    socket.on('chat message', (data) => {
        const item = document.createElement('li');
        item.innerHTML = `<strong>${data.username}:</strong> ${data.message}`;
        messages.appendChild(item);
        messages.scrollTop = messages.scrollHeight;
    });

    // ユーザー入室通知の処理
    socket.on('user joined', (data) => {
        const item = document.createElement('li');
        item.className = 'system-message';
        if (data.oldUsername === 'Anonymous') {
            item.textContent = `${data.newUsername} がチャットに参加しました`;
        } else {
            item.textContent = `${data.oldUsername} は ${data.newUsername} に名前を変更しました`;
        }
        messages.appendChild(item);
        messages.scrollTop = messages.scrollHeight;
    });

    // ユーザー退室通知の処理
    socket.on('user left', (data) => {
        const item = document.createElement('li');
        item.className = 'system-message';
        item.textContent = `${data.username} が退出しました`;
        messages.appendChild(item);
        messages.scrollTop = messages.scrollHeight;
    });
</script>

<style>
.system-message {
    color: #666;
    font-style: italic;
    font-size: 0.9em;
}
</style>

6. チャットルームの追加

異なるチャットルームを作成して参加できる機能を追加します。サーバーを更新します:

// app.js にルーム処理を追加
const rooms = new Set(['general', 'random']);

io.on('connection', (socket) => {
  // ... 既存のコード ...

  // ルームへの参加
  socket.on('join room', (room) => {
    // デフォルト以外のすべてのルームから退出
    socket.rooms.forEach(r => {
      if (r !== socket.id) {
        socket.leave(r);
        socket.emit('left room', r);
      }
    });

    // 新しいルームに参加
    socket.join(room);
    socket.emit('joined room', room);

    // ルーム内の他のユーザーに通知
    socket.to(room).emit('room message', {
      username: 'System',
      message: `${socket.username} がルームに参加しました`,
      timestamp: new Date().toISOString()
    });
  });

  // ルームの作成処理
  socket.on('create room', (roomName) => {
    if (!rooms.has(roomName)) {
      rooms.add(roomName);
      io.emit('room created', roomName);
    }
  });

  // メッセージハンドラーをルーム宛に送信するように変更
  socket.on('chat message', (data) => {
    const room = Array.from(socket.rooms).find(r => r !== socket.id) || 'general';
    io.to(room).emit('chat message', {
      username: socket.username,
      message: data.message,
      timestamp: new Date().toISOString(),
      room: room
    });
  });
});

7. ユーザーリストとタイピングインジケーターの追加

ユーザーリストと「入力中...」の表示を追加して、ユーザー体験を向上させましょう。

7.1 サーバー側:状態管理

// app.js でユーザーとタイピング状態を追跡
const usersInRooms = new Map();
const typingUsers = new Map();

io.on('connection', (socket) => {
   // ルーム参加時の初期化
   socket.on('join room', (room) => {
     // ... 既存のルーム参加コード ...

     if (!usersInRooms.has(room)) {
         usersInRooms.set(room, new Map());
         typingUsers.set(room, new Set());
     }

     usersInRooms.get(room).set(socket.id, {
         username: socket.username,
         id: socket.id
     });
    
     updateUserList(room);
   });

   // タイピング状態の処理
   socket.on('typing', (isTyping) => {
     const room = Array.from(socket.rooms).find(r => r !== socket.id);
     if (!room) return;
    
     if (isTyping) {
         typingUsers.get(room).add(socket.username);
     } else {
         typingUsers.get(room).delete(socket.username);
     }
    
     io.to(room).emit('typing users', Array.from(typingUsers.get(room)));
   });

   // 切断時の処理
   socket.on('disconnect', () => {
     Array.from(usersInRooms.entries()).forEach(([room, users]) => {
         if (users.has(socket.id)) {
            users.delete(socket.id);
            typingUsers.get(room)?.delete(socket.username);
            updateUserList(room);
         }
     });
   });
});

function updateUserList(room) {
  const users = Array.from(usersInRooms.get(room)?.values() || []);
  io.to(room).emit('user list', {
      room: room,
      users: users.map(u => ({
         username: u.username,
         isTyping: typingUsers.get(room)?.has(u.username) || false
      }))
  });
}

8. クライアントサイドAPIの概要

クライアントサイドの Socket.IO API は以下のメソッドを提供します:

  • io(): サーバーに接続します。
  • socket.emit(): サーバーにイベントを送信します。
  • socket.on(): サーバーからのイベントをリッスン(受信)します。
  • socket.disconnect(): サーバーから切断します。

8.1 Socket.IO イベント

Socket.IO は通信にイベントベースのアーキテクチャを使用します。

ビルトインイベント:

  • connect: 接続時に発生
  • disconnect: 切断時に発生
  • error: エラー発生時に発生
  • reconnect: 再接続成功時に発生
  • reconnect_attempt: 再接続の試行中に発生

9. Socket.IO ミドルウェア

Socket.IO では、認証などの目的でミドルウェア関数を定義できます。

const io = new Server(server);

// 認証用ミドルウェア
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  
  if (!token) {
    return next(new Error('認証エラー: トークンがありません'));
  }
  
  // トークンの検証 (JWTの例)
  try {
    const user = jwt.verify(token, 'your-secret-key');
    socket.user = user;
    next();
  } catch (error) {
    next(new Error('認証エラー: 無効なトークンです'));
  }
});

io.on('connection', (socket) => {
  console.log(`認証済みユーザーが接続しました: ${socket.user.username}`);
});

10. Socket.IO vs ネイティブ WebSocket

機能Socket.IOネイティブ WebSocket
フォールバック機構あり (HTTPロングポーリング等)なし
自動再接続ありなし (手動実装が必要)
ブロードキャスト標準機能手動実装が必要
ルーム/ネームスペース標準機能手動実装が必要
ブラウザサポートすべてのブラウザモダンブラウザのみ
パケットサイズ大きい (プロトコルオーバーヘッド)小さい
バイナリデータサポート済みサポート済み

Socket.IO は、信頼性、互換性、および高度な機能が必要な場合に適しています。一方、ネイティブ WebSocket はより軽量でオーバーヘッドが少ないのが特徴です。