30 April 2015

Прогресс выполнения тяжелой задачи в PHP

PHPJavaScript
From Sandbox
Случилось мне как-то иметь дело с тяжелым PHP-скриптом. Нужно было каким-то образом в браузере отображать прогресс выполнения задачи в то время, пока в достаточно длительном цикле на стороне PHP проводились расчёты. В таких случаях обычно прибегают к периодичному выводу строки вроде этой:

<script>document.getElementById('progress').style.width = '1%';</script>

Этот вариант меня не устраивал по нескольким причинам, к тому же мне в принципе не нравится такой подход.

Итераций у меня было порядка 3000—5000. Я прикинул, что великоват трафик для такой несложной затеи. Кроме того, мне такой вариант казался очень некрасивым с технической точки зрения, а внешний вид страницы и вовсе получался уродлив: футер дойдет еще не скоро — после последнего уведомления о 100% выполнении задачи.

Увернуться от проблемы некрасивой страницы большого труда не составляло, но остальные минусы заставили меня обрадоваться и приступить к поискам более изящного решения.

Несколько наводящих вопросов. Асинхронные HTTP-запросы возможны? — Да. Можно ли с помощью одного-единственного байта сообщить, что часть большой задачи выполнена? — Да. Можем ли мы постепенно (последовательно) получать и обрабатывать данные с помощью XMLHttpRequest.onreadystatechange? — Да. Мы даже можем воспользоваться заголовками HTTP для передачи предварительного уведомления об общей продолжительности выполняемой задачи (если это возможно в принципе).

Решение простое. Основанная страница — это пульт управления. С пульта можно запустить и остановить задачу. Эта страница инициирует XMLHttpRequest — стартует выполнение основной задачи. В процессе выполнения этой задачи (внутри основного цикла) скрипт отправляет клиенту один байт — символ пробела. На пульте в обработчике onreadystatechange мы, получая байт за байтом, сможем делать вывод о прогрессе выполнения задачи.

Схема такая. Скрипт операции:

<?php

set_time_limit(0);
for ($i = 0; $i < 50; $i++)	// допустим, что итераций будет 50
	{
	sleep(1);	// Тяжелая операция
	echo ' ';
	}


Обработчик XMLHttpRequest.onreadystatechange:

xhr.onreadystatechange = function()
	{
	if (this.readyState == 3)
		{
		var progress = this.responseText.length;
		document.getElementById('progress').style.width = progress + '%';
		}
	};

Однако, итераций всего 50. Об этом мы знаем, потому что сами определили их количество в файле скрипта. А если не знаем или количество может меняться? При readyState == 2 мы можем получить информацию из заголовков. Давайте этим и воспользуемся для определения количества итераций:

header('X-Progress-Max: 50');

А на пульте получим и запомним это значение:

var progressMax = 100;

xhr.onreadystatechange = function()
	{
	if (this.readyState == 2)
		{
		progressMax = +this.getResponseHeader('X-Progress-Max') || progressMax;
		}
	else if (this.readyState == 3)
		{
		var progress = 100 * this.responseText.length / progressMax;
		document.getElementById('progress').style.width = progress + '%';
		}
	};

Общая схема должна быть ясна. Поговорим теперь о подводных камнях.

Во-первых, если в PHP включена опция output_buffering, нужно это учесть. Здесь все просто: если она включена, то при запуске скрипта ob_get_level() будет больше 0. Нужно обойти буферизацию. Еще, если вы используете связку Nginx FastCGI PHP, нужно учесть, что и FastCGI и сам Nginx будут буферизовать вывод. Последний это будет делать в том случае, если собирается сжимать данные для отправки. Устраняется проблема просто:

header('Content-Encoding: none', true);

Если проблему с gzip можно решить внутри самого PHP-скрипта, то заставить FastCGI сразу передавать данные можно только поправив конфигурацию сервера:

fastcgi_keep_conn on;

Кроме того, то ли Nginx, то ли FastCGI, то ли сам Chrome считают, что инициировать прием-передачу тела ответа, которое содержит всего-навсего один байт — слишком расточительно. Поэтому нужно предварить всю операцию дополнительными байтами. Нужно договориться, скажем, что первые 20 пробелов вообще ничего не должны означать. На стороне PHP их нужно просто «выплюнуть» в вывод, а в обработчике onreadystatechange их нужно проигнорировать. На мой взгляд — раз уж вся конфигурационная составляющая передается в заголовках — то и это число игнорируемых пробелов тоже лучше передать в заголовке. Назовем это padding-ом.

<?php

header('X-Progress-Padding: 20', true);
echo str_repeat(' ', 20);
flush();

// ...

На стороне клиента это тоже нужно учесть:

var progressMax = 100,
	progressPadding = 0;

xhr.onreadystatechange = function()
	{
	if (this.readyState == 2)
		{
		progressMax = +this.getResponseHeader('X-Progress-Max') || progressMax;
		progressPadding = +this.getResponseHeader('X-Progress-Padding') || progressPadding;
		}
	else if (this.readyState == 3)
		{
		var progress = 100 * (this.responseText.length - progressPadding) / progressMax;
		document.getElementById('progress').style.width = progress + '%';
		}
	};

Откуда число 20? Если подскажете — буду весьма признателен. Я его установил экспериментальным путем.

Кстати, насчет настройки PHP output_buffering. Если у вас сложная буферизация и вы не хотите ее нарушать, можно воспользоваться такой функцией:

function ob_ignore($data, $flush = false)
	{
	$ob = array();
	while (ob_get_level())
		{
		array_unshift($ob, ob_get_contents());
		ob_end_clean();
		}
	
	echo $data;
	if ($flush)
		flush();
	
	foreach ($ob as $ob_data)
		{
		ob_start();
		echo $ob_data;
		}
	return count($ob);
	}

С ее помощью можно обойти все уровни буферизации, вывести данные напрямую, после чего все буферы восстанавливаются.

Кстати, а почему именно пробел используется для уведомления о выполненной части задачи? Просто потому что почти любой формат представления данных в вебе такими пробелами не испортишь. Можно применить такой метод передачи уведомления о прогрессе операции, а после всего этого вывести отчет о результатах в JSON.

Если все привести в порядок, немного оптимизировать и дополнить код всеми возможностями, которые могут пригодиться, получится вот что:

progress-loader.js
function ProgressLoader(url, callbacks)
	{
	var _this = this;
	for (var k in callbacks)
		if (typeof callbacks[k] != 'function')
			callbacks[k] = false;
	delete k;
	
	function getXHR()
		{
		var xhr;
		try
			{
			xhr = new ActiveXObject("Msxml2.XMLHTTP");
			}
		catch (e)
			{
			try
				{
				xhr = new ActiveXObject("Microsoft.XMLHTTP");
				}
			catch (E)
				{
				xhr = false;
				}
			}
		if (!xhr && typeof XMLHttpRequest != 'undefined')
			xhr = new XMLHttpRequest();
		return xhr;
		}
	
	this.xhr = getXHR();
	this.xhr.open('GET', url, true);
	
	var contentLoading = false,
		progressPadding = 0,
		progressMax = -1,
		progress = 0,
		progressPerc = 0;
	
	this.xhr.onreadystatechange = function()
		{
		if (this.readyState == 2)
			{
			contentLoading = false;
			progressPadding = +this.getResponseHeader('X-Progress-Padding') || progressPadding;
			progressMax = +this.getResponseHeader('X-Progress-Max') || progressMax;
			if (callbacks.start)
				callbacks.start.call(_this, this.status);
			}
		else if (this.readyState == 3)
			{
			if (!contentLoading)
				contentLoading = !!this.responseText
					.replace(/^\s+/, '');	// .trimLeft() — медленнее О_о
			
			if (!contentLoading)
				{
				progress = this.responseText.length - progressPadding;
				progressPerc = progressMax > 0 ? progress / progressMax : -1;
				if (callbacks.progress)
					{
					callbacks.progress.call(_this,
						this.status,
						progress,
						progressPerc,
						progressMax
						);
					}
				}
			else if (callbacks.loading)
				callbacks.loading.call(_this, this.status, this.responseText);
			}
		else if (this.readyState == 4)
			{
			if (callbacks.end)
				callbacks.end.call(_this, this.status, this.responseText);
			}
		};
	if (callbacks.abort)
		this.xhr.onabort = callbacks.abort;
	
	this.xhr.send(null);
	
	this.abort = function()
		{
		return this.xhr.abort();
		};
	
	this.getProgress = function()
		{
		return progress;
		};
	
	this.getProgressMax = function()
		{
		return progressMax;
		};
	
	this.getProgressPerc = function()
		{
		return progressPerc;
		};
	
	return this;
	}

process.php
<?php

function ob_ignore($data, $flush = false)
	{
	$ob = array();
	while (ob_get_level())
		{
		array_unshift($ob, ob_get_contents());
		ob_end_clean();
		}
	
	echo $data;
	if ($flush)
		flush();
	
	foreach ($ob as $ob_data)
		{
		ob_start();
		echo $ob_data;
		}
	return count($ob);
	}

if (($work = @$_GET['work']) > 0)
	{
	header("X-Progress-Max: $work", true, 200);
	header("X-Progress-Padding: 20");
	ob_ignore(str_repeat(' ', 20), true);
	
	for ($i = 0; $i < $work; $i++)
		{
		usleep(rand(100000, 500000));
		ob_ignore(' ', true);
		}
	
	echo $work.' done!';
	die();
	}

launcher.html
<!DOCTYPE html>
<html>
<head>
<title>ProgressLoader</title>
<script type="text/javascript" src="progress-loader.js"></script>
<style>
progress, button {
	display: inline-block;
	vertical-align: middle;
	padding: 0.4em 2em;
	margin-right: 2em;
}
</style>
</head>
<body>
<progress id="progressbar" value="0" max="0" style="display: none;"></progress>
<button id="start">Start/Stop</button>
<script>

var progressbar = document.getElementById('progressbar'),
	btnStart = document.getElementById('start'),
	worker = false;

btnStart.onclick = function()
	{
	if (!worker)
		{
		var url = 'process.php?work=42';
		worker = new ProgressLoader(url, {
			start: function(status)
				{
				progressbar.style.display = 'inline-block';
				},
			progress: function(status, progress, progressPerc, progressMax)
				{
				progressbar.value = +progressbar.max * progressPerc;
				},
			end: function(status, s)
				{
				progressbar.style.display = 'none';
				worker = false;
				},
			});
		}
	else
		{
		worker.abort();
		progressbar.style.display = 'none';
		worker = false;
		}
	};

</script>
</body>
</html>

Вместо вступления перед катом: Прошу не бить ногами. Гугление перед проработкой схемы не дало вменяемых результатов, поэтому ее и понадобилось выдумать, ввиду чего я и решил ее изложить в этой своей первой публикации.
Tags:phpjavascriptprogress barnginxfastcgiстрока состоянияпрогресс-бар
Hubs: PHP JavaScript
+14
44.2k 232
Comments 45
Ads
Top of the last 24 hours