Несмотря на то, что PHP чаще всего используется для создания сайтов и Web-сервисов, ни для кого не секрет, что его можно применять и для других задач, в том числе тех, которые могут выполняться достаточно длительное время. Например, когда вы рассылаете спам вконтактесоздаете дамп базы данных или, скажем, архивируете файлы на сайте, чтобы сделать бэкап, неплохо бы отобразить прогресс текущей операции. Но как это получше сделать? Именно об этом я вам и собираюсь рассказать.
Лично мне пришлось столкнуться с такой задачей, когда я разрабатывал свой PHP Obfuscator версии 2. Процесс обфускации толстых PHP-скриптов (или их большого количества) может оказаться достаточно долгим, и пользователю приятно будет видеть прогресс выполнения работы.
Как известно, PHP предлагает встроенный механизм для работы с сессиями. Это поможет нам в решении задачи. Если кратко, то все будет происходить примерно так:
- Сервер (PHP) начинает сессию, передает клиенту соответствующий cookie при первом обращении клиента на веб-страницу. Это происходит, когда пользователь открыл веб-страничку, на которой можно стартовать длительную операцию, в браузере.
- Клиент посылает запрос серверу, сервер выдает клиенту уникальный идентификатор задачи. По этому идентификатору в дальнейшем можно будет получать прогресс выполнения длительной операции. Этот идентификатор уникален только в пределах сессии. Таким образом, сессия + идентификатор (вместе) являются глобально уникальными в пределах сервера. Идентификатор необходим для того, чтобы можно было стартовать параллельно (из разных вкладок в одном браузере) несколько длительных операций и параллельно запрашивать их прогресс.
- Клиент посылает серверу запрос, который может выполняться очень долгое время, и ждет ответа. В этот запрос клиент включает полученный из пункта 1 идентификатор. Этот запрос можно делать через AJAX либо через скрытый IFRAME, это неважно. Сервер выполняет запрос (например, архивирует большое количество файлов), и дает знать о текущем прогрессе клиенту (см. следующий пункт).
- Клиент регулярно с некоторой частотой опрашивает сервер, передавая в запросе идентификатор задачи, и получает прогресс выполнения операции. Клиент перестает опрашивать сервер, когда запрос из пункта 3 закончил выполняться (это значит, задача выполнена).
Пришло время перейти к практической реализации описанного взаимодействия. Начнем с серверной части на PHP. Нам понадобится работать с сессиями, поэтому сделаем для этой задачи небольшой вспомогательный статический класс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 |
final class SessionHelper { //Открыта ли сессия private static $started = false; private function __construct() { } //Безопасное открытие сессии static private function safeSessionStart() { $name = session_name(); $cookie_session = false; if(ini_get('session.use_cookies') && isset($_COOKIE[$name])) { $cookie_session = true; $sessid = $_COOKIE[$name]; } else if(!ini_get('session.use_only_cookies') && isset($_GET[$name])) { $sessid = $_GET[$name]; } else { return @session_start(); } if(is_array($sessid) || !preg_match('/^[a-zA-Z0-9,-]+$/', $sessid)) { if($cookie_session) //Try to reset incorrect session cookie { setcookie($name, '', 1); unset($_COOKIE[$name]); if(!ini_get('session.use_only_cookies') && isset($_GET[$name])) unset($_GET[$name]); return @session_start(); } return false; } return @session_start(); } //Открыть сессию static public function init() { if(!self::$started) { if(self::safeSessionStart()) self::$started = true; } } //Открыта ли сессия static public function isStarted() { return self::$started; } //Завершить сессию static public function close() { if(self::$started) { session_write_close(); self::$started = false; } } //Получить значение ключа с именем $name из сессии //Если ключ отсутствует, будет возвращено значение $default_value static public function get($name, $default_value = null) { return isset($_SESSION[$name]) && !is_array($_SESSION[$name]) ? $_SESSION[$name] : $default_value; } //Установить значение ключа с именем $name в $value static public function set($name, $value) { $_SESSION[$name] = $value; } //Удалить ключ с именем $name из сессии static public function remove($name) { unset($_SESSION[$name]); } } |
В этом классе определенный интерес представляет функция safeSessionStart
, которая позволяет безопасно открыть сессию. В PHP нет удобных механизмов определения, передан ли скрипту корректный идентификатор сессии, поэтому приходится делать такой механизм вручную, проверяя этот идентификатор. Если сессия передана через cookie, то у нас есть возможность также сбросить некорректный идентификатор и перегенерировать его, чтобы всё же корректно начать сессию. Иногда это делается автоматически в PHP при вызове функции session_start
с некорректным идентификатором, но когда-то и нет (видимо, зависит от настроек php.ini
и версии PHP). Если идентификатор сессии не проверять, просто вызывая session_start()
, мы рискуем получить пачку предупреждений, если пользователь подсунет кривой PHPSESSID
.
Для того, чтобы открывать сессию и автоматически закрывать ее, когда она больше не требуется, я написал еще один класс (имплементирует этакое RAII для сессии):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class SessionInitializer { //Была ли инициализирована сессия при создании класса private $session_initialized; public function __construct() { $this->session_initialized = SessionHelper::isStarted(); SessionHelper::init(); } public function __destruct() { if(!$this->session_initialized) SessionHelper::close(); } } |
Этот класс в конструкторе будет стартовать сессию (если она еще не была открыта), а в деструкторе - закрывать ее в том случае, если сессия не была открыта в момент создания класса.
Далее, напишем совсем небольшой класс, помогающий получать параметры запросов к веб-страничке и отвечать клиенту в формате JSON:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
final class WebHelpers { private function __construct() { } //Получить значение из массива $_REQUEST //Если значение отсутствует, вернуть $default_value static public function request($name, $default_value = null) { return isset($_REQUEST[$name]) && !is_array($_REQUEST[$name]) ? $_REQUEST[$name] : $default_value; } //Выдать ответ в формате JSON static public function echoJson(Array $value) { header('Content-Type: application/json; charset=UTF-8'); $ret = json_encode($value); if($ret !== false) { echo $ret; return true; } return false; } } |
Кроме того, нам придется генерировать идентификаторы задач, которые должны быть уникальными в пределах сессии. Напишем класс и для этого (он также будет позволять получать текущий идентификатор задачи, переданный клиентом):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
final class TaskHelper { private function __construct() { } //Создать уникальный в пределах сессии идентификатор задачи static public function generateTaskId() { $session_initializer = new SessionInitializer; $id = SessionHelper::get('max_task', 0) + 1; SessionHelper::set('max_task', $id); return $id; } //Получить идентификатор задачи, переданный клиентом static public function getTaskId() { $task_id = WebHelpers::request('task'); if(!preg_match('/^\d{1,9}$/', $task_id)) return null; return (int)$_REQUEST['task']; } } |
Здесь в функции generateTaskId
мы получаем из сессии значение для ключа max_task
(если оно отсутствует, то по умолчанию берется значение 0), прибавляем к нему единицу и возвращаем это значение, не забыв сохранить его в сессии перед этим. Пока сессия открыта в том или ином скрипте PHP, файл сессии, который создает для нас PHP, блокируется на чтение и запись, поэтому другие обращения к тому же самому скрипту с тем же самым идентификатором сессии (PHPSESSID
) не приведут к порче идентификатора задачи, т.е. никакая синхронизация нам здесь не нужна - PHP уже сделал это за нас.
Функция getTaskId
необходима для получения и проверки идентификатора задачи, который клиент нам будет передавать (см. пункты 3 и 4). Пусть параметр идентификатора задачи будет называться task
.
Наконец, нам потребуется управлять прогрессом длительных операций (увеличивать его в процессе выполнения операции и сообщать о нем клиенту, если тот спросит). Для этого мы также сделаем класс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
class ProgressManager { //Идентификатор задачи private $task_id = 0; //Количество шагов в задаче private $step_count = 1; //Текущий шаг private $current_step = 0; //Инициализатор сессии на время работы менеджера private $session_initializer; //Создание менеджера прогресса для задачи с идентификатором $task_id public function __construct($task_id) { $this->session_initializer = new SessionInitializer; $this->task_id = $task_id; SessionHelper::set('progress' . $this->task_id, 0); SessionHelper::close(); } //Установка количества шагов прогресса public function setStepCount($step_count) { $this->step_count = $step_count; $this->current_step = 0; } //Увеличение прогресса на 1 (переход к следующему шагу) public function incrementProgress() { if(++$this->current_step >= $this->step_count) $this->current_step = $this->step_count; SessionHelper::init(); SessionHelper::set('progress' . $this->task_id, (int)(($this->current_step * 100.0) / $this->step_count)); SessionHelper::close(); header_remove('Set-Cookie'); } //Завершение подсчета прогресса public function __destruct() { SessionHelper::init(); SessionHelper::remove('progress' . $this->task_id); } //Получение значения прогресса для идентификатора задачи, переданного клиентом public static function getProgress() { $task_id = TaskHelper::getTaskId(); if($task_id === null) return null; $session_initializer = new SessionInitializer; $progress = SessionHelper::get('progress' . $task_id, null); if($progress === null) return null; return (int)$progress; } } |
При создании нового менеджера прогресса инициализируется сессия (если еще не была инициализирована), и текущий прогресс выставляется в 0, после чего сессия закрывается. Это очень важный момент: так сделано для того, чтобы клиент мог параллельно с тем же идентификатором сессии запрашивать прогресс с помощью функции getProgress
. Если бы сессия после модификации не закрывалась, PHP бы блокировал файл сессии до тех пор, пока длительная операция не завершится (я уже писал об этом выше), и прогресс запросить было бы невозможно.
Функции setStepCount
и incrementProgress
используются для непосредственной настройки прогресса. Первая выставляет количество шагов, а вторая инкрементирует текущий номер шага. Например, можно задать 10 шагов прогресса, и после этого он будет равномерно увеличиваться при десяти последовательных вызовых функции incrementProgress
. В функции incrementProgress
интересна строка, удаляющая все заголовки с cookies, которые были установлены. Дело в том, что функция session_start
, которая выполняется каждый раз при вызове incrementProgress
, устанавливает cookie с идентификатором сессии, который нам в данном случае не нужен. Если бы мы не удаляли этот заголовок, в браузер бы выдалось множество их дубликатов, так как session_start
вызывается в процессе изменения прогресса многократно. Следует отметить, что удалятся вообще все заголовки, касающиеся cookies, так что если вы будете во время выполнения длительной операции с отслеживанием прогресса устанавливать какие-то посторонние cookies, они до браузера не дойдут (просто придется несколько переделать код функции incrementProgress
). Мне этого не требовалось, поэтому для демонстрации я сделал всё просто и топорно.
Теперь у нас есть всё необходимое, поэтому перейдем к написанию кода, который всеми этими вспомогательными классами управляет. Сперва снимаем ограничение времени выполнения скрипта, так как будем выполнять длительные операции.
1 2 |
error_reporting(E_ALL); set_time_limit(0); |
Далее, если клиент запросил генерирование нового идентификатора задачи, выполняем соответствующую функцию и выходим:
1 2 3 4 5 |
if(WebHelpers::request('new_task') === '1') //Генерируем новый ID задачи { WebHelpers::echoJson(['task' => TaskHelper::generateTaskId()]); return; } |
Если клиент хочет получить текущее значение прогресса для той или иной задачи:
1 2 3 4 5 6 7 8 9 10 |
if(WebHelpers::request('get_progress') === '1') //Получаем прогресс { $progress = ProgressManager::getProgress(); if($progress !== null) WebHelpers::echoJson(['progress' => $progress]); else WebHelpers::echoJson([]); return; } |
И, наконец, если клиент желает запустить длительную операцию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
//Запускаем длительный процесс (на 60 шагов по 200 миллисекунд) с контролем прогресса const STEP_COUNT = 60; const STEP_DELAY = 200000; if(WebHelpers::request('long_process') === '1') { $task_id = TaskHelper::getTaskId(); if($task_id === null) return; $manager = new ProgressManager($task_id); $manager->setStepCount(STEP_COUNT); for($i = 0; $i !== STEP_COUNT; ++$i) { $manager->incrementProgress(); usleep(STEP_DELAY); } WebHelpers::echoJson([]); return; } |
Этот код создает экземпляр класса ProgressManager
, описанного выше, указывая в вызове конструктора идентификатор задачи, переданный клиентом. Далее в цикле выполняем длительную операцию, инкрементируя на каждой итерации прогресс.
С серверной частью мы закончили, переходим к клиентской. Для начала стартуем сессию и готовимся выводить HTML:
1 2 3 |
//Вывод странички SessionHelper::init(); ?> |
1 2 3 4 5 |
<!doctype html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <title>Progress test</title> |
Я использую в этом примере библиотеку JQuery для упрощения и ускорения разработки:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<script type="text/javascript" src="//code.jquery.com/jquery-latest.min.js"></script> <script type="text/javascript"> //Тут будут наши скрипты </script> <style> h3 { text-align: center; } .progress-div { padding: 5px; border: 1px solid gray; margin: 3px; } </style> </head> <body> <h3>Progress test</h3> <div id="progressContainer"> </div> <div> <button onclick="startProgress();">Start progress</button> </div> </body> </html> |
На демонстрационной страничке будет расположена кнопка "Start progress", стартующая длительную операцию с отслеживанием прогресса. Сделаем так, чтобы можно было начать несколько таких длительных процессов одновременно. Для этого нам необходим div
с идентификатором progressContainer
: в него мы будем динамически добавлять информацию обо всех запущенных процессах.
Теперь непосредственно перейдем к JavaScript'у:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
//Идентификаторы завершенных задач var finishedTasks = []; //Стартовать длительную задачу var startLongTask = function(task_id) { $.get("?", {long_process: 1, task: task_id}, function(data) { finishedTasks.push(task_id); $("#task-" + task_id).text("Finished"); }, "json"); } //Отслеживать прогресс длительной задачи var monitorProgress = function(task_id) { $.get("?", {get_progress: 1, task: task_id}, function(data) { if($.inArray(task_id, finishedTasks) != -1) return; if(data.progress !== undefined) $("#task-" + task_id).text("Progress: " + data.progress + "%"); setTimeout(function() { monitorProgress(task_id); }, 100); }, "json"); } //Запустить длительную задачу с отслеживанием прогресса var runTask = function(task_id) { var progressDiv = $("<div/>").addClass("progress-div"); $("<div/>").text("Task ID: " + task_id).appendTo(progressDiv); $("<div/>").attr("id", "task-" + task_id).text("Starting...") .appendTo(progressDiv); $("#progressContainer").append(progressDiv); startLongTask(task_id); monitorProgress(task_id); } //Получить новый уникальный идентификатор задачи, после чего //запустить длительную задачу с отслеживанием прогресса var startProgress = function() { $.get("?", {new_task: 1}, function(data) { runTask(data.task); }, "json"); } |
Здесь функция startProgress
вызывается при нажатии на кнопку "Start progress". Она запрашивает у сервера новый уникальный в пределах сессии идентификатор задачи. Когда ответ от сервера получен, можно начинать выполнение длительной задачи и отслеживать ее прогресс. Это делается в функции runTask
, которая в свою очередь добавляет в div
с идентификатором progressContainer
(о котором я писал выше) информацию о новой задаче (ее идентификатор и прогресс). Далее в функции startLongTask
делается запрос, который будет выполняться очень долго, при этом будет возможность отследить его прогресс. Когда запрос будет выполнен, в массив finishedTasks
будет добавлен соответствующий идентификатор задачи. Это нужно для того, чтобы в функции, выполняющей отслеживание прогресса (monitorProgress
) можно было определить, что задача действительно выполнена. Функция отслеживания прогресса вызывается с интервалом 1 раз в 100 миллисекунд, отправляя запрос прогресса выполнения задачи серверу и выводя значение прогресса (в процентах) в соответствующий элемент div
. Когда задача выполнена (ее идентификатор добавлен в массив finishedTasks
), отслеживание прогресса завершается.
В работе наша страничка будет выглядеть как-то так:
Вот, собственно, и всё. Я достаточно подробно расписал идею такого механизма и привел полностью рабочий пример, чтобы можно было без проблем использовать описанную методику в собственных проектах. Напоследок прикладываю полный вариант демо-скрипта: СКАЧАТЬ.
Спасибо, вещь весьма актуальная и полезная.
Как на этом заработать я не понял, в какой момент лавеха на счету появляется?
В момент, когда мозг включаешь
Спасибо за информацию. Единственный блог в рунете который так близок к повседневным задачам кодеров. Спасибо, что вы есть.
Долго с:
$task_id = TaskHelper::getTaskId();
if($task_id === null)
return;
$manager = new ProgressManager($task_id);
Хотеть:
//ProgressManager($task_id=getTaskId())
if ($manager = new ProgressManager()) return;
Оператор new не может в PHP вернуть false или 0. Зато конструктор может кидать исключения. Так что предложенная строка
if ($manager = new ProgressManager()) return;
невозможна в PHP.
Но никто не мешает 4 приведенные строки из поста засунуть в какую-нибудь функцию.
А нахрена использовать сессию, если можно использовать базу данных или memcached?
Используй, в чем проблема? PHP вообще позволяет сессии куда угодно писать, а я для простоты примера воспользовался стандартным механизмом.
Почему используется опрос, а не комет/вебсокет?
Комет включает в себя как опрос, так и вебсокеты, как я понял из этой статьи (и еще другие методы), так что вопрос не совсем корректен. А вебсокеты я не использовал, потому что не хотелось ограничивать работоспособность скрипта только браузерами, поддерживающими HTML5.
Хотя нет, неправильно я понял, судя по статье в википедии. Значит, использовал поллинг не только по причинам совместимости, но и потому что это самый простой метод.
Подскажите, как в этом коде отправить post запрос с данными, нужными для начала долгой задачи?
В javascript'е, начинающем долгую задачу, заменить $.get на $.post. Вот в этой строке:
$.get("?", {long_process: 1, task: task_id}, function(data)
...
Ууу, я делаю через form action = "index.php"
И он перезагружает страницу (что и должно быть) и данные исчезают. Тыкни пальцем на другой вариант
Через форму не получится сделать, потому что ты отправляешь все данные формы, и браузер будет отображать результат на новой странице, конечно. Есть вариант либо через ajax, как в примере, либо форму отправлять с таргетом, указывающим на какой-нибудь скрытый iframe.
Вот на Аяксе как это будет выглядеть?
Я же написал, get заменить на post :)
В примере и так ajax используется ведь.
Спасибо за пост, у меня все работает. Делал сам подобную вещь, но у меня ничего не получилось. xDDD. Посту скоро 10 лет, а он до сих актуален))))