概要
時折、サーバ側において長い処理を作り、処理の進捗状況を都度クライアントに伝えたい時があります。この記事ではそれを実現するコードを紹介します。
動作例が次で、
コードが次です。
クリックしてソースコードを展開
<?php // Laravel のルーティングを使っていますが、処理は素のPHPそのままなのでフレームワーク問わず動作します。 Route::get('progress/viewer', static fn () => view('progress_viewer')); Route::get('progress/value', static function () { $separator = '<MESSAGE_PACK_END>'; // 都度出力するメッセージの区切り(終端)を示す文字列 $max = 1000; // 例ではランダムに増加するパーセンテージをレスポンスにするのでそのための値 for ($i=0; $i < $max; $i += random_int(0, 300)) { $msg = ($i / $max) * 100; // ランダムに増加するパーセンテージ echo "{$msg}{$separator}"; // メッセージ本体とその終端を示す文字列を書き込みバッファに対して出力 flush(); // 書き込みバッファ内に保存した値を出力バッファへ明示的に受け渡し sleep(1); // 例では一瞬でレスポンスを終わらせないために毎秒スリープ } echo "100{$separator}"; // 処理終了を示す文字列。↑のループはほぼ確実に 9x.x を出力して終わるので特別に出力 // 最後はflush, ob_flushなどをせずともPHP終了時にwebサーバが勝手にまとめて出力してくれる });
<style> .control { display: flex; flex-direction: column; } .event-msg-box { width: 3.25em; text-align: end; } .progress-view-box{ display: flex; align-items: center; } </style> <div style="display: flex; flex-direction: row"> <div class="control xhr"> <input class="xhr" type="button" name="xhr" value="長い処理を開始(XHR)" /> <!-- プログレスバーとパーセンテージ表示で進捗を表現 --> <div class="progress-view-box"> <progress class="event-progress" max="100">0%</progress> <div class="event-msg-box">0%</div> </div> </div> <div class="control axios"> <input class="axios" type="button" name="axios" value="長い処理を開始(axios)" /> <!-- プログレスバーとパーセンテージ表示で進捗を表現 --> <div class="progress-view-box"> <progress class="event-progress" max="100">0%</progress> <div class="event-msg-box">0%</div> </div> </div> </div> <script> /** * レスポンスを元にプログレスバーとパーセンテージ表記に進捗を書き込む関数を返す * @param {HTMLElement} logMsgBox * @param {HTMLProgressElement} logProgress * @return {Function} (responseText: string) => void */ const createProgressWriter = (logMsgBox, logProgress) => (responseText) => { // responseText はレスポンス全体を含むので、メッセージの区切りを決めておき、その末尾のみを取得する const v = responseText.replace(/(.*<MESSAGE_PACK_END>)?(.*?)<MESSAGE_PACK_END>/g,'$2'); // 取得したメッセージを各要素に適切な形で書き込み logProgress.value = v; logMsgBox.innerText = `${v}%`; } const xhrElement = document.querySelector('.control.xhr'); xhrElement.addEventListener('click', () => { // xhrElement 以下の進捗書き込み先を渡して書き込み用関数を生成 const writer = createProgressWriter(xhrElement.querySelector('.event-msg-box'), xhrElement.querySelector('.event-progress')) const xhr = new XMLHttpRequest(); // XHR の progress イベントを用いて適宜通信の中身を処理する // @see https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest/progress_event xhr.addEventListener('progress', event => writer(event.target.responseText)); // 進捗を返す機能を持つ API へ GET メソッドで通信 xhr.open("GET", 'http://default.docker-host.local/progress/value'); xhr.send(); }); const axiosElement = document.querySelector('.control.axios'); axiosElement.addEventListener('click', () => { // axiosElement 以下の進捗書き込み先を渡して書き込み用関数を生成 const writer = createProgressWriter(axiosElement.querySelector('.event-msg-box'), axiosElement.querySelector('.event-progress')) // 進捗を返す機能を持つ API へ GET メソッドで通信 axios.get('http://default.docker-host.local/progress/value',{ // XHR 同様にの progress イベントを用いて適宜通信の中身を処理する onDownloadProgress: (event) => writer(event.target.responseText), }); }); </script> <!-- 例のために CDN から axios を読み込み --> <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.20.0/axios.min.js" integrity="sha512-quHCp3WbBNkwLfYUMd+KwBAgpVukJu5MncuQaWXgCrfgcxCJAq/fo+oqrRKOj+UKEmyMCG3tb8RB63W+EmrOBg==" crossorigin="anonymous"></script>
逐次出力をする PHP と逐次出力を読み取る JavaScript が主役です。
PHP
PHPの処理では出力制御関数群を用います。これは主に出力の向き先とタイミングを操作する関数です。これを用いて逐次出力を実現します。
PHP: 出力制御 関数 – Manual
flush は書き込みバッファ内のデータを出力バッファに手動で渡すための関数です。この関数はPHPマニュアルの説明中の概要部が誤読を招きやすいのできっちり詳細部を読みましょう(後述のob_flushの説明とほぼ同じなので概要を誤って読んでも疑問を持ちやすいのが救いです)。
PHP: flush – Manual
echo "{$msg}{$separator}"; // メッセージ本体とその終端を示す文字列を書き込みバッファに対して出力 flush(); // 書き込みバッファ内に保存した値を出力バッファへ明示的に受け渡し
自分の環境では単に書き込みバッファ→出力バッファへ渡せばそのままブラウザ送りにできましたが、環境によってはできません。第一に引っかかるのが出力バッファのバッファリングです。もし、PHPに出力バッファのバッファリングサイズが0以上が設定されていた場合(大体 php.ini の output_buffering にあります)、出力のバッファリングが有効になり flush のみではブラウザに出力されず、メッセージは出力バッファにしばらく滞留します。これを制御するためには出力制御関数の ob_xxxx を使います(ob は Output Buffer の略です)。具体的には次の様にします。
echo "{$msg}{$separator}"; // メッセージ本体とその終端を示す文字列を書き込みバッファに対して出力 flush(); // 書き込みバッファ内に保存した値を出力バッファへ明示的に受け渡し ob_flush(); // 出力バッファ内に保存した値を出力(webサーバのバッファに受け渡し)
ob_flush を使います。ob_flush は↑コメントにある様に出力バッファ内のデータをPHPの出力先(ここでは web サーバ)に手動で渡す関数です。これを行うと output_buffering で設定されたバッファリングサイズが満杯になるのを待たずともソースコードで指定したタイミングで web サーバに出力できます。
PHP: ob_flush – Manual
この後さらに web サーバやブラウザのバッファリングに引っかかることがあります。例えば nginx ならば
fastcgi_buffering off;
の様に php-fpm のから送られてきたデータのバッファリングをしないと明示的にオプションを付けておかないとデフォルトでバッファリングされます(今回の様なユースケース以外ではバッファリングした方が通信負荷等で便利なはずです)。この辺りを PHP から制御することは不可能です。
これらのバッファリングを通り抜ければ無事に逐次出力ができます。ちなみに通信路に無用な負荷がかかりやすいですがバッファリングを埋められるだけのデータを出力することによって web サーバの設定等に左右されず逐次出力をできます。
$padding = str_repeat(' ', 1e7);// バッファリングを埋められるだけの空白文字 echo "{$padding}{$msg}{$separator}"; // メッセージ本体とその終端を示す文字列を書き込みバッファに対して出力
JavaScript
JavaScript 側に絡んでくる要素は PHP ほど複雑ではありません。JavaScript コードで完結する世界です。
/** * レスポンスを元にプログレスバーとパーセンテージ表記に進捗を書き込む関数を返す * @param {HTMLElement} logMsgBox * @param {HTMLProgressElement} logProgress * @return {Function} (responseText: string) => void */ const createProgressWriter = (logMsgBox, logProgress) => (responseText) => { // responseText はレスポンス全体を含むので、メッセージの区切りを決めておき、その末尾のみを取得する const v = responseText.replace(/(.*<MESSAGE_PACK_END>)?(.*?)<MESSAGE_PACK_END>/g,'$2'); // 取得したメッセージを各要素に適切な形で書き込み logProgress.value = v; logMsgBox.innerText = `${v}%`; } const xhrElement = document.querySelector('.control.xhr'); xhrElement.addEventListener('click', () => { // xhrElement 以下の進捗書き込み先を渡して書き込み用関数を生成 const writer = createProgressWriter(xhrElement.querySelector('.event-msg-box'), xhrElement.querySelector('.event-progress')) const xhr = new XMLHttpRequest(); // XHR の progress イベントを用いて適宜通信の中身を処理する // @see https://developer.mozilla.org/ja/docs/Web/API/XMLHttpRequest/progress_event xhr.addEventListener('progress', event => writer(event.target.responseText)); // 進捗を返す機能を持つ API へ GET メソッドで通信 xhr.open("GET", 'http://default.docker-host.local/progress/value'); xhr.send(); });
通信時に起きるイベントである progress イベントを元に現在までのレスポンス内容を取得、レスポンス内容をパースするのみです。
XMLHttpRequest: progress イベント – Web API | MDN
progress イベントはレスポンス内容が増える度に発火されるイベントです。つまりサーバ側(PHP側)が逐次レスポンスを出力するたびに発火されます。レスポンスの増分を取得できれば良いのですが、取得方法を見つけられなかったので積もったレスポンス内容をパースする実装を用いました。区切り文字列で一つのレスポンス内に複数メッセージがあることを示し、解釈する様にしています。
axios は通常の通信のみならずこの progress イベントに関数を登録するのも簡単でじつに便利です。
const axiosElement = document.querySelector('.control.axios'); axiosElement.addEventListener('click', () => { // axiosElement 以下の進捗書き込み先を渡して書き込み用関数を生成 const writer = createProgressWriter(axiosElement.querySelector('.event-msg-box'), axiosElement.querySelector('.event-progress')) // 進捗を返す機能を持つ API へ GET メソッドで通信 axios.get('http://default.docker-host.local/progress/value',{ // XHR 同様にの progress イベントを用いて適宜通信の中身を処理する onDownloadProgress: (event) => writer(event.target.responseText), }); });
これによって単純なファイルのアップロード、ダウンロード以外でも進捗表示ができるようになります。また、無限ループスクリプトとタイムアウトなしの通信をすることによってリアルタイム通信的な挙動を行うこともできる様になります。