12
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WebSocketを使わないでRedis + JavaScriptでリアルタイム監視のプレゼンス機能を作る

12
Posted at

はじめに

「このページを今、誰が見ているか」をリアルタイムで表示する機能はよく見ると思います(Googleスプレッドシートなど)
このような複数ユーザーの存在をリアルタイムに共有する機能はプレゼンス(Presence)機能と呼ばれます。

本記事では、WebSocketを使わずに、Redis + JavaScript + PHPでプレゼンス機能を実装する方法を解説します。

完成イメージ

  • ユーザーがページを開くと、他の閲覧者のアバターが右上に表示される
  • 誰かが離脱すると、リアルタイムでアイコンが消える
  • 60秒操作がないとアイドル状態となり、ポーリングが自動停止

なぜWebSocketを使わないのか?

WebSocketは確かにリアルタイム通信の王道ですが、以下の理由から今回はLong Pollingを採用しました:

観点 WebSocket Long Polling
インフラ構成 専用サーバー/ロードバランサー設定が必要 既存のHTTPサーバーで動作
実装複雑度 接続管理、再接続処理が必要 シンプルなHTTPリクエスト

あとはシンプルに時間的制約により、今回はWebSocketを使わずに実装しました(笑)


アーキテクチャ概要

コンポーネントの役割

コンポーネント 役割
PollingClient サーバーへのポーリング制御、アイドル検知、離脱通知
PresenceUI 閲覧者アバターの表示/非表示、アニメーション制御
PollingController HTTPエンドポイント、Long Pollingループ制御
PresenceService Redis操作、ハッシュ計算、非アクティブユーザー削除

技術選定のポイント

技術 選定理由
Long Polling WebSocket不要でシンプル、サーバー負荷が予測可能
MD5ハッシュ比較 配列全体の比較より高速、変更時のみデータ送信
Redis TTLによる自動クリーンアップ、高速なSet/Hash操作

処理フロー

シーケンス図


実装詳細

Redis キー設計

キー データ型 用途 TTL
presence:{resourceId} Set 閲覧中のユーザーID集合 60秒
presence:{resourceId}:details Hash ユーザー詳細情報 (JSON) 60秒

ポイント: SetとHashを分けることで、「誰がいるか」の高速な集合演算と「詳細情報」の個別取得を両立しています。

PHP側:PresenceService

<?php

class PresenceService
{
    private const TTL_SECONDS = 60;
    private const INACTIVE_THRESHOLD = 60;

    private $redis;
    private $resourceId;
    private $viewerId;
    private $viewerName;

    public function __construct(
        Redis $redis,
        string $resourceId,
        string $viewerId,
        string $viewerName
    ) {
        $this->redis = $redis;
        $this->resourceId = $resourceId;
        $this->viewerId = $viewerId;
        $this->viewerName = $viewerName;
    }

    /**
     * 閲覧者として登録
     */
    public function register(): void
    {
        $key = $this->getPresenceKey();
        $detailKey = $this->getDetailKey();

        // Setに追加
        $this->redis->sAdd($key, $this->viewerId);
        $this->redis->expire($key, self::TTL_SECONDS);

        // 詳細情報を保存
        $detail = json_encode([
            'viewerId' => $this->viewerId,
            'viewerName' => $this->viewerName,
            'joinedAt' => time(),
            'lastActiveAt' => time(),
        ]);
        $this->redis->hSet($detailKey, $this->viewerId, $detail);
        $this->redis->expire($detailKey, self::TTL_SECONDS);
    }

    /**
     * アクティビティを更新(ポーリング時に呼び出し)
     */
    public function updateActivity(): void
    {
        $detailKey = $this->getDetailKey();
        $rawData = $this->redis->hGet($detailKey, $this->viewerId);

        if ($rawData === false) {
            $this->register();
            return;
        }

        $detail = json_decode($rawData, true);
        $detail['lastActiveAt'] = time();

        $this->redis->hSet($detailKey, $this->viewerId, json_encode($detail));
        $this->redis->expire($this->getPresenceKey(), self::TTL_SECONDS);
        $this->redis->expire($detailKey, self::TTL_SECONDS);
    }

    /**
     * 非アクティブなユーザーを削除
     */
    public function cleanupInactive(): void
    {
        $key = $this->getPresenceKey();
        $detailKey = $this->getDetailKey();
        $viewerIds = $this->redis->sMembers($key);
        $now = time();

        foreach ($viewerIds as $viewerId) {
            $rawData = $this->redis->hGet($detailKey, $viewerId);
            if ($rawData === false) {
                $this->redis->sRem($key, $viewerId);
                continue;
            }

            $detail = json_decode($rawData, true);
            if ($now - $detail['lastActiveAt'] > self::INACTIVE_THRESHOLD) {
                $this->redis->sRem($key, $viewerId);
                $this->redis->hDel($detailKey, $viewerId);
            }
        }
    }

    /**
     * 閲覧者一覧とハッシュ値を取得
     */
    public function getViewersWithHash(): array
    {
        $detailKey = $this->getDetailKey();
        $allDetails = $this->redis->hGetAll($detailKey);

        $viewers = [];
        foreach ($allDetails as $viewerId => $rawData) {
            $detail = json_decode($rawData, true);
            $viewers[] = [
                'viewerId' => $detail['viewerId'],
                'viewerName' => $detail['viewerName'],
                'joinedAt' => $detail['joinedAt'],
            ];
        }

        // ハッシュ計算用にソート(joinedAt降順、同値ならviewerId昇順)
        usort($viewers, function ($a, $b) {
            if ($a['joinedAt'] !== $b['joinedAt']) {
                return $b['joinedAt'] - $a['joinedAt'];
            }
            return strcmp($a['viewerId'], $b['viewerId']);
        });

        // lastActiveAtを除外してハッシュ計算(ノイズ除去)
        $hash = md5(json_encode($viewers));

        return [
            'viewers' => $viewers,
            'hash' => $hash,
        ];
    }

    /**
     * ハッシュ値が変化したかチェック
     */
    public function hasChanged(string $lastHash): bool
    {
        $current = $this->getViewersWithHash();
        return $current['hash'] !== $lastHash;
    }

    /**
     * 離脱処理
     */
    public function leave(): void
    {
        $this->redis->sRem($this->getPresenceKey(), $this->viewerId);
        $this->redis->hDel($this->getDetailKey(), $this->viewerId);
    }

    private function getPresenceKey(): string
    {
        return "presence:{$this->resourceId}";
    }

    private function getDetailKey(): string
    {
        return "presence:{$this->resourceId}:details";
    }
}

PHP側:PollingController

<?php

class PollingController
{
    private const MAX_ATTEMPTS = 20;
    private const INTERVAL_SECONDS = 3;

    /**
     * ポーリングエンドポイント
     * POST /api/polling
     */
    public function pollingAction(): void
    {
        $resourceId = $_POST['resourceId'];
        $viewerId = $_SESSION['viewerId'];
        $viewerName = $_SESSION['viewerName'];
        $lastHash = $_POST['lastHash'] ?? '';

        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);

        $presenceService = new PresenceService(
            $redis,
            $resourceId,
            $viewerId,
            $viewerName
        );

        // Long Polling: 変更があるまでサーバー側でループ
        for ($i = 0; $i < self::MAX_ATTEMPTS; $i++) {
            // 非アクティブユーザーをクリーンアップ
            $presenceService->cleanupInactive();

            // 自分のアクティビティを更新
            $presenceService->updateActivity();

            // 変更チェック
            if ($presenceService->hasChanged($lastHash)) {
                $data = $presenceService->getViewersWithHash();
                echo json_encode([
                    'hasChanged' => true,
                    'viewers' => $data['viewers'],
                    'hash' => $data['hash'],
                ]);
                return;
            }

            // 変更なし → 待機
            sleep(self::INTERVAL_SECONDS);
        }

        // タイムアウト
        echo json_encode(['hasChanged' => false]);
    }

    /**
     * 離脱エンドポイント(Beacon API用)
     * POST /api/leave
     */
    public function leaveAction(): void
    {
        $resourceId = $_POST['resourceId'];
        $viewerId = $_SESSION['viewerId'];

        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);

        $presenceService = new PresenceService(
            $redis,
            $resourceId,
            $viewerId,
            ''
        );

        $presenceService->leave();

        echo json_encode(['success' => true]);
    }
}

JavaScript側:PollingClient

PollingClientはプレゼンス機能のフロントエンド側の中核となるクラスです。
主に3つの責務を持ちます。

PollingClientの責務

1. ポーリング制御の詳細

ポーリングは再帰的な非同期関数として実装しています。

async startPolling() {
    // ① アイドル状態ならポーリング停止
    if (this.#isIdle) {
        console.log('User is idle, stopping polling');
        return;  // ここで再帰を止める
    }

    try {
        // ② サーバーにリクエスト送信
        const response = await fetch('/api/polling', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                resourceId: this.#resourceId,
                lastHash: this.#lastHash,  // 前回のハッシュ値を送信
            }),
        });

        const result = await response.json();

        // ③ 変更があればUI更新
        if (result.hasChanged) {
            this.#presenceUI.updateViewers(result.viewers);
            this.#lastHash = result.hash;  // ハッシュを更新
        }

        // ④ 再帰的にポーリング継続(無限ループ)
        this.startPolling();

    } catch (error) {
        // ⑤ エラー時は5秒待ってリトライ
        console.error('Polling error:', error);
        setTimeout(() => this.startPolling(), 5000);
    }
}

ポイント解説:

番号 処理 説明
アイドルチェック ユーザーが非アクティブなら即座にreturn
fetch送信 lastHashを送ることで、サーバー側で変更の有無を判定
UI更新 変更があった場合のみUIを更新し、新しいハッシュを保存
再帰呼び出し awaitで完了を待ってから次のリクエストを開始
エラーハンドリング ネットワークエラー時は少し待ってからリトライ

2. アイドル検知の詳細

ユーザーが画面上を一定時間操作(クリック・入力・スクロール等)しない場合、
サーバー負荷を軽減するためにポーリングを停止します。

#setupIdleDetection() {
    const resetIdleTimer = () => {
        // ① アクティブ状態に戻す
        this.#isIdle = false;

        // ② 既存のタイマーをクリア
        if (this.#idleTimer) {
            clearTimeout(this.#idleTimer);
        }

        // ③ 新しいタイマーをセット(60秒後にアイドル状態へ)
        this.#idleTimer = setTimeout(() => {
            this.#isIdle = true;
            console.log('User became idle');
        }, PollingClient.IDLE_TIMEOUT_MS);  // 60000ms = 60秒
    };

    // ④ 監視するDOMイベントを登録
    document.addEventListener('click', resetIdleTimer);
    document.addEventListener('keyup', resetIdleTimer);
    document.addEventListener('mousemove', resetIdleTimer);
    document.addEventListener('scroll', resetIdleTimer);

    // ⑤ 初期化時にタイマー開始
    resetIdleTimer();
}

アイドル検知の仕組み:

  1. ページ読み込み時に60秒のタイマーを開始
  2. ユーザーがクリック・入力・スクロール等の操作をするたびにタイマーをリセット
  3. 60秒間操作がなければ#isIdle = trueになる
  4. 次のstartPolling()呼び出し時にチェックされ、ポーリング停止

監視対象のイベント:

イベント 検知する操作
click マウスクリック、タップ
keyup キーボード入力
mousemove マウス移動
scroll ページスクロール

3. 離脱通知(Beacon API)の詳細

ユーザーがページを閉じる際、サーバーに離脱を通知します。

#setupBeforeUnload() {
    window.addEventListener('beforeunload', () => {
        // Beacon APIで離脱通知
        const formData = new FormData();
        formData.append('resourceId', this.#resourceId);

        navigator.sendBeacon('/api/leave', formData);
    });
}

なぜBeacon APIを使うのか?

  • 通常のfetch: ページ離脱時に中断される可能性がある
  • Beacon API: ブラウザがバックグラウンドで送信を保証

完全なPollingClientクラス

class PollingClient {
    #resourceId;
    #lastHash;
    #isIdle = false;
    #idleTimer = null;
    #presenceUI;

    static IDLE_TIMEOUT_MS = 60000; // 60秒

    constructor(resourceId, initialHash, presenceUI) {
        this.#resourceId = resourceId;
        this.#lastHash = initialHash;
        this.#presenceUI = presenceUI;

        this.#setupIdleDetection();
        this.#setupBeforeUnload();
    }

    /**
     * ポーリング開始
     */
    async startPolling() {
        // アイドル状態ならポーリング停止
        if (this.#isIdle) {
            console.log('User is idle, stopping polling');
            return;
        }

        try {
            const response = await fetch('/api/polling', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    resourceId: this.#resourceId,
                    lastHash: this.#lastHash,
                }),
            });

            const result = await response.json();

            if (result.hasChanged) {
                // UI更新
                this.#presenceUI.updateViewers(result.viewers);
                // ハッシュを更新
                this.#lastHash = result.hash;
            }

            // 再帰的にポーリング継続
            this.startPolling();

        } catch (error) {
            console.error('Polling error:', error);
            // エラー時は少し待ってからリトライ
            setTimeout(() => this.startPolling(), 5000);
        }
    }

    /**
     * アイドル検知のセットアップ
     */
    #setupIdleDetection() {
        const resetIdleTimer = () => {
            this.#isIdle = false;

            if (this.#idleTimer) {
                clearTimeout(this.#idleTimer);
            }

            this.#idleTimer = setTimeout(() => {
                this.#isIdle = true;
                console.log('User became idle');
            }, PollingClient.IDLE_TIMEOUT_MS);
        };

        // ユーザー操作を監視
        document.addEventListener('click', resetIdleTimer);
        document.addEventListener('keyup', resetIdleTimer);
        document.addEventListener('mousemove', resetIdleTimer);
        document.addEventListener('scroll', resetIdleTimer);

        // 初期化
        resetIdleTimer();
    }

    /**
     * ページ離脱時の処理
     */
    #setupBeforeUnload() {
        window.addEventListener('beforeunload', () => {
            // Beacon APIで離脱通知
            const formData = new FormData();
            formData.append('resourceId', this.#resourceId);

            navigator.sendBeacon('/api/leave', formData);
        });
    }
}

JavaScript側:PresenceUI

PresenceUIは閲覧者アバターの表示を担当するクラスです。

PresenceUIの責務

updateViewers(viewers) {
    // ① 自分以外の閲覧者をフィルタ
    const otherViewers = viewers.filter(v => v.viewerId !== currentViewerId);

    // ② 0人なら非表示
    if (otherViewers.length === 0) {
        this.#containerElement.innerHTML = '';
        return;
    }

    // ③ アバターHTML生成
    let html = '<div class="presence-avatars">';

    // 最大5人までアバター表示
    const visibleViewers = otherViewers.slice(0, this.#maxVisibleAvatars);
    visibleViewers.forEach(viewer => {
        html += `
            <div class="presence-avatar" title="${viewer.viewerName}">
                ${this.#getInitial(viewer.viewerName)}
            </div>
        `;
    });

    // ④ 5人を超える場合は +N バッジ
    if (otherViewers.length > this.#maxVisibleAvatars) {
        const remaining = otherViewers.length - this.#maxVisibleAvatars;
        html += `<div class="presence-badge">+${remaining}</div>`;
    }

    html += '</div>';

    // ⑤ DOM更新
    this.#containerElement.innerHTML = html;

    // ⑥ 自動フェードアウト開始
    this.#showWithAutoHide();
}

表示例:

閲覧者数 表示
0人 (非表示)
3人 [A] [B] [C]
7人 [A] [B] [C] [D] [E] +2

自動フェードアウトの仕組み

閲覧者アイコンは常に表示していると邪魔になるため、6.5秒後に自動的にフェードアウトします。

#showWithAutoHide() {
    // ① 表示状態にする
    this.#containerElement.classList.add('visible');

    // ② 既存のタイマーをクリア(連続更新時の対策)
    if (this.#autoHideTimer) {
        clearTimeout(this.#autoHideTimer);
    }

    // ③ 6.5秒後にフェードアウト開始
    this.#autoHideTimer = setTimeout(() => {
        this.#containerElement.classList.remove('visible');
        this.#containerElement.classList.add('fade-out');
    }, 6500);
}

ポイント: 連続してupdateViewers()が呼ばれた場合、タイマーをリセットして6.5秒を再カウントします。

完全なPresenceUIクラス

class PresenceUI {
    #containerElement;
    #maxVisibleAvatars = 5;
    #autoHideTimer = null;

    constructor(containerSelector) {
        this.#containerElement = document.querySelector(containerSelector);
    }

    /**
     * 閲覧者一覧を更新
     */
    updateViewers(viewers) {
        // 自分以外の閲覧者をフィルタ
        const otherViewers = viewers.filter(v => v.viewerId !== currentViewerId);

        if (otherViewers.length === 0) {
            this.#containerElement.innerHTML = '';
            return;
        }

        // アバター表示を生成
        let html = '<div class="presence-avatars">';

        const visibleViewers = otherViewers.slice(0, this.#maxVisibleAvatars);
        visibleViewers.forEach(viewer => {
            html += `
                <div class="presence-avatar" title="${viewer.viewerName}">
                    ${this.#getInitial(viewer.viewerName)}
                </div>
            `;
        });

        // 5人を超える場合は +N バッジ
        if (otherViewers.length > this.#maxVisibleAvatars) {
            const remaining = otherViewers.length - this.#maxVisibleAvatars;
            html += `<div class="presence-badge">+${remaining}</div>`;
        }

        html += '</div>';

        this.#containerElement.innerHTML = html;

        // 表示アニメーション
        this.#showWithAutoHide();
    }

    /**
     * 6.5秒後に自動フェードアウト
     */
    #showWithAutoHide() {
        this.#containerElement.classList.add('visible');

        if (this.#autoHideTimer) {
            clearTimeout(this.#autoHideTimer);
        }

        this.#autoHideTimer = setTimeout(() => {
            this.#containerElement.classList.remove('visible');
            this.#containerElement.classList.add('fade-out');
        }, 6500);
    }

    #getInitial(name) {
        return name.charAt(0).toUpperCase();
    }
}

CSSサンプル

/* コンテナ */
#presence-container {
    position: fixed;
    top: 20px;
    right: 20px;
    z-index: 1000;
    opacity: 0;
    transition: opacity 0.3s ease;
}

#presence-container.visible {
    opacity: 1;
}

#presence-container.fade-out {
    opacity: 0;
}

/* アバター */
.presence-avatars {
    display: flex;
    gap: 4px;
}

.presence-avatar {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background: #4a90d9;
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: bold;
    font-size: 14px;
}

/* +N バッジ */
.presence-badge {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background: #666;
    color: white;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 12px;
}

使用例(初期化)

// ページ読み込み時
document.addEventListener('DOMContentLoaded', () => {
    // サーバーから渡された初期値
    const resourceId = window.__RESOURCE_ID__;
    const initialHash = window.__INITIAL_HASH__;
    const initialViewers = window.__INITIAL_VIEWERS__;

    // UI初期化
    const presenceUI = new PresenceUI('#presence-container');
    presenceUI.updateViewers(initialViewers);

    // ポーリング開始
    const pollingClient = new PollingClient(resourceId, initialHash, presenceUI);
    pollingClient.startPolling();
});

パフォーマンス最適化のための工夫

1. ハッシュベース変更検知

閲覧者リストの変更を検知する際、配列全体を比較するのではなく、MD5ハッシュ値を比較します。

ポイント: lastActiveAtはポーリングのたびに更新されるため、ハッシュ計算から除外します。これにより、「実質的な変更」のみを検知できます。

// ハッシュ計算時に lastActiveAt を除外
// → ポーリングによる自動更新でのノイズを防止
$viewers = [
    ['viewerId' => '1', 'viewerName' => 'Alice', 'joinedAt' => 1234567890],
    ['viewerId' => '2', 'viewerName' => 'Bob', 'joinedAt' => 1234567891],
];
$hash = md5(json_encode($viewers)); // lastActiveAtは含めない

2. Long Polling による効率化

通常のポーリングでは、クライアントが一定間隔でリクエストを送信し続けます。
Long Pollingでは、サーバー側で変更があるまで待機するため、リクエスト数を大幅に削減できます。

通常ポーリング(3秒間隔):
  60秒間で20リクエスト

Long Polling:
  変更がなければ60秒で1リクエスト
  変更があれば即座にレスポンス

3. アイドル検知

ユーザーが60秒間操作しない場合、ポーリングを自動停止します。
これにより、放置されたタブによるサーバー負荷を防ぎます。


その他工夫点

ブラウザ終了時

ユーザーがブラウザを閉じた際、通常のHTTPリクエストは送信できません。
しかし、Beacon APIを使用することで、ページ離脱時に非同期でリクエストを送信できます。

window.addEventListener('beforeunload', () => {
    const formData = new FormData();
    formData.append('resourceId', this.#resourceId);
    navigator.sendBeacon('/api/leave', formData);
});

Beacon失敗時でも

Beacon APIが失敗した場合でも、以下のフォールバックにより、確実に離脱を検知できます:

  1. アクティビティベースのクリーンアップ: 次回ポーリング時にcleanupInactive()が60秒以上アクティビティのないユーザーを削除
  2. Redis TTL: すべてのキーに60秒のTTLを設定する

まとめ

本記事では、Redis + JavaScript + PHPを使用して、WebSocketを使わずにリアルタイムプレゼンス機能を実装する方法を解説しました。

採用した技術と設計判断

観点 選択 理由
リアルタイム通信 Long Polling WebSocket不要、既存インフラで動作
変更検知 MD5ハッシュ比較 高速、必要時のみデータ送信
データストア Redis Set/Hash 高速、TTLによる自動クリーンアップ
離脱検知 Beacon API + TTL 二重のフォールバック

活用できるケース

  • 同時編集の競合防止(管理画面など)
  • 閲覧者数の表示(商品ページ「今N人が見ています」)
  • 簡易的なオンライン状態表示

などで活用できるかと思います。

WebSocketのインフラ構築コストを避けたい場合に、ぜひ参考にしてみてください。

12
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?