読者です 読者をやめる 読者になる 読者になる

無駄と文化

実用的ブログ

サーバーでの重い処理の経過をリアルタイムに通知する

f:id:todays_mitsui:20150830200643p:plain

Webアプリケーションを作るとき、サーバーで重い処理を実行する際にはどうしても途中経過を通知したくなります。

この場合の重い処理とは完了までに時間が掛かる処理という意味合いです。
リクエストから結果の表示まで10分待たされる処理があったとして、「現在32%完了...」のように経過報告付きで見せられる場合と、完了までの目安も教えてもらえずにひたすら待たされる場合とでは、待つ側の精神の安定度が違いますからね。


Google API を300回叩く

ここ数日、GoogleのAPIを叩きまくるプログラムを書いていました。バックエンドの言語はPHPです。

試作時で1タスクあたり7回のHTTPリクエストを繰り返し行います。現在想定している本番の状況では約300回のHTTPリクエストを繰り返して、最終的にまとめた結果を返す、そんな処理です。
あまり連続的にAPIを叩くと無料プランの制限を超えてしまうので、途中途中でsleepさせながら300回のHTTPリスエストをこします。
そうすると結果表示までに概算で15分くらい掛かる計算になるんですよね...。


処理の結果はブラウザで表示させたいわけですが、もちろん15分間ただ表示を待たせておくわけにはいきません。
概ね次のような問題が考えられますね、

  • 15分間ブラウザが真っ白になる(クライアント側に"200 OK"や"202 Accepted"すら返らない)ので確実にタイムアウトする
  • サーバー側も15分間プロセスが専有されるので、同時に一人しか使えないサービスになる

そんなわけで何とかしましょう。勉強しました。


構築環境

今回の話の前提になる環境だけ簡単に説明しておきます。

クライアント(ブラウザ)側はHTML5+JavaScript(+jQuery2.x)です。

サーバー側ではPHPと、PHPのマイクロフレームワークSlimを使っています。
SlimはRubyのマイクロフレームワークSinatraをPHPでクローンしたものですね。PHPのClosureさえ理解していれば30分くらいで使い方覚えられるのでお勧めです。


それと、今回いろいろとサンプルコードを書いたので、まとめてGitHubに置いておきます。

todays-mitsui/fetch-progress-sample

実際に試してみたいという奇特な方はgit cloneしてcomposer installして走らせてみてください。


サーバー側にリクエストを送る

まずクライアント側ですが、シンプルに<input>に入力された値をサーバーにPOSTするだけにしましょう。
ただし、最終的に非同期でいろいろやりたい訳なんで普通にフォームのsubmitで画面遷移するのではなく、ajaxを使ってリクエストを飛ばします。

index.tpl

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>

<form class="js-form" action="/inquire" method="POST">
  <input type="text" name="q">
  <input type="submit" value="submit">
</form>

<script src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
<script>
(function($) {
  $('.js-form').on('submit', function(e) {
    e.preventDefault();

    $.ajax({
      type: $(this).attr('method'),
      url:  $(this).attr('action'),
      data: { q: $(this).find('[name=q]').val() },
      processData: false,
      contentType: false
    }).then(function(res) {
      console.log(res);
    });
  });
})(jQuery);
</script>

</body>

特殊なことはしてないですね。
jQuery.ajax()を使ってPOSTリクエストを飛ばしているだけです。

そしてリクエスト先の/appで待ち構えてるPHPの処理が普通にやると15分掛かる構成になっています。
ajaxのレスポンスをdeferred.then()で受けてconsole.log()で出力していますが、そのままではコンソールに結果が表示されるのは15分後です。

どげんかせんといかん。


PHPで子プロセスを生成

調べると「時間の掛かる処理を子プロセスとして切り離して、レスポンスだけ先に返せ」と、いろんなところに書いてあります。

具体的にはPHPのexec()関数を使うようです。
レスポンスには「リクエストは受理したけどちょっと時間かかるよ」という意味を込めて202 Acceptedを返すのが適切でしょう。

index.php

<?php
require_once("./vendor/autoload.php");

$app = new \Slim\Slim();

// "/inquire"へのPOSTリクエストに対する処理を記述
$app->post("/inquire", function() use ($app) {
  // リクエストパラメーターを取得
  $query = $app->request()->params("q");

  // execコマンドで子プロセスを生成
  // 時間の掛かる処理を heavy_task.php に記述しておき、別個に呼び出す
  // exec() の結果は即座に返り、処理はバックグラウンドで続けられる
  // 
  // コマンドライン引数を渡すときはXSS対策のためescapeshellcmd()でエスケープすること
  exec("nohup php heavy_task.php ".escapeshellcmd($query)." > /dev/null 2>&1 &");

  // ステータスコードを上書きして 202 を返す
  // PHP5.4以上であれば header() の代わりに
  // http_response_code() を使うこともできそうです
  header("HTTP/ 202 Accepted");

  exit;
});

$app->run();

これでブラウザを15分間待たせることはなくなりましたが、これだけでは処理の進行状況を知ることができず、処理が完了した後に結果をどこに取りに行けばいいのかも分かりません。

なので、そこらへんの補足情報をJSONで返すようにします。

index.php

<?php
require_once("./vendor/autoload.php");

$app = new \Slim\Slim();

// "/inquire"へのPOSTリクエストに対する処理を記述
$app->post("/inquire", function() use ($app) {
  // リクエストパラメーターを取得
  $query = $app->request()->params("q");

  // execコマンドで子プロセスを生成
  // 時間の掛かる処理を heavy_task.php に記述しておき、別個に呼び出す
  // exec() の結果は即座に返り、処理はバックグラウンドで続けられる
  // 
  // コマンドライン引数を渡すときはXSS対策のためescapeshellcmd()でエスケープすること
  exec("nohup php heavy_task.php ".escapeshellcmd($query)." > /dev/null 2>&1 &");

  // ステータスコードを上書きして 202 を返す
  // PHP5.4以上であれば header() の代わりに
  // http_response_code() を使うこともできそうです
  header("HTTP/ 202 Accepted");


  // リクエストに固有の名札として
  // hash digestとかを発行しておくと便利じゃないでしょうか
  $id = hash("sha256", time().$query);

  // クライアントにとりあえず返す補足情報を組み立てる
  $res = array(
    // idは次回以降のリクエストにも使って欲しいので
    // クライアントにも共有
    "id" => $id,

    // 処理中の場合は"Accepted"などを、
    // リクエストが不正な場合などは"Error"などを返す
    "status" => "Accepted",

    // 途中経過を問い合わせるためのURIや
    // 処理結果を取りに行くURIを教えてあげる
    "location" => "/progress/${id}",

    // タイムスタンプなども返す
    "time" => time(),
  );

  // JSONにエンコードしてレスポンスを返す
  // Content-Type の設定を忘れずに
  header("Content-Type: application/json");
  echo json_encode($res);

  exit;
});

$app->run();

これにてサーバー側は即座にレスポンスを返しつつ、途中経過の問い合わせ先をクライアントに伝えられるようになりました。

伝えられるようになったのはいいんですが、途中経過を問い合わせるためのエンドポイントを作るという仕事が増えてしまいました。上記の例で言えば /progress/${id} がエンドポイントになります。
ちゃちゃっとやりましょう。


子プロセスから途中経過を返す

とはいえ、

exec("nohup php heavy_task.php ".escapeshellcmd($query)." > /dev/null 2>&1 &");

の時点で子プロセスの処理は親プロセスから切り離されて状況を知ることができなくなっています。出力やエラーも/dev/nullに捨ててますしね。

なんかちゃんとやろうと思えばちゃんとやる方法もあるみたいですが、今回は三井が思いついた方法でやります。
子プロセスにテキストファイルで経過を保存させます。

heavy_task.php

<?php
// 親プロセスからコマンドライン引数経由でリクエストのIDを受け取る
$id = $argv[1];

// 途中経過の保存先を設定
// 形式はJSONにします
$log_location = "./log/${id}.json";

// ログを貯めていく変数
$log = array();

// バックグラウンドで実行するタスクの個数
// 今回は100個の処理をすると仮定
$task_count = 100;

foreach(range(1, $task_count) as $i) {
  sleep(2); // ダミーの重い処理、完了までに2秒かかる。

  // $i 番目の処理が完了したので $log に記録
  array_push($log, "task ${i} done.");

  // 親プロセスに伝える途中経過を組み立て
  // 親プロセスのレスポンスと形を揃えてあげると
  // やりやすいんじゃないでしょうか  
  $progress = array(
    "id"       => $id,
    // 処理中なので status は "Processing" で
    "status"   => "Processing",
    // 処理が何%完了したか
    "progress" => $i / $task_count,
    "log"      => $log,
    "time"     => time(),
  );

  // JSONにエンコードしてファイルとして保存
  file_put_contents($log_location, json_encode($progress));
}

// 全ての処理が完了したらその旨を報告
$progress = array(
  "id"       => $id,
  // 完了したら status を "Done" に
  "status"   => "Done",
  "progress" => 1,
  "log"      => $log,
  "time"     => time(),
);

// JSONにエンコードしてファイルとして保存
file_put_contents($log_location, json_encode($progress));

exit;

これでやっと、任意のタイミングで./log/${id}.jsonを読みに行けば途中経過が分かるようになりました。

ブラウザから./log/${id}.jsonの中身にアクセスできるように、index.phpにちょっと追記してあげましょう。


途中経過取得のエンドポイントを作る

index.php に/progress/${id}へアクセスされた場合の処理を追記しましょう。

index.php

<?php
require_once("./vendor/autoload.php");

$app = new \Slim\Slim();

// 〜 中略 〜

$app->get("/progress/:id", function($id) use ($app) {
  // 子プロセスが保存したJSONを指定
  $log_location = "./log/${id}.json";

  if (file_exists($log_location)) {
    // JSONを読み込み
    $json = file_get_contents($log_location);

    // JSONにエンコードしてレスポンスを返す
    // Content-Type の設定を忘れずに
    header("Content-Type: application/json");
    echo $json;

    exit;
  } else {
    // JSONが見つからない場合は適当に404を返す
    header("HTTP/ 404 Not Found");

    exit;
  }
});

$app->run();

こんなもんでしょう。
他にも、子プロセスが保存したJSONを親プロセスで加工してから返すことも考えられます。
またJSONが見つからない場合には、子プロセスがちゃんと働いているんだけどまだJSONを作ってない状態も含むかも知れないので、実際にはもう少し工夫が必要です。


ここまでくれば当初の目的である、サーバー側の処理の経過をブラウザでリアルタイムに表示するところまでもう少しですね。


JavaScriptを使った定期的問い合わせ

あとはJSを書いて1秒に1回とかの頻度で途中経過を問い合わせてあげればいいだけです。

var fetch = function(url, interval) {
  $.ajax({
    type: 'GET',
    url:  url,
    processData: false,
    contentType: false
  }).then(function(res) {
    console.log(res);

    if (res.status === 'Done') { clearInterval(interval); }
  });
};

var progress = function(url) {
  var interval = setInterval(fetch, 1000, url, interval);
};

progress('/progress/{id}');

はい、今回はconsole.log()で出力するだけにしていますが、ユーザーの目に見える場所にいい感じに表示するのがいいですね。


とりあえずの目的は達成したけれど...?

こんな感じで、サーバー側での重たい処理をぶん回す必要が生じてからというもの、色々と調べては手を動かし、やっとここまでたどり着きました。

けれど、
なんかこのやり方ダサくないですか?
こんなのでいいんですか?合ってますか?

子プロセス内でのログを保存する方法として、ファイルシステムを辞めてデータベースに変えればダサさは幾分か軽減すると思うんですが、それでも根本的にこんな方向性で合ってるのか不安です。


はっきり言って、今回ここまでツラツラ書いてきたのなんて全部前置きです。
僕が言いたいのはただ一つだけです、

これを読んでるプロPHPerのみなさん、もっとカッコイイやり方教えてください。


私からは以上です。