[Из песочницы] Прогресс выполнения тяжелой задачи в PHP
Случилось мне как-то иметь дело с тяжелым PHP-скриптом. Нужно было каким-то образом в браузере отображать прогресс выполнения задачи в то время, пока в достаточно длительном цикле на стороне PHP проводились расчёты. В таких случаях обычно прибегают к периодичному выводу строки вроде этой: Этот вариант меня не устраивал по нескольким причинам, к тому же мне в принципе не нравится такой подход.Итераций у меня было порядка 3000—5000. Я прикинул, что великоват трафик для такой несложной затеи. Кроме того, мне такой вариант казался очень некрасивым с технической точки зрения, а внешний вид страницы и вовсе получался уродлив: футер дойдет еще не скоро — после последнего уведомления о 100% выполнении задачи.Увернуться от проблемы некрасивой страницы большого труда не составляло, но остальные минусы заставили меня обрадоваться и приступить к поискам более изящного решения.
Несколько наводящих вопросов. Асинхронные HTTP-запросы возможны? — Да. Можно ли с помощью одного-единственного байта сообщить, что часть большой задачи выполнена? — Да. Можем ли мы постепенно (последовательно) получать и обрабатывать данные с помощью XMLHttpRequest.onreadystatechange? — Да. Мы даже можем воспользоваться заголовками HTTP для передачи предварительного уведомления об общей продолжительности выполняемой задачи (если это возможно в принципе).
Решение простое. Основанная страница — это пульт управления. С пульта можно запустить и остановить задачу. Эта страница инициирует XMLHttpRequest — стартует выполнение основной задачи. В процессе выполнения этой задачи (внутри основного цикла) скрипт отправляет клиенту один байт — символ пробела. На пульте в обработчике onreadystatechange мы, получая байт за байтом, сможем делать вывод о прогрессе выполнения задачи.
Схема такая. Скрипт операции:
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-ом.
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
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