ルモーリン

サーバーの処理経過を表示

投稿:2022-04-18

処理時間が15秒もあるサーバー処理では使う側にストレスが溜まります。 クライアント側でインチキ処理経過(タイマー表示でそれっぽく見せる奴)も良い。 けれど今時のサーバー/クライアントの通信インターフェースなら、本当にサーバー側の処理経過をクライアントで表示できそう。 よし、いっちょ腕試しにやってみっか。

こんな流れで事が進むといいな♪

  1. 処理してもらうファイルをクライアントがサーバーにアップロード
  2. サーバーで処理
  3. 処理中はクライアントに処理経過(サーバーの進捗)が表示される
  4. サーバーの処理が完了
  5. クライアントに返すファイルが完成
  6. クライアントがダウンロード

普通のアップロード用フォームで良いですが、サーバーからのメッセージを表示する都合でページ遷移を避けたい。 アップロードボタンからJavaScriptを呼び出し、リクエストを生成してサーバーへ送ります。 アップロード完了でサーバーからテキストが返る(予定)なのでクライアントに表示します。 サーバーがエラーの場合(よくある)はとりあえずクライアント側のメッセージを表示します。 フォームにあるフィールドを丸ごとアップロードするので、オプション情報を追加できます。

<form id="file_upload" enctype="multipart/form-data">
ファイル:<input type="file" name="txt" accept=".txt" required><br />
<input type="button" value="アップロード" onClick="file_upload();"><br />
</form>
メッセージ:<div id="result" style="display: inline;"></div><br />
変換処理:<progress max="100" value="0" id="progress">0%</progress>
<script>
function file_upload() {
	var progress = document.getElementById("progress");
	progress.value = 0;
	var fm = document.getElementById("file_upload");
	var fd = new FormData(fm);
	var xhr = new XMLHttpRequest();
	xhr.open("POST", "/api/sample_upload", true);
	xhr.responseType = "text";
	xhr.addEventListener("load", function(ev) {
		var result = document.getElementById("result");
		if (200 == ev.target.status) {
			result.innerHTML = xhr.response;
		} else {
			result.innerHTML = "アップロードが失敗しました。";
		}
		delete xhr;
	});
	xhr.send(fd);
}
</script>

サーバー処理は単一のファイルに収める都合でプラグイン形式です。 registerが呼ばれるのでルーターを設定し、リクエストがあればプラグイン内の関数が呼ばれます。 ランダム文字列をクッキーに入れてクライアントに保存されますがランダム文字列を生成するData::Randomを使えば良かった(反省)。 ランダム文字列をベースにしたファイル名を生成、アップロードされたファイルを保存します。 ファイルを処理するサブプロセスを起動(後述)、クライアントへメッセージを返して終了します。 なお、Mojoliciousはクッキーに署名を入れてクライアントからのクッキーに署名チェックをかけて偽造を弾くらしいです。

package SampleProcess;
use Mojo::Base "Mojolicious::Plugin";

use constant {
	RANDOM_LENGTH => 10,
};

sub register {
	my ($self, $app, $conf) = @_;

	my $r = $app->routes;
	$r->post("/api/sample_upload")->to(cb => \&sample_upload);

	# イベントソース処理(後述)
}

sub sample_upload {
	my $self = self;

	my $upload = $self->param("txt");
	if (!defined $upload || $upload->filename !~ /\.txt$/i) {
		$self->render(text => "txtファイルを指定してください。");
		return;
	}

	my $rand_str = $self->session("rand_str");
	if (!defined $rand_str) {
		my @alphabet = split //, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
		$rand_str .= $alphabet[rand @alphabet] for 1 .. RANDOM_LENGTH;
                $self->session(rand_str => $rand_str);
        }
        if (!defined $rand_str || RANDOM_LENGTH != length $rand_str) {
                $self->render(text => "リクエストに問題があります。");
                return;
        }

        my $txt_file = Mojo::Home->new->child("client", "${rand_str}.txt");
        $upload->move_to($txt_file);

	# サブプロセスは後述

	# このプロセスは応答して終了するが、サブプロセスは実行を継続
	$self->render(text => "アップロード完了、変換処理中です。");
}

1;

アップロード後に応答を返す都合があるので、サーバー側の処理ができません。 言い方を変えるとアップロードと一貫して処理を進めると応答が来なくてクライアントは待ちぼうけです。 そこでサブプロセスを生成して応答と別に処理を進めます。 前項の「サブプロセスは後述」の位置に入ります。 AppRedisはMojo::Redisをアプリケーション内で単一に扱えるプラグイン(自作)です。 使う都度生成しても良さそうですがRedisのクライアントなので多量に生成してクライアントの個数が増えるのもアレだなと思って1個にしました。 処理経過(パーセント)に変化があればprogressを呼び、notifyを呼び、pubsubで待っているプロセス(後述)が拾います。

	# サブプロセスを起動
	my $subprocess = Mojo::IOLoop::Subprocess->new;
	$subprocess->on(progress => sub {
		my ($subprocess, $prog) = @_;
		$self->AppRedis->pubsub->notify("mojolicious:sample:progress" => "$rand_str progress $prog");
	});
	$subprocess->run(
		sub {
			my $progress = 0;
			my $count = 0;
			my $data;
			# 回数のかかるループ
			for (1 .. 10000) {
				# ここに時間のかかる処理がある

				$count++;
				my $progress_new = int($count * 100 / 10000);
				if ($progress < $progress_new) {
					$progress = $progress_new;
					$subprocess->progress($progress);
				}
			}

        		my $new_file = Mojo::Home->new->child("client", "${rand_str}_new.txt");
			$new_file->spurt($data);

			return "OK";
		},
		sub {
			my ($subprocess, $err, @results) = @_;
			$txt_file->remove;
			if ($err) {
				$self->AppRedis->pubsub->notify("mojolicious:sample:progress" => "$rand_str result 変換処理中にエラーが発生しました");
			} else {
				$self->AppRedis->pubsub->notify("mojolicious:sample:progress" => "$rand_str result 変換しました");
				$self->AppRedis->pubsub->notify("mojolicious:sample:progress" => "$rand_str download _");
			}
		},
	);

サーバーの処理経過をクライアントへ報告します。 処理中のnotifyで処理経過が進むとlistenが受け付けますから、イベントソースのemitでクライアントへ送ります。 イベントソースのテキストはutf8エンコードが必要です。 listenは全てのサーバー処理から報告が来ているのでイベントソースが受け付けているクライアント以外もあります。 そこでクッキーのランダム文字列が一致した報告だけクライアントにだけ送り、他のクライアント向けの処理経過をマスクします。 このソースは前述の「イベントソース処理」の位置に入ります。 イベントソースは通信なしのタイムアウトがあるので30秒毎にダミーのメッセージを流して延命します。 ページ遷移やタブ閉じやブラウザ閉じでコネクションが切れるとfinishが呼ばれるので保存したファイルを削除します。

	$r->event_source("/sample_progress" => sub {
		my $self = shift;

		my $rand_str = $self->session("rand_str");

		# txは弱いリファレンスなのでtxをコールバックで参照して延命を図る
		my $tx = $self->render_later->tx;
		my $id;
		my $txt_file;
		if (defined $rand_str && RANDOM_LENGTH == length $rand_str) {
			$txt_file = Mojo::Home->new->child("client", "$rand_str.txt");

			$self->AppRedis->pubsub->listen("mojolicious:sample:progress" => sub {
				my ($pubsub, $message, $channel) = @_;
				my @pubsub_param = split / /, $message;
				$_ = Encode::encode utf8 => $_ for @pubsub_param;
				$self->emit(@pubsub_param[1 .. $#pubsub_param]) if $rand_str eq $pubsub_param[0];
			});

			$id = Mojo::IOLoop->recurring(30 => sub {
				$self->emit("ping", "");
			});
		}

		# 終了
		$self->on(finish => sub {
			if (defined $txt_file) {
				$txt_file->remove;
			}
			if (defined $rand_str) {
				my $new_file = Mojo::Home->new->child("client", "${rand_str}_new.txt");
				$new_file->remove;
			}
			if (defined $id) {
				Mojo::IOLoop->remove($id);
				undef $id;
			}
			$self->AppRedis->pubsub->unlisten("mojolicious:sample:progress");
			$tx;
		});

		# 応答を抑止
		$self->render_later;
	});

イベントソースで処理経過を報告してもらい、その都度、処理経過の表示を更新します。 このイベントソースは他の報告も兼用していて、addEventListenerの第1パラメタで区別します。

<script>
var es = new EventSource("/sample_progress");

es.addEventListener("progress", function(e) {
	var progress = document.getElementById("progress");
	progress.value = e.data;
}, false);
</script>

処理結果の報告を表示します。 progressがresultに変わっただけですね。

<script>
es.addEventListener("result", function(e) {
	var result = document.getElementById("result");
	result.innerHTML = e.data;
}, false);
</script>

サーバーの処理が終わると最後にダウンロード指示の報告(なんだそりゃ?)が来ますから、ダウンロードします。 報告にはパラメタはありません。サーバー側のemitの都合でダミーに_(アンダースコア)を入れています。 空文字でも良かったりして(笑)。 ダウンロードのリクエストにパラメタはありません、空のフォームでリクエストします。 クッキーにランダム文字列がありますからダウンロードで同じランダム文字列のファイルをダウンロードできます。 ファイルの応答に対してファイルダウンロードの動作をさせます。

<script>
es.addEventListener("download", function(e) {
	var result = document.getElementById("result");
	result.innerHTML = "ダウンロードします。";

	var xhr = new XMLHttpRequest();
	xhr.open("POST", "/api/sample_download", true);
	xhr.responseType = "blob";
	xhr.addEventListener("load", function(ev) {
		if (200 == ev.target.status) {
			var blob = xhr.response;
			var objectURL = window.URL.createObjectURL(blob);
			var link = document.createElement("a");
			document.body.appendChild(link);
			link.href = objectURL;
			link.download = xhr.getResponseHeader("content-disposition").split("filename=")[1].split(";")[0];
			link.click();
			document.body.removeChild(link);
			result.innerHTML = "ダウンロードしました。";
		} else {
			result.innerHTML = "ダウンロードが失敗しました。";
		}
		delete xhr;
	});
	var fd = new FormData();
	xhr.send(fd);
}, false);
</script>

ルーターの設定にダウンロードのURLを追加します。

	$r->post("/api/sample_download")->to(cb => \&sample_download);

ダウンロードのリクエストが来たらクッキーをチェックして処理済みのファイル名を求めます。 ファイルがあればテンポラリファイルに移動してレスポンスとして返します。 応答後にテンポラリファイルは自動削除されます。

sub sample_download {
        my $self = shift;
        my $rand_str = $self->session("rand_str");

        return $self->reply->not_found if !defined $rand_str && RANDOM_LENGTH != length $rand_str;

        my $txt_file = Mojo::Home->new->child("client", "${rand_str}_new.txt");
        if (-e $txt_file) {
                my $temp_file = tempfile();
                $txt_file->move_to($temp_file);
                undef $txt_file;
                $self->res->headers->content_disposition("attachment; filename=sample_new.txt;");
                $self->reply->file($temp_file);
        } else {
                $self->replay->not_found;
        }
}