軽量なリアルタイム通知:Node.jsでServer-Sent Events (SSE) をマスターする

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

全二重通信のオーバーヘッド

プロジェクトの要件に「リアルタイム」という言葉が出てきた瞬間,多くの開発者は反射的にWebSocketsを選択してしまいます。かつての私もそうでした。クライアントとサーバーの間でデータが自由に行き来する,永続的な双方向のパイプを想像するのです。机上では完璧に思えますが,通知機能やアクティビティフィード,ライブダッシュボードのような機能において,WebSocketsは解決する問題よりも多くの複雑さを持ち込むことが少なくありません。

高トラフィックのECプラットフォーム向けに通知システムを構築していた際,私は最初にWebSocketを採用しました。しかし,すぐに壁にぶつかりました。接続を維持するためのハートビートの管理,クライアント側での複雑な再接続ロジック,そしてHTTPからWSへのプロトコルアップグレードを適切に処理するためのNginxの設定などです。インフラチームは,何千ものアイドル状態の双方向接続を維持することによるメモリ消費を懸念していました。まさに「小さなナッツを割るのに大きなハンマーを使っている」状態だったのです。

なぜリアルタイム機能を複雑にしてしまうのか

この複雑さの根本的な原因は,通信ニーズの誤解にあります。Webアプリケーションにおけるリアルタイム機能のほとんどは「単方向」です。新しいメッセージ,決済の完了,ステータスの変更といったイベントがサーバー側で発生し,それをユーザーにプッシュする必要があります。ユーザーがその同じ永続的な接続を通じてデータを送り返す必要は,ほとんどありません。

WebSocketsは,マルチプレイヤーゲームや共同編集エディタのような,低遅延の双方向通信向けに設計されています。単純な通知にこれを使用すると,シンプルなストリームで十分な場合に,全二重プロトコルの管理を強いられることになります。さらに,WebSocketsは標準的なHTTPセマンティクスに従わないため,特定のプロキシ設定が必要になったり,Upgradeヘッダーを認識しないファイアウォールや企業プロキシによってブロックされたりする可能性があります。

Server-Sent Events:軽量な対抗馬

Server-Sent Events (SSE) は,よりスマートな道を提供します。WebSocketsとは異なり,SSEは完全に標準のHTTP上で動作します。サーバーがレスポンスを開いたままにし,特定のフォーマット(text/event-stream)でデータを送信し続ける,長寿命のHTTP接続を利用します。

SSEの素晴らしさは,そのシンプルさにあります。単なるHTTPであるため,既存のロードバランサー,ファイアウォール,認証ロジックをそのまま利用できます。

ブラウザのEventSource APIは,リアルタイムプログラミングで最も厄介な「自動再接続」を処理してくれます。接続が切れた場合,setIntervalバックオフロジックを一行も書くことなく,ブラウザが再接続を試みてくれます。私はこのアプローチを本番環境に適用してきましたが,特にリソース管理とメンテナンスの容易さの観点から,一貫して安定した結果が得られています。

実践:Node.jsによる通知システムの構築

これがどれほどシンプルかを示すために,Node.jsExpressを使って最小限の通知サーバーを構築してみましょう。クライアントが購読(サブスクライブ)するためのエンドポイントと,通知を「トリガー」するためのセカンダリエンドポイントを作成します。

1. サーバーのセットアップ

まず,プロジェクトを初期化してExpressをインストールします:

mkdir sse-notifications
cd sse-notifications
npm init -y
npm install express

次に,server.jsを作成します。このスクリプトは,接続されたクライアントのリストを管理し,新しいイベントが発生したときにデータをプッシュします。

const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.json());
app.use(express.static('public'));

// アクティブなクライアント接続を保存
let clients = [];

app.get('/events', (req, res) => {
    // SSEに必要なヘッダー
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // クライアントを登録
    const clientId = Date.now();
    const newClient = {
        id: clientId,
        res
    };
    clients.push(newClient);

    // 接続が閉じられたらクライアントを削除
    req.on('close', () => {
        clients = clients.filter(client => client.id !== clientId);
    });
});

// 通知をトリガーするエンドポイント
app.post('/notify', (req, res) => {
    const message = req.body.message || '新しい通知があります!';
    
    // 接続されているすべてのクライアントにデータを送信
    clients.forEach(client => {
        client.res.write(`data: ${JSON.stringify({ message, time: new Date() })}\n\n`);
    });

    res.status(200).send({ success: true });
});

app.listen(PORT, () => {
    console.log(`SSEサーバーが http://localhost:${PORT} で起動しました`);
});

2. クライアントの作成

クライアント側の実装はさらに簡単です。外部ライブラリは一切不要で,EventSource APIはすべてのモダンブラウザに標準搭載されています。

public/index.htmlファイルを作成します:

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>SSE通知システム</title>
</head>
<body>
    <h1>ライブ通知</h1>
    <ul id="notif-list"></ul>

    <script>
        const eventSource = new EventSource('/events');
        const list = document.getElementById('notif-list');

        eventSource.onmessage = (event) => {
            const data = JSON.parse(event.data);
            const li = document.createElement('li');
            li.textContent = `[${new Date(data.time).toLocaleTimeString()}] ${data.message}`;
            list.prepend(li);
        };

        eventSource.onerror = (err) => {
            console.error("EventSourceが失敗しました:", err);
        };
    </script>
</body>
</html>

3. システムのテスト

サーバーを起動します:

node server.js

ブラウザでhttp://localhost:3000を複数のタブで開きます。次に,curlやPostmanのようなツールを使って通知をトリガーします:

curl -X POST http://localhost:3000/notify \
     -H "Content-Type: application/json" \
     -d '{"message": "サーバーからのこんにちは!"}'

開いているすべてのタブに即座に通知が表示されるはずです。複雑なハンドシェイクも,ソケット管理も不要。純粋なHTTPストリーミングだけです。

スケーリングと本番環境での現実

SSEを本番環境に導入する際,私が常に念頭に置いていることが2つあります。1つ目はブラウザの接続制限です。ほとんどのブラウザは,同一ドメインに対するHTTP/1.1の同時接続数を6つに制限しています。ユーザーがアプリを7つのタブで開くと,7つ目の接続は失敗します。解決策は簡単で,HTTP/2を使用することです。HTTP/2ではこれらの接続が多重化(マルチプレックス)されるため,単一のTCP接続でより多くのストリームを処理できます。

2つ目の考慮事項は,無通信時の接続維持です。一部のプロキシやロードバランサーは,30秒や60秒間データが送信されない「アイドル状態」のHTTP接続を切断することがあります。私のテクニックは,「ハートビート」を実装することです。15秒ごとに小さなコメント行(: keep-alive\n\n)を送信します。SSEはコロンで始まる行を無視するため,クライアント側でイベントを発生させることなく,パイプを開いたままに保つことができます。

適切なツールの選択

WebSocketsが時代遅れになったわけではありませんが,それは専門的な用途のためのものです。ユーザーが常にメッセージを送り合うチャットアプリケーションや,リアルタイムの共同編集ホワイトボードを構築する場合は,WebSocketsを使い続けてください。そこではオーバーヘッドに見合う価値があります。

しかし,リアルタイム通知の需要の9割(レポートの準備完了通知,プログレスバーの更新,ライブ価格の変動など)において,SSEは優れたアーキテクチャの選択肢となります。デバッグが容易で(Chrome DevToolsのNetworkタブでストリームを確認できます),スケーリングしやすく,実装もはるかにシンプルです。次にSocket.ioを使おうとしたときは,一度要件を見直してみてください。もしデータフローが主に一方通行であれば,苦労を避けてServer-Sent Eventsを使いましょう。

Share: