пятница, 7 мая 2010 г.

Индикация прогресса при длительных операциях в ASP.NET

Я думаю вы сталкивались с ситуацией, когда нужно что-то импортить, например CSV файл с продуктами, кастомерами, синхронизировать базы или работать с каким то тормознутым веб сервисом. Во всех перечисленных и подобных случаях страница "лочится" пока не закончится операция, но нам хотелось бы видеть какой-то прогресс, о чем сегодня и поговорим.
Недавно мне нужно было импортить продукты из индусского сервиса, не важно как, главное что страница лочилась на 1-2 минуты и непонятно, что там происходило и когда оно завершалось.
Существует несколько решений как можно узнать прогресс на длительной операции:
  1. Сохранять прогресс в сессию, а на клиенте должен быть таймер, который после запуска длительной операции будет опрашивать страницу с какой-то периодичностью, например каждую секунду или тп. Этот метод описан здесь;
  2. Создать вспомогательную страницу в iframe, при запуске которой в OnLoad сразу начинается длительная операция. Во время прогресса надо писать в Response вызов JS функции, которая будет вызываться в родительском окне, с которого был запущен iframe. Этот метод описан здесь;
  3. Сделать страницу асинхронной, через Async или ICallbackEventHandler Interface и опять-таки через таймер опрашивать сервер. Это способ можно отнести как вариация второго.
Из всех перечисленных мне не нравится никакой :)
  1. Потому, что надо делать вспомогательную страницу. Хоть автор и советует использовать только этот метод, но мне он кажется недоделаным.
  2. Потому, что както-то по индусски... опрашивать постоянно сервер? может в каком-то случае это и будет лучший вариант, но не сегодня.
  3. Ограниченные возможности по передаче параметров от клиента к серверу и наоборот. Можно передать, что угодно, но только в одной строке. А дальше думать формат, парсить... ну и + оргументы выше в пункте 2.
Мое решение похожее больше на 1 вариант, но без iframe. В результате получим примерно следующее:



На картинках выше показана индикация прогресса во время импорта продуктов, категорий и брендов из сервиса. Initialization показывает прогресс когда выполняется аутентификация или может создание вспомогательный классов, по вашему желанию. В мое случае я логинился на сервис. А теперь как это делалось. Для этого нам понадобится одна! страница, класс с длительной операцией, интерфейс через который будет уведомляться страница о прогрессе, пару js функций которые будут двигать прогресс бар и немного css для прогресс бара.

public interface IProgressReporter
    {
        void ReportInitializationFinished();
        void ReportCategoriesProgress(int count, int completed);
        void ReportProductsProgress(int count, int completed);
        void ReportBrandsProgress(int count, int completed);
        void ReportException(Exception exception);
    }

Методы интерфейса как я и говорил мы будет дергать из класса, который занимает длительным процессом. Ниже приведен сам класс, он примитивный, просто чтоб показать саму суть.

public class LongOpperations
    {
        public void ProcessCatalog(IProgressReporter progressReporter)
        {
            // some initialization, web service authentication, whatever
            Thread.Sleep(5000);
            progressReporter.ReportInitializationFinished();

            for (int i = 0; i < 100; i++)
            {
                Thread.Sleep(100);
                progressReporter.ReportCategoriesProgress(100, i + 1);
            }

            for (int i = 0; i < 20; i++)
            {
                Thread.Sleep(200);
                progressReporter.ReportBrandsProgress(20, i + 1);
            }

            for (int i = 0; i < 100; i++)
            {
                Thread.Sleep(200);
                progressReporter.ReportProductsProgress(100, i + 1);
            }
        }
    }
Отчетом о прогрессе выступает наша страница, для этого нужно реализовать наш интерфейс. Потом, когда вы будем стартовать длительную процедуру, мы передадим себя, страницу, в наш обработчик. В итоге получаем подобие шаблона Visitor. Самое интересное находится в методе Render. Начало ясное, если мы не нажали кнопку, то просто рендерим страницу как есть. Дальше мы должны отключить буферизацию страницы, в другом случае IE например закеширует первые 256 байт ответа и мы получим не то, что ожидали. Поэтому отключаем. Если мы после этого начнем репортить прогресс, то мы перетрем то, что существует на странице, поэтому ренедрим её как есть, а потом будет дописывать в Response наши ответы. Основная идея заключается в том, чтоб в Response записать вызов клиентской функции и сразу вывести его на страницу через Flush. Как только на странице появится script, он сразу выполнится, тем самым подвинет прогресс бары.

public partial class _Default : Page, IProgressReporter
    {
        private bool started;
        protected override void Render(HtmlTextWriter writer)
        {
            if (!started)
            {
                base.Render(writer);
                return;
            }

            Response.BufferOutput = false;
            base.Render(writer);
            Response.Flush();

            ReportInitializationStarted();
            new LongOpperations().ProcessCatalog(this);
        }

        protected void OnStartClick(object sender, EventArgs e)
        {
            started = true;
        }

        public void ReportInitializationStarted()
        {
            Response.Write("<script>ReportInitializationStarted();</script>");
            Response.Flush();
        }

        public void ReportInitializationFinished()
        {
            Response.Write("<script>ReportInitializationFinished();</script>");
            Response.Flush();
        }

        public void ReportCategoriesProgress(int count, int completed)
        {
            ReportProgress(pnlCategoryProgress.ClientID, lblCategory.ClientID, count, completed);
        }

        public void ReportProductsProgress(int count, int completed)
        {
            ReportProgress(pnlProductProgress.ClientID, lblProduct.ClientID, count, completed);
        }

        public void ReportBrandsProgress(int count, int completed)
        {
            ReportProgress(pnlBrandProgress.ClientID, lblBrand.ClientID, count, completed);
        }

        public void ReportException(Exception exception)
        {
            string progressCall = string.Format("<script>ReportException('{0}','{1}');</script>", exception.Message, exception.StackTrace);

            Response.Write(progressCall);
            Response.Flush();
        }

        private void ReportProgress(string progressBar, string percentLabel, int count, int completed)
        {
            double progress = (count == 0) ? 100 : (100.0 / count) * completed;

            string progressCall = string.Format("<script>UpdateProgress('{0}','{1}',{2},{3},{4});</script>", progressBar, percentLabel, progress, count, completed);

            Response.Write(progressCall);
            Response.Flush();
        }

Клиентский функции, которые двигают прогресс бары и тп.

function UpdateProgress(progressBarId, percentLabelId, progress, count, completed) {
            var progressBar = document.getElementById(progressBarId);
            var progressLabel = document.getElementById(percentLabelId);
            
            progressLabel.innerHTML = (progress == -1) ? '0' : completed + '/' + count;
            progressBar.style.width = progress + '%';            
        };

        function ReportException(message, stack) {
            var p = document.createElement('p');
            p.innerHTML = 'Message: ' + message + '
 Stack: ' + stack;
            document.getElementById('error').appendChild(p);
        };

        function ReportInitializationFinished() {
            var pnl = document.getElementById('pnlInitializing');
            pnl.className = null;
        };

        function ReportInitializationStarted() {
            var pnl = document.getElementById('pnlInitializing');
            pnl.className = 'progress';
            pnl.style.width = '100%'            
        };

Вот еще кусок разметки для ясности. Весь приводить не буду, т.к. там тоже самое и немного css.


Вот и все. Основная идея в том, чтоб запустив длительный процесс сразу отдавать ответ в виде js сриптовой инъекции, которая будет обновлять прогресс. Вы можете наделать любых методов в интерфейсе. Они могут быть настолько детальными на сколько вам необходимо. В моем случае я хотел еще справа от прогресс баров добавить лог ошибок и добавил соответсвующий метод в инерфейсе ReportException, оставил для примера.
Буду рад услышать ваши идеи и отзывы.

2 комментария:

  1. Не завершать респонс, а посылать куски данных это давно известный способ, очень многие сейчас так делают, тоже Google Wave, EtherPad, вот только они это делают не скриптами, они анализируют дом на JS походу, чтобы не разрасталась страница.

    ОтветитьУдалить
  2. на счет GW не знаю, надо погуглить, на gmail там через iframe закачка файлов делается. также iframe - это вариант если ты хочешь шоб не разросталась страница, у тебя тогда в iframe будет писаться parent.UpdateProgress() и будет обновляться основное окно, а все скрипты будут выводится в вспомогательное окно в iframe'e. Я написал, что не считаю этот способ наилучшим, как пишет автор. Я старался сделать более сбалансированное решение, меньше ресурсов - меньше кода. В общем все как обычно - it depends.

    ОтветитьУдалить