Случилось мне как-то иметь дело с тяжелым PHP-скриптом. Нужно было каким-то образом в браузере отображать прогресс выполнения задачи в то время, пока в достаточно длительном цикле на стороне PHP проводились расчёты. В таких случаях обычно прибегают к периодичному выводу строки вроде этой:
Этот вариант меня не устраивал по нескольким причинам, к тому же мне в принципе не нравится такой подход.
Итераций у меня было порядка 3000—5000. Я прикинул, что великоват трафик для такой несложной затеи. Кроме того, мне такой вариант казался очень некрасивым с технической точки зрения, а внешний вид страницы и вовсе получался уродлив: футер дойдет еще не скоро — после последнего уведомления о 100% выполнении задачи.
Увернуться от проблемы некрасивой страницы большого труда не составляло, но остальные минусы заставили меня обрадоваться и приступить к поискам более изящного решения.
Несколько наводящих вопросов. Асинхронные HTTP-запросы возможны? — Да. Можно ли с помощью одного-единственного байта сообщить, что часть большой задачи выполнена? — Да. Можем ли мы постепенно (последовательно) получать и обрабатывать данные с помощью
Решение простое. Основанная страница — это пульт управления. С пульта можно запустить и остановить задачу. Эта страница инициирует XMLHttpRequest — стартует выполнение основной задачи. В процессе выполнения этой задачи (внутри основного цикла) скрипт отправляет клиенту один байт — символ пробела. На пульте в обработчике
Схема такая. Скрипт операции:
Обработчик
Однако, итераций всего 50. Об этом мы знаем, потому что сами определили их количество в файле скрипта. А если не знаем или количество может меняться? При
А на пульте получим и запомним это значение:
Общая схема должна быть ясна. Поговорим теперь о подводных камнях.
Во-первых, если в PHP включена опцияПоследний это будет делать в том случае, если собирается сжимать данные для отправки. Устраняется проблема просто (из PHP-скрипта):
Кроме того, то ли Nginx, то ли FastCGI, то ли сам Chrome считают, что инициировать прием-передачу тела ответа, которое содержит всего-навсего один байт — слишком расточительно. Поэтому нужно предварить всю операцию дополнительными байтами. Нужно договориться, скажем, что первые 20 пробелов вообще ничего не должны означать. На стороне PHP их нужно просто «выплюнуть» в вывод, а в обработчике
На стороне клиента это тоже нужно учесть:
Откуда число 20? Если подскажете — буду весьма признателен. Я его установил экспериментальным путем.
Кстати, насчет настройки PHP
С ее помощью можно обойти все уровни буферизации, вывести данные напрямую, после чего все буферы восстанавливаются.
Кстати, а почему именно пробел используется для уведомления о выполненной части задачи? Просто потому что почти любой формат представления данных в вебе такими пробелами не испортишь. Можно применить такой метод передачи уведомления о прогрессе операции, а после всего этого вывести отчет о результатах в JSON.
Если все привести в порядок, немного оптимизировать и дополнить код всеми возможностями, которые могут пригодиться, получится вот что:
Вместо вступления перед катом: Прошу не бить ногами. Гугление перед проработкой схемы не дало вменяемых результатов, поэтому ее и понадобилось выдумать, ввиду чего я и решил ее изложить в этой своей первой публикации.
<script>document.getElementById('progress').style.width = '1%';</script>
Этот вариант меня не устраивал по нескольким причинам, к тому же мне в принципе не нравится такой подход.
Итераций у меня было порядка 3000—5000. Я прикинул, что великоват трафик для такой несложной затеи. Кроме того, мне такой вариант казался очень некрасивым с технической точки зрения, а внешний вид страницы и вовсе получался уродлив: футер дойдет еще не скоро — после последнего уведомления о 100% выполнении задачи.
Увернуться от проблемы некрасивой страницы большого труда не составляло, но остальные минусы заставили меня обрадоваться и приступить к поискам более изящного решения.
Несколько наводящих вопросов. Асинхронные HTTP-запросы возможны? — Да. Можно ли с помощью одного-единственного байта сообщить, что часть большой задачи выполнена? — Да. Можем ли мы постепенно (последовательно) получать и обрабатывать данные с помощью
XMLHttpRequest.onreadystatechange
? — Да. Мы даже можем воспользоваться заголовками HTTP для передачи предварительного уведомления об общей продолжительности выполняемой задачи (если это возможно в принципе).Решение простое. Основанная страница — это пульт управления. С пульта можно запустить и остановить задачу. Эта страница инициирует XMLHttpRequest — стартует выполнение основной задачи. В процессе выполнения этой задачи (внутри основного цикла) скрипт отправляет клиенту один байт — символ пробела. На пульте в обработчике
onreadystatechange
мы, получая байт за байтом, сможем делать вывод о прогрессе выполнения задачи.Схема такая. Скрипт операции:
<?php
set_time_limit(0);
// допустим, что итераций будет 50:
for ($i = 0; $i < 50; $i++) {
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('X-Accel-Buffering: no', true);
Кроме того, то ли 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>
Вместо вступления перед катом: Прошу не бить ногами. Гугление перед проработкой схемы не дало вменяемых результатов, поэтому ее и понадобилось выдумать, ввиду чего я и решил ее изложить в этой своей первой публикации.