Прогресс выполнения тяжелой задачи в 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>

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

Комментарии 45

    –16
    Чего только люди не придумают, чтобы комет-сервер не использовать.
      +20
      Ну да — давайте поставим комет и будем его поддерживать вместо того, чтобы просто в HTTP заголовке передать что нам нужно.
      +9
      $work = @$_GET['work'] 
      

      Берет за душу, поправьте, пожалуйста на
      $work = isset($_GET['work']) ?  $_GET['work'] : 0;
      
        +13
        Еще лучше на:
        $work = isset($_GET['work']) ? intval($_GET['work']) : 0;
        
          0
          Еще лучше на:

          $work = filter_has_var(INPUT_GET, 'work') ? filter_input(INPUT_GET, 'work', FILTER_VALIDATE_INT) : 0
            +2
            А подскажите пожалуйста, в чем приемущество filter_has_var над $_GET?
              +1
              filter_has_var — функция, проверяющая на наличие требуемого ключа в массивах входных параметров скрипта. $_GET — один из таких массивов. В чем преимущество функции над массивом, затрудняюсь ответить…

              Преимущество filter_has_var(INPUT_GET, 'work') над isset($_GET['work']) в том, что filter_has_var() вернет false, если ключ «work» был задан программно, а не передан в качестве параметра запроса. То есть:
              <?php
              $_GET['test'] = 1;
              echo filter_has_var(INPUT_GET, 'test') ? 'Yes' : 'No';
              ?>
              выдаст «No», если в запросе к скрипту не будет ?test=some_value.
              • НЛО прилетело и опубликовало эту надпись здесь
                • НЛО прилетело и опубликовало эту надпись здесь
                0
                Помимо того, что написал Aiki рекомендую почитать аргументы по ссылке: stackoverflow.com/questions/15102796/when-to-use-filter-input

                Не даром фреймворки используют свои обертки над глобальными переменными.
          +12
          Ваша реализация сильно зависит от настроек конкретного веб-сервера, где устанавливаются размеры буферов ответа, причем это может быть даже в нескольких местах — не хочу вдаваться в подробности.
          Более «серверонезависимым» будет вариант когда клиент запускает одним ajax-запросом «тяжелый» php скрипт (назовем его process.php) и с помощью другого ajax-запроса в цикле проверяет прогресс, запрашивая второй php скрипт (назовем его status.php). Во время своей работы process.php с помощью curl (или любым другим способом) сообщает status.php свое состояние используя любой удобный вариант идентификации.
          Такой вариант мне кажется намного надежнее, чем «борьба» с буферами ответа.
            0
            Такой вариант я тоже рассматривал, но мне сначала не понравилось, что параллельный ajax-запрос будет висеть на таймере. Еще не понравилось, что нужен какой-то механизм хранения состояния выполнения конкретного {process.php} (он может быть запущен одновременно несколькими клиентами несколько раз). То есть, к моменту начала выполнения нужно знать идентификатор процесса {process.php} для каждого XHR. Для каждого такого идентификатора нужно где-то хранить состояние (memcache?).
            В виду этих мелочей мне и захотелось сделать так, как я сделал.
            Да, с конфигурацией сервера и прилежащих частей есть загвоздки. Но большинство из них — те, которые я описал — не очень сильно преобразуют дефолтный рабочий конфиг.
              0
              Вам достаточно первый раз отдавая страницу launcher.html клиенту проставить в ней уникальный ID для процесса и использовать его при запуске процесса и при запросе статуса, например прямо в URL: /process.php?id=ID и /status.php?id=ID, свой статус process.php сообщает в status.php используя этот же ID, а это состояние status.php может хранить в своей сессии также используя этот же ID. Причем передавать можно гораздо больше информации о состоянии процесса, ну там текущий шаг к примеру и сколько осталось по этому шагу и т.д. + ничего в настройках сервера и php связанного с буферизацией вывода менять не придется. В вашем варианте надо всегда настраивать конкретный сервер, а это далеко не всегда можно легко и просто сделать.
                0
                >> /process.php?id=ID и /status.php?id=ID

                По-моему проще просто в сессию все складывать, process в сессию пишет прогресс, а status оттуда его забирает
                  +1
                  Не проще. В разных реализациях-настройках рнр сессий на различных веб-серверах возможны проблемы блокировки сессий в такой ситуации, и это надо будет как-то «разруливать» так как: Session data is usually stored after your script terminated.
                    0
                    А, пардон. Я уж и забыл, что в php сессии блокируются.
                  0
                  Я же не говорил, что это невозможно, а просто сказал, что этот метод мне показался не слишком красивым в моем случае.
                  Согласен, что схема, описанная вами, хороша сама по себе. Именно о таком методе почти все статьи. Я просто предложил еще один вариант (на мой взгляд, имеющий право на жизнь), о котором не слышал и не видел раньше. Среди преимуществ моего варианта: всего один XHR вместо нескольких и отсутствие таймера.
                  0
                  Пользователь не сможет воспользоваться вашим скриптом имея плохое соединение, например из Китая или с мобильного интернета в деревне.
                    0
                    Ну, при подобных обстоятельствах и запрашивать у сервера статус с интервалом — тоже будет проходить не без неприятностей.
                    Я понял свою ошибку: я не указал область применения. Последний случай был таким: у меня была выборка (unbuffered query) из большой таблицы БД, а в процессе транспортировки данных проводилась дополнительная фильтрация, при которой примерно треть отсеивалась. После этого проводилась сравнительно быстрая шлифовка результата. Все, результат выводился. Весь процесс занимал от полминуты до получаса (и это считалось нормой, это для админки). То есть, да, отчасти и по этой причине я даже не рассматривал post-запросы в скрипте, как бы намекая, что get — он для того и get, только для получения, прерывание которого не приведёт к проблемам вроде «скрипт отработал только на половину».
                  0
                  Приходилось прибегнуть к похожему решению. Главное не использовать сессии в скриптах: ajax.php и progress.php (это так: для справки).
                    0
                    А в чем проблема сессий?
                      +1
                      Старт сессии устанавливает блок.
                      Если не закрыть сессию вы не сможете выполнить оба запроса параллельно.
                  +1
                  1Кб на прием-передачу данных раз в 30 секунд — это много? Вы можете вычислить примерную скорость уже после 1 ответа сервера — с процентом выполнения. И дальше ее корректировать, а на клиенте показывать плавный индикатор. Там Google PageSpeed Insights делает. Вам точные проценты не нужны, можно просто реперные точки передавать.
                    +2
                    Если юзер вдруг обновит страницу или произойдет отключение, то скрипт прекратить свою работу на половине задачи и возможно все поломается.

                    Я бы использовал ignore_user_abort и заставил скрипт выводить в файл % выполнения. А на странице подгружал бы этот файл по таймауту, запросы на обычные файлы у сервера проходят в разы легче чем к скриптам.
                      0
                      Вас не смущает, что весь этот файловый мусор потом надо будет как-то убирать?
                        +1
                        Смотря что за задача, если требуется 1 процесс, то будет 1 файл. Если нет, можно удалять файл самим же скриптом, а на клиенте прописать, что если файл был, а потом удалился, значит задача выполнена на 100%. Или передавать доп запрос в конце на удаление файла.

                        Или же реализовать на скриптах, хранить в базе\памяти, зависит от задачи, требований к нагрузкам, времени которое можно уделить на решение.
                        0
                        Для разных ситуаций — разные решения. А если как раз нужно остановить выполнение скрипта?
                          0
                          Если это для обычных пользователей, то они не догадаются, что остановка загрузки страницы = остановка программы. Многие даже не знают как остановить загрузку страницы, это из опыта.

                          Кнопка «Стоп» передающая запрос в любом случаи будет лучше.

                          Можно комментировать раз в 5 минут — несправедливость.
                            0
                            Существует метод XMLHttpRequest.abort(), который я пробросил в своем этом классе-обертке. В примере это есть: кнопка старта играет и роль кнопки для остановки.
                        +2
                        Websockets вам в помощь. Как раз для коротких асинхронных сообщений с сервера.
                          –4
                          Более высокая сложность реализации, доп ПО. А из плюсов небольшая экономия на заголовках пакетов и чуть более плавная индикация состояния. В данном случаи того не стоит, как по мне.
                          +2
                          Попробуйте SSE
                            0
                            readyState == 3

                            Насколько это стало кроссбраузерно?
                            Года 3 назад я заметил эту возможность у xmlhttprequest обрабатывать порции данных, но тогда именно вот этот ready state 2 не выдавали какие-то из популярных на то время браузеров.
                              –1
                              Уверены, что дело в самих браузерах было?

                              Еще дополнение. Уже после публикации статьи я наткнулся на ситуацию в Хроме: событие onreadystatechange отрабатывало должным образом, только если Content-Type != text/plain. Если text/plain, то Хром, по видимому, складывал все в какой-то свой буфер, а уже после EOF показывал все полученные данные разом. Даже если поменять на text/unknown или text/something, проблема улетучивалась.
                            • НЛО прилетело и опубликовало эту надпись здесь
                                0
                                … Потом мы понимаем, что в файл писать слишком накладно, да и задачу нужно разпаралелить и начинаем писать в какой-нибудь MySQL. А когда база данных начнет отказывать по timeout, мы перепишем все на Redis или запихнем таблицу в память целиком. А протом окажется что нам нужна бóльшая стабильность, задачи нужно балансировать на несколько серверов, а на одной странице может выполнятся сразу несолько задач, и мы наконец сделаем нормальное решение на базе message broker и websockets.
                                • НЛО прилетело и опубликовало эту надпись здесь
                                    0
                                    Как правило технологии осиливаются на своем горьком опыте, так что все ок, но за использоваине файлов я все-таки влеплю вам мысленный подзатыльник. Подумайте сами, что расточительнее по ресурсам: отправить 5000 байт клиенту или сделать запись в файл 5000 раз с полным сбросом буфера. HDD начнут взлетать от такой нагрузки, а SSD вы просто задолбаетесь менять.
                                    0
                                    Если это нужно для пары несложных страниц на всю систему (которая, как часто бывает, живет под девизом «нужно приспосабливаться под текущие условия — так сложилось исторически»), то все эти домыслы не к месту.
                                  0
                                  Мне кажется все зависит от того что за долгий скрипт собирает выполнятся. Я бы делал на ReactPHP (WebSokets).
                                    +5
                                    Простите конечно, но хабрахабр не Stackoverflow. А ваша статья больше похожа на тот как делать не стоит, даже несмотря на просьбу сильно не пинать в конце.

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


                                    Можно бесконечно смотреть на огонь, воду и то как PHP-разработчики решают проблемы. Вы сделали очень грязный хак для вашей пары браузер-вебсервер, но вы точно не решили проблему, ведь в даже не знаете, что ее вызвало. Что если в каком-то другом случае 20ти пробелов недостаточно?
                                      0
                                      Присоединяюсь и добавлю, что хак в 20 байтов связан с наполнителем буфера, который помимо прочего содержит еще и заголовки сервера. т.е. мало того, что буфер у всех разный, так еще всё будет зависеть от количества заголовков. Автор, проведите эксперимент: добавьте еще пару лишних хэдеров, и посмотрите, нужны ли 20 пробелов. Результат в студию
                                        0
                                        Не знаю — я так и написал.
                                        Эксперимент с дополнительными хедерами провалился: они ничего не дают.

                                        onreadystatechange срабатывает, но в responseText — пусто. Срабатывает столько раз, сколько пришло байт, но тело ответа пустое, а потом, когда тело становится длиннее волшебного числа 8 (в моем случае на самом деле не 20) — responseText моментально преобразуется в строку из 8 байт.

                                        У меня были догадки на этот счет, которые упираются в Apache и Nagle. Но после наблюдений они разошлись с догадками и я не стал умничать и вводить читателя в заблуждение, а спросил.

                                        Быть может, у вас есть точный ответ?
                                          0
                                          Точного ответа у меня нет, я могу только гадать. 8 байт натолкнули меня на мыcль: может стоит указать в хедерах явно кодировку ascii. Еще попробуйте поставить `Content-Type: application/octet-stream`.
                                        0

                                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                        Самое читаемое