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

от автора

Случилось мне как-то иметь дело с тяжелым 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> 

Вместо вступления перед катом: Прошу не бить ногами. Гугление перед проработкой схемы не дало вменяемых результатов, поэтому ее и понадобилось выдумать, ввиду чего я и решил ее изложить в этой своей первой публикации.

ссылка на оригинал статьи http://habrahabr.ru/post/257053/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *