Отслеживание прогресса длительных операций в PHP

php

Несмотря на то, что PHP чаще всего используется для создания сайтов и Web-сервисов, ни для кого не секрет, что его можно применять и для других задач, в том числе тех, которые могут выполняться достаточно длительное время. Например, когда вы рассылаете спам вконтактесоздаете дамп базы данных или, скажем, архивируете файлы на сайте, чтобы сделать бэкап, неплохо бы отобразить прогресс текущей операции. Но как это получше сделать? Именно об этом я вам и собираюсь рассказать.

Лично мне пришлось столкнуться с такой задачей, когда я разрабатывал свой PHP Obfuscator версии 2. Процесс обфускации толстых PHP-скриптов (или их большого количества) может оказаться достаточно долгим, и пользователю приятно будет видеть прогресс выполнения работы.

Как известно, PHP предлагает встроенный механизм для работы с сессиями. Это поможет нам в решении задачи. Если кратко, то все будет происходить примерно так:

  1. Сервер (PHP) начинает сессию, передает клиенту соответствующий cookie при первом обращении клиента на веб-страницу. Это происходит, когда пользователь открыл веб-страничку, на которой можно стартовать длительную операцию, в браузере.
  2. Клиент посылает запрос серверу, сервер выдает клиенту уникальный идентификатор задачи. По этому идентификатору в дальнейшем можно будет получать прогресс выполнения длительной операции. Этот идентификатор уникален только в пределах сессии. Таким образом, сессия + идентификатор (вместе) являются глобально уникальными в пределах сервера. Идентификатор необходим для того, чтобы можно было стартовать параллельно (из разных вкладок в одном браузере) несколько длительных операций и параллельно запрашивать их прогресс.
  3. Клиент посылает серверу запрос, который может выполняться очень долгое время, и ждет ответа. В этот запрос клиент включает полученный из пункта 1 идентификатор. Этот запрос можно делать через AJAX либо через скрытый IFRAME, это неважно. Сервер выполняет запрос (например, архивирует большое количество файлов), и дает знать о текущем прогрессе клиенту (см. следующий пункт).
  4. Клиент регулярно с некоторой частотой опрашивает сервер, передавая в запросе идентификатор задачи, и получает прогресс выполнения операции. Клиент перестает опрашивать сервер, когда запрос из пункта 3 закончил выполняться (это значит, задача выполнена).

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

В этом классе определенный интерес представляет функция safeSessionStart, которая позволяет безопасно открыть сессию. В PHP нет удобных механизмов определения, передан ли скрипту корректный идентификатор сессии, поэтому приходится делать такой механизм вручную, проверяя этот идентификатор. Если сессия передана через cookie, то у нас есть возможность также сбросить некорректный идентификатор и перегенерировать его, чтобы всё же корректно начать сессию. Иногда это делается автоматически в PHP при вызове функции session_start с некорректным идентификатором, но когда-то и нет (видимо, зависит от настроек php.ini и версии PHP). Если идентификатор сессии не проверять, просто вызывая session_start(), мы рискуем получить пачку предупреждений, если пользователь подсунет кривой PHPSESSID.

Для того, чтобы открывать сессию и автоматически закрывать ее, когда она больше не требуется, я написал еще один класс (имплементирует этакое RAII для сессии):

Этот класс в конструкторе будет стартовать сессию (если она еще не была открыта), а в деструкторе - закрывать ее в том случае, если сессия не была открыта в момент создания класса.

Далее, напишем совсем небольшой класс, помогающий получать параметры запросов к веб-страничке и отвечать клиенту в формате JSON:

Кроме того, нам придется генерировать идентификаторы задач, которые должны быть уникальными в пределах сессии. Напишем класс и для этого (он также будет позволять получать текущий идентификатор задачи, переданный клиентом):

Здесь в функции generateTaskId мы получаем из сессии значение для ключа max_task (если оно отсутствует, то по умолчанию берется значение 0), прибавляем к нему единицу и возвращаем это значение, не забыв сохранить его в сессии перед этим. Пока сессия открыта в том или ином скрипте PHP, файл сессии, который создает для нас PHP, блокируется на чтение и запись, поэтому другие обращения к тому же самому скрипту с тем же самым идентификатором сессии (PHPSESSID) не приведут к порче идентификатора задачи, т.е. никакая синхронизация нам здесь не нужна - PHP уже сделал это за нас.

Функция getTaskId необходима для получения и проверки идентификатора задачи, который клиент нам будет передавать (см. пункты 3 и 4). Пусть параметр идентификатора задачи будет называться task.

Наконец, нам потребуется управлять прогрессом длительных операций (увеличивать его в процессе выполнения операции и сообщать о нем клиенту, если тот спросит). Для этого мы также сделаем класс:

При создании нового менеджера прогресса инициализируется сессия (если еще не была инициализирована), и текущий прогресс выставляется в 0, после чего сессия закрывается. Это очень важный момент: так сделано для того, чтобы клиент мог параллельно с тем же идентификатором сессии запрашивать прогресс с помощью функции getProgress. Если бы сессия после модификации не закрывалась, PHP бы блокировал файл сессии до тех пор, пока длительная операция не завершится (я уже писал об этом выше), и прогресс запросить было бы невозможно.

Функции setStepCount и incrementProgress используются для непосредственной настройки прогресса. Первая выставляет количество шагов, а вторая инкрементирует текущий номер шага. Например, можно задать 10 шагов прогресса, и после этого он будет равномерно увеличиваться при десяти последовательных вызовых функции incrementProgress. В функции incrementProgress интересна строка, удаляющая все заголовки с cookies, которые были установлены. Дело в том, что функция session_start, которая выполняется каждый раз при вызове incrementProgress, устанавливает cookie с идентификатором сессии, который нам в данном случае не нужен. Если бы мы не удаляли этот заголовок, в браузер бы выдалось множество их дубликатов, так как session_start вызывается в процессе изменения прогресса многократно. Следует отметить, что удалятся вообще все заголовки, касающиеся cookies, так что если вы будете во время выполнения длительной операции с отслеживанием прогресса устанавливать какие-то посторонние cookies, они до браузера не дойдут (просто придется несколько переделать код функции incrementProgress). Мне этого не требовалось, поэтому для демонстрации я сделал всё просто и топорно.

Теперь у нас есть всё необходимое, поэтому перейдем к написанию кода, который всеми этими вспомогательными классами управляет. Сперва снимаем ограничение времени выполнения скрипта, так как будем выполнять длительные операции.

Далее, если клиент запросил генерирование нового идентификатора задачи, выполняем соответствующую функцию и выходим:

Если клиент хочет получить текущее значение прогресса для той или иной задачи:

И, наконец, если клиент желает запустить длительную операцию:

Этот код создает экземпляр класса ProgressManager, описанного выше, указывая в вызове конструктора идентификатор задачи, переданный клиентом. Далее в цикле выполняем длительную операцию, инкрементируя на каждой итерации прогресс.

С серверной частью мы закончили, переходим к клиентской. Для начала стартуем сессию и готовимся выводить HTML:

Я использую в этом примере библиотеку JQuery для упрощения и ускорения разработки:

На демонстрационной страничке будет расположена кнопка "Start progress", стартующая длительную операцию с отслеживанием прогресса. Сделаем так, чтобы можно было начать несколько таких длительных процессов одновременно. Для этого нам необходим div с идентификатором progressContainer: в него мы будем динамически добавлять информацию обо всех запущенных процессах.

Теперь непосредственно перейдем к JavaScript'у:

Здесь функция startProgress вызывается при нажатии на кнопку "Start progress". Она запрашивает у сервера новый уникальный в пределах сессии идентификатор задачи. Когда ответ от сервера получен, можно начинать выполнение длительной задачи и отслеживать ее прогресс. Это делается в функции runTask, которая в свою очередь добавляет в div с идентификатором progressContainer (о котором я писал выше) информацию о новой задаче (ее идентификатор и прогресс). Далее в функции startLongTask делается запрос, который будет выполняться очень долго, при этом будет возможность отследить его прогресс. Когда запрос будет выполнен, в массив finishedTasks будет добавлен соответствующий идентификатор задачи. Это нужно для того, чтобы в функции, выполняющей отслеживание прогресса (monitorProgress) можно было определить, что задача действительно выполнена. Функция отслеживания прогресса вызывается с интервалом 1 раз в 100 миллисекунд, отправляя запрос прогресса выполнения задачи серверу и выводя значение прогресса (в процентах) в соответствующий элемент div. Когда задача выполнена (ее идентификатор добавлен в массив finishedTasks), отслеживание прогресса завершается.

В работе наша страничка будет выглядеть как-то так:

progress

Вот, собственно, и всё. Я достаточно подробно расписал идею такого механизма и привел полностью рабочий пример, чтобы можно было без проблем использовать описанную методику в собственных проектах. Напоследок прикладываю полный вариант демо-скрипта: СКАЧАТЬ.

Отслеживание прогресса длительных операций в PHP: 18 комментариев

  1. Спасибо за информацию. Единственный блог в рунете который так близок к повседневным задачам кодеров. Спасибо, что вы есть.

  2. Долго с:
    $task_id = TaskHelper::getTaskId();
    if($task_id === null)
    return;
    $manager = new ProgressManager($task_id);

    Хотеть:
    //ProgressManager($task_id=getTaskId())
    if ($manager = new ProgressManager()) return;

    1. Оператор new не может в PHP вернуть false или 0. Зато конструктор может кидать исключения. Так что предложенная строка

      if ($manager = new ProgressManager()) return;

      невозможна в PHP.

      Но никто не мешает 4 приведенные строки из поста засунуть в какую-нибудь функцию.

    1. Используй, в чем проблема? PHP вообще позволяет сессии куда угодно писать, а я для простоты примера воспользовался стандартным механизмом.

    1. Комет включает в себя как опрос, так и вебсокеты, как я понял из этой статьи (и еще другие методы), так что вопрос не совсем корректен. А вебсокеты я не использовал, потому что не хотелось ограничивать работоспособность скрипта только браузерами, поддерживающими HTML5.

      1. Хотя нет, неправильно я понял, судя по статье в википедии. Значит, использовал поллинг не только по причинам совместимости, но и потому что это самый простой метод.

  3. Подскажите, как в этом коде отправить post запрос с данными, нужными для начала долгой задачи?

    1. В javascript'е, начинающем долгую задачу, заменить $.get на $.post. Вот в этой строке:

      $.get("?", {long_process: 1, task: task_id}, function(data)
      ...

      1. Ууу, я делаю через form action = "index.php"
        И он перезагружает страницу (что и должно быть) и данные исчезают. Тыкни пальцем на другой вариант

        1. Через форму не получится сделать, потому что ты отправляешь все данные формы, и браузер будет отображать результат на новой странице, конечно. Есть вариант либо через ajax, как в примере, либо форму отправлять с таргетом, указывающим на какой-нибудь скрытый iframe.

            1. Я же написал, get заменить на post :)
              В примере и так ajax используется ведь.

  4. Спасибо за пост, у меня все работает. Делал сам подобную вещь, но у меня ничего не получилось. xDDD. Посту скоро 10 лет, а он до сих актуален))))

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

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