【PHP】Server-Sent Events を用いて1リクエストの中でファイルの中身を監視し続ける方法

  • 2023年5月17日
  • PHP

 SSE(Server-Sent Events サーバー送信イベント)という技術があります。これはウェブサーバーからクライアント(通常はウェブブラウザ)へリアルタイムに情報をプッシュするための技術です。JavaScriptには EventSource という形でこれを使う方法が用意されています

サーバー送信イベントの使用 – Web API | MDN
EventSource – Web API | MDN

 SSEを使うことでサーバー内部でファイルを監視、ファイルに変化があったらこれをクライアントに送信、クライアント上のJavaScriptで受け取ったデータをいい感じに都度描画、とできます。これを使うことでファイルの監視などのリアルタイム性が欲しい機能をウェブサーバーとJavaScriptだけで比較的簡易に実装できます。この記事ではPHPを用いてファイルの監視とファイルの中身の都度送信、JavaScriptでそれを受け取って描画する仕組みを紹介します。

 実際に実装したソースコードが次です。

<?php
// monitor.php


/**
 * Server-Sent Eventsを利用してファイルの内容を一行ずつ送信します。
 * ファイルを監視し、変更があり次第送信します。
 * @param  string  $filepath 監視対象ファイルのパス
 * @param  string  $separator 改行コード
 * @return never
 */
function monitorFile(string $filepath, string $separator = "\n"): never
{
    /** Server-Sent Events を使うためのヘッダー定義 */
    // Content-Typeをtext/event-streamに設定し、イベントストリームを利用することをブラウザに伝えます。
    header('Content-Type: text/event-stream');
    // Cache-Controlをno-storeに設定し、ブラウザがこの応答をキャッシュしないように指示します。
    header('Cache-Control: no-store');

    // Connectionをkeep-aliveに設定し、この接続が継続的に活動していることをクライアントに伝えます。
    header('Connection: keep-alive');
    // X-Accel-Bufferingをnoに設定し、nginxのバッファリングを無効化します。これによりリアルタイム性が保たれます。
    // @see https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/
    header('X-Accel-Buffering: no');

    /** 監視に使う変数の準備 */
    // 指定のファイルが存在しない場合、処理を中止します。
    if(!file_exists($filepath)) {
        die('file not found');
    }

    // 前回読み込んだファイルサイズを初期化します。
    // この値でファイルの読み込み位置を制御し、毎回全体を読むことを防ぎます。
    $lastSize = 0;
    // 前回の読み込みで完全に読み込まれなかった行の残り部分を保持します。
    $remainder = '';
    /** 監視、送信ループ */
    while(true) {
        // ファイルの情報キャッシュをクリアします。これにより常に最新のファイルサイズを取得できます。
        clearstatcache(true, $filepath);
        // 現在のファイルサイズを取得します。
        $currentSize = filesize($filepath);

        // 現在のファイルサイズが前回読み込んだサイズより大きい場合、新たなデータが追加されたと判断します。
        if($currentSize > $lastSize) {
            // ファイルを読み込みモードで開きます。
            $file = fopen($filepath, 'r');
            // 前回読み込んだ後の位置に移動します。
            fseek($file, $lastSize);

            // 一度に4096バイトずつ読み込みます。
            while($block = fread($file, 4096)) {
                // 読み込んだ部分を改行で分割し、行単位で配列に格納します。
                $lines = explode($separator, $remainder . $block);

                // 最後の行は完全に読み込まれていない可能性があるので、抜き出して保持しておきます。
                $remainder = array_pop($lines);

                // 各行に対して、SSEのdataフィールドを設定して送信します。
                foreach($lines as $line) {
                    echo "data: " . json_encode(compact('line')) . "\n\n";
                }
            }

            // ファイルを閉じます。
            fclose($file);
            // 前回読み込んだファイルサイズを更新します。次回の比較のために現在のファイルサイズを保存します。
            $lastSize = $currentSize;
        }

        // 出力バッファリングが有効な場合(レベルが0より大きい場合)、バッファをフラッシュします。
        // これにより、バッファに溜まったデータがすぐにクライアントに送信されます。
        if(ob_get_level() > 0) {
            ob_end_flush();
        }
        // バッファをフラッシュし、書き込まれたデータを即座にクライアントに送信します。
        flush();
        // 1秒間スリープします。これにより、このスクリプトが無制御にループを続けてCPUを過剰に使用することを防ぎます。
        sleep(1);
    }
}

// 使用例
monitorFile(__DIR__ . '/tmp.log');

<html>
<head>
    <title>file viewer</title>
</head>
<body>
<!-- ファイルの内容を表示する要素。preで改行も見やすくする -->
<pre id="content"></pre>
<script>
document.addEventListener('DOMContentLoaded', () => {
    const content = document.getElementById('content');
    // SSEを用いた通信のためのEventSourceオブジェクトを用意。上記の PHP ソースコードである /monitor.php に接続します。
    const source = new EventSource('/monitor.php');

    // EventSourceが接続を開始したときに、コンソールに'open'と表示します。これは接続が正常に行われたことを確認するためのものです。
    source.onopen = () => console.log('open')

    // EventSourceがメッセージを受信したときの処理を定義します。
    // メッセージはイベントとして渡され、そのデータ部分を取得するためにevent.dataを使用します。
    source.onmessage = function (event) {
        if (event.data == null) {
            // メッセージがnullの場合は何もしない
            return;
        }
        // メッセージのデータ部分(JSON形式の文字列)をJavaScriptオブジェクトに変換します。
        // メッセージは単に文字列として送られてきます。ここで JSON.parse できるのは PHP で json_encode(compact('line')) としたからです。
        const data = JSON.parse(event.data);
        // データからlineプロパティを取り出し、それをcontent要素の末尾に追加します。これにより、ファイルの新しい行が順次表示されます。
        content.innerHTML += data.line + "\n";
    };
});
</script>
</body>
</html>

 これでPHPとJavaScriptを用いてファイルのリアルタイム監視を行う仕組みができました。上記のコードでは、monitorFile関数がファイルを監視、ファイルの中身をクライアントに送信、送信された内容を単に表示、としていますが、ファイルに構造がある場合などは何か前処理を行うことでより扱いやすいものにできますし、出力ももっと見やすいものにする余地があります。

 while(true) { /* 処理; */ sleep(1); }の部分からなんとなく想像がつくようにPHPのSSEはサーバーが何かリソースを監視し続けて、イベントが起き次第クライアントにそれを伝えるという構造になりやすいです。このためサーバーにとっての負荷は定期的な監視に近い形になりやすいです。もしサーバーへの負荷も大きく軽くしたいのであれば正直PHP自体をやめた方が楽です。SSEによってパフォーマンスが最適化されやすいのは通信路です。クライアントが定期的にHTTPでPHPを呼び出す形をとる場合、呼び出しの度に通信を確立したりフレームワークを立ち上げたりするための無駄が貯まり、通信内容によってはユーザーにとんでもない通信量(契約によっては通信料も)を負担させてしまいます。SSEを用いることでこれを回避できます。

 SSEを利用すればリアルタイム性が必要なウェブアプリケーションを実装できます。似たようなことをする方法としてsetIntervalを用いた定期的な監視、WebSocket、Firebase(実体はWebSocketですがWebSocketを意識せずに使えます)などがあります。実装のコスト、パフォーマンスなどを比べて適切なものを選ぶとよりよいプログラミングができます。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG