Реализация пошаговой работы PHP-скрипта с помощью AJAX

    Искал более-менее простое и универсальное средство для организации пошаговой работы скрипта, но так ничего и не нашел. Даже вопрос в QA задал, везде только общие фразы. Поэтому решил сам сделать такой инструмент.

    Для чего это вообще нужно?


    Бывает необходимо обработать скриптом какой-то очень уж большой файл, например, для импорта. Естественно, время работы скрипта увеличивается пропорционально размеру файла или количеству строк в нем.

    Хотелось бы разбить обработку файла на несколько частей и запускать скрипт в работу уже по частям.
    image
    Принцип реализации давно известен — обмен данными между сервером и клиентом:
    Клиент запускает скрипт, тот выполняет несколько итераций и возвращает клиенту номер строки, на которой он остановился. После этого клиент делает новый запрос, в котором передает скрипту этот номер и скрипт продолжает работу дальше.

    Собственно сам код



    Для работы нам понадобятся:

    index.html
    <html>
      <head>
        <title>ScriptOffset - инструмент для организации пошаговой работы скрипта</title>
        <script type="text/javascript" src="http://yandex.st/jquery/1.7.1/jquery.min.js"></script>
        <script type="text/javascript" src="scriptoffset.js"></script>
        <link rel="stylesheet" type="text/css" href="scriptoffset.css">
      </head>
      <body>
        <div class="form">
          <input id="url" name="url">
          <input id="offset" name="offset" type="hidden">
    
          <div class="progress" style="display: none;">
            <div class="bar" style="width: 0%;"></div>
          </div>
    
          <a href="#" id="runScript"  class="btn" data-action="run">Старт</a>
          <a href="#" id="refreshScript" class="btn" style="display: none;">Заново</a>
        </div>
      </body>
    </html>
    

    scriptoffset.php
    <?php
    // Отвечаем только на Ajax
    if ($_SERVER['HTTP_X_REQUESTED_WITH'] != 'XMLHttpRequest') {return;}
    
    // Можно передавать в скрипт разный action и в соответствии с ним выполнять разные действия.
    $action = $_POST['action'];
    if (empty($action)) {return;}
    
    $count = 50;
    $step = 1;
    
    // Получаем от клиента номер итерации
    $url = $_POST['url']; if (empty($url)) return;
    $offset = $_POST['offset'];
    
    // Проверяем, все ли строки обработаны
    $offset = $offset + $step;
    if ($offset >= $count) {
      $sucsess = 1;
    } else {
      $sucsess = round($offset / $count, 2);
    }
    
    // И возвращаем клиенту данные (номер итерации и сообщение об окончании работы скрипта)
    $output = Array('offset' => $offset, 'sucsess' => $sucsess);
    echo json_encode($output);
    

    scriptoffset.js
    function setCookie (url, offset){
        	var ws=new Date();
    		if (!offset && !url) {
    				ws.setMinutes(10-ws.getMinutes());
    			} else {
    				ws.setMinutes(10+ws.getMinutes());
    			}
    		document.cookie="scriptOffsetUrl="+url+";expires="+ws.toGMTString();
    		document.cookie="scriptOffsetOffset="+offset+";expires="+ws.toGMTString();
    	}
    	
    function getCookie(name) {
    		var cookie = " " + document.cookie;
    		var search = " " + name + "=";
    		var setStr = null;
    		var offset = 0;
    		var end = 0;
    		if (cookie.length > 0) {
    			offset = cookie.indexOf(search);
    			if (offset != -1) {
    				offset += search.length;
    				end = cookie.indexOf(";", offset)
    				if (end == -1) {
    					end = cookie.length;
    				}
    				setStr = unescape(cookie.substring(offset, end));
    			}
    		}
    		return(setStr);
    	}
    
    function showProcess (url, sucsess, offset, action) {
    		$('#url, #refreshScript').hide();
    		$('.progress').show();
    		$('#runScript').text('Стоп!');
    		$('.bar').text(url);
    		$('.bar').css('width', sucsess * 100 + '%');
    		setCookie(url, offset);
    
    		$('#runScript').click(function(){
    				document.location.href=document.location.href
    			});
    		
    		scriptOffset(url, offset, action);
    	}
    
    function scriptOffset (url, offset, action) {
    		$.ajax({
    			url: "http://bfmn.ru/scriptoffset/scriptoffset.php",
    			type: "POST",
    			data: {
    			    "action":action
    			  , "url":url
    			  , "offset":offset
    			},
    			success: function(data){
    				data = $.parseJSON(data);
    				if(data.sucsess < 1) {
    					showProcess(url, data.sucsess, data.offset, action);
    					} else {
    					setCookie();
    					$('.bar').css('width','100%');
    					$('.bar').text('OK');
    					$('#runScript').text('Еще');
    					}
    			}
    		});
    	}
    	
    $(document).ready(function() {
    	
    	var url = getCookie("scriptOffsetUrl");
    	var offset = getCookie("scriptOffsetOffset");
    	
    	if (url && url != 'undefined') {		
    			$('#refreshScript').show();
    			$('#runScript').text('Продолжить');
    			$('#url').val(url);
    			$('#offset').val(offset);
    		}
    	
    	$('#runScript').click(function() {
    		
    			var action = $('#runScript').data('action');
    			var offset = $('#offset').val();
    			var url = $('#url').val();
    			
    			if ($('#url').val() != getCookie("scriptOffsetUrl")) {
    					setCookie();
    					scriptOffset(url, 0, action);
    				} else {
    					scriptOffset(url, offset, action);
    				}
    			return false;
    		});
    		
    	$('#refreshScript').click(function() {
    		
    			var action = $('#runScript').data('action');
    			var url = $('#url').val();
    		
    			setCookie();
    			scriptOffset(url, 0, action);
    			return false;
    		});
    		
    });
    

    scriptoffset.css
    input {
        font-size: 13px;
        margin: 0;
        padding: 0 3px;
        vertical-align: middle;
        border: 1px solid #CCCCCC;
        border-radius: 3px 3px 3px 3px;
        color: #808080;
        display: inline-block;
        font-size: 13px;
        height: 26px;
        line-height: 18px;
        width: 243px;
        -moz-transition: border 0.2s linear 0s, box-shadow 0.2s linear 0s;
        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) inset;
    }
    
    .btn {
        font-size: 13px;
        padding: 5px 8px;
        background-color: #0064CD;
        background-image: -moz-linear-gradient(center top , #049CDB, #0064CD);
        background-repeat: repeat-x;
        border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25);
        color: #FFFFFF;
        display: inline-block;
        vertical-align: middle;
        border-radius: 3px 3px 3px 3px;
        text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
        text-decoration: none;
    }
    
    .btn:hover {
        background-position: 0 -15px;
    }
    
    .btn:active {
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25) inset, 0 1px 2px rgba(0, 0, 0, 0.05);
    }
    
    .progress {
        font-size: 13px;
        margin: 0;
        vertical-align: middle;
        background-color: #F7F7F7;
        background-image: -moz-linear-gradient(center top , #F5F5F5, #F9F9F9);
        background-repeat: repeat-x;
        border-radius: 4px 4px 4px 4px;
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) inset;
        height: 28px;
        width: 250px;
        overflow: hidden;
        display: inline-block;
    }
    
    .progress .bar {
        background-color: #0E90D2;
        background-image: -moz-linear-gradient(center top , #149BDF, #0480BE);
        background-size: 40px 40px;
        -moz-box-sizing: border-box;
        -moz-transition: width 0.6s ease 0s;
        background-repeat: repeat-x;
        box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.15) inset;
        color: #FFFFFF;
        float: left;
        font-size: 12px;
        height: 100%;
        text-align: left;
        padding: 5px 8px;
        font-size: 13px;
        text-shadow: 1px 1px #333;
        white-space: nowrap;
    }
    
    div.form {
        margin: 150px auto 0;
        width: 500px;
    }
    


    Для оформления css взял несколько правил из Bootstrap.

    Что в итоге

    В поле url мы указываем, например, ссылку на файл, который нужно обработать, и запускаем скрипт. Появляется прогресс-бар, а мы сидим и ждем, когда он доползет до 100 %, чтобы увидеть результат работы.

    При работе с этим решением:
    • Мы можем установить количество обрабатываемых строк за одну итерацию (в самом скрипте);
    • Пользователю показывается настоящий прогресс-бар, а не бесконечная «крутилка» — если прогресс-бар стоит на середине, значит обработана половина файла;
    • Пользователь может остановить выполнение скрипта. В этом случае offset записывается в cookies на 10 мин, чтобы он мог продолжить работу скрипта с того же места.
    • Если пользователь обновит страницу, ему будет предложено продолжить работу скрипта с места остановки или начать заново (так же благодаря cookies).


    Если у сообщества есть примеры реализации подобного функционала или вообще готовые решения для пошаговой работы со скриптами, буду благодарен ссылкам в комментариях.

    UPD. Решение, адаптированное для MODX здесь.
    Поделиться публикацией
    Комментарии 11
      0
      Demo. Google Chrome. Версия 22.0.1229.79 m

      Request URL:http://bfmn.ru/scriptoffset/scriptoffset.php
      Request Method:POST
      Status Code:200 OK

      Пустой ответ и нет реакции.
        0
        В поле ввода надо что-то написать, я в него вставляю путь к файлу, который надо обрабоать.

        Исправил демо, по умолчанию стоит /import/import.xml
        0
        «Хотелось бы разбить обработку файла на несколько частей и запускать скрипт в работу уже по частям.» — зачем? Только для прогрессбара и возможности остановить? Вам в ответах написали пару неплохих решений которые выполнят те же задачи, но при этом будут значительно быстрее, легче по ресурсам и пр. Или речь о хостинге который не позволяет ставить время выполнения и убивать процесс (хотя последнее можно и обойти)?

        Не смотрел кода, но если я правильно понял по описанию, каждый запрос это новый новый запуск скрипта, обработанные строки пропускаются, т.е. скрипт должен пропустить в файле миллион строк чтобы сотню обработать?
          0
          Предлагаю вам пойти дальше:
          Во-первых, отказаться от cookies, и хранить информацию о прогрессе скрипта на сервере. Все равно ведь ваш скрипт, я так понял, предназначен для админской части. А раз так, то многопрофильность ему не нужна, достаточно будет одного экземпляра. Правда в этом случае придется отслеживать конкурентов.
          Во-вторых, отказаться от итераций. Вместо этого — один сплошной поток операций. В вашем случае это будет цикл по всем строкам CSV. Внутри цикла периодически записывать текущее состояние, в базу или файл — не принципиально. Если скрипт оборвется по таймауту, то в следующий раз цикл начинать с последней запомненной строки. Это конечно может привести к лишним запросам к базе, если интервал м/у записями состояния будет большим, но это не страшно.
          Во-третьих, если хотите видеть актуальный прогресс выполнения, то нужно создать еще один скрипт, который читает текущий прогресс из базы (файла) и выдает браузеру. На клиенте, одновременно с запуском основного скрипта, запускаете серию период-ких запросов на чтение прогресса до тех пор, пока не закончится/оборвется основной скрипт.
            0
            Целью было получить с одной стороны простое (его не нужно настраивать, не нужно лезть в конфиги сервера, не нужно создавать дополнительные таблицы в базе данных), с другой стороны универсальное решение, чтобы его можно было использовать на любом сайте с любой CMS, на любом сервере, даже на shared-хостинге, который не позволяет ставить время выполнения скрипта.
            0
            Я и не предлагал вам лезть в конфиги сервера. Всего лишь изменить алгоритм взаимодействия сервера и клиента.
            Можно обойтись и без дополнительных таблиц в БД. Я не говорил что это обязательно. Что в конце концов стоит сериализовать переменную, хранящую число пройденных строк, в файл, а потом восстановить ее? Это же сущий пустяк.
            И к времени выполнения скрипта я тоже не предлагал привязываться. Оборвется скрипт — ничего страшного. При следующем запуске десериализуем упомянутую переменную и продолжаем процесс с соотв-щей строки. Правда, чтобы не было издержек, придется делать сериализацию после каждой строки. Но это не должно сказаться на быстродействии.
            Таким образом, такое более продвинутое (имхо) решение тоже можно использовать на любом… shared-хостинге. Собственно я его сам и использую, правда для немного других целей.
              0
              Сорри, ответил не в ту ветку.
              0


              1. Зачем два последних шага? Причём сначала сервер сообщает что обработал 50 записей и это ни что иное как 98% работы, а затем ещё раз 50 записей, но это уже 100%.

              2. Отличаются форматы offset. Во всех ответах это integer, а в самом последнем это строка.

              Код не читал, но пахнет он не очень хорошо.

              3. В вашем скрипте ограничение на количество строк, которое он обрабатывает за 1 раз. Но это не очень удобно для больших объёмов информации. Если вы хотите сделать импорт быстрее, то должны выполнить минимум запросов к серверу, а в каждом запросе обработать максимум строк. Очень часто не известно каким является этот максимум. По этому, нужно делать ограничение по времени. Например, скрипт работает в течении 1 минуты и обрабатывает столько строк сколько успеет. Или пусть он работает max_execution_time / 2.

              4. Какой смысл делать это на аяксе? Единственный плюс — это плавность ползунка когда у вас 50 запросов за 3 секунды. Но если делать каждый запрос по 5 минут, то можно перезагрузить страницу целиком. Зато у аякса есть огромный минус: он значительно усложняет отладку скрипта импорта. Бывает, клиент звонит через пол года после сдачи проекта и говорит что импорт отваливается на 958 строке и он не видит никаких ошибок и даже не может ничего скопипастить. По этому приходится просить у него import.xls и самому запускать скрипт с открытым фаербагом.
                0
                Просто мне не нравится целую минуту смотреть на бесконечно вращающийся кружок в заголовке окна))))
                –1
                Я использую такой вывод (обычный лог — файл около 120мб...).
                Можно им управлять и через AJAX (db нужна для команд).
                for ($i=1;$i<$lines;$i++){

                sleep(1);///что-то делаем…

                echo 'Строка: '.$i.' успешно обработана…
                ';
                ob_flush();flush();
                ////проверка базы: есть ли команда через ajax?
                }
                  0
                  Илья, ваше решение оказалось очень кстати и легко расширяемо! Спасибо :)

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

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