Минимизация трафика в ASP.NET Web Forms, кликабельный div и периодический опрос сервера

    Технология ASP.NET Web Forms медленно но верно уходит в прошлое. На смену ей приходят Web API с Angular 6 и похожие стеки. Но мне по наследству достался проект именно на Web Forms с огромным legacy. У меня есть несколько друзей, у которых плюс-минус похожая ситуация. Давно написанные приложения на старой технологии, которые надо развивать и поддерживать. У Web Forms есть возможность на PostBack не обновлять всю страницу, а только её часть. То что обёрнуто в UpdatePanel. Это добавляет интерактива, но всё равно работает довольно медленно и потребляет много трафика, т.к. рендеринг каждый раз происходит на сервере, а клиенту передаётся готовая разметка, которую нужно вставить вместо текущей внутрь div. К слову, UpdatePanel как раз рендерится в div, в котором потом разметка и заменяется.

    Что тут можно сделать, чтобы минимизировать трафик?

    1. Написать WebMethod на странице и вызывать его с клиента средствами AJAX, при получении ответа изменять DOM через JS.

      Минус этого решения в том, что нельзя определить WebMethod в контроле. Вовсе не хочется весь функционал писать на странице, особенно если он используется несколько раз на разных страницах.
    2. Написать asmx сервис, и вызывать его с клиента. Это уже лучше, но в этом случае нет явной связи между контролом и сервисом. Количество сервисов будет расти с увеличением количества контролов. Кроме того нам не будет доступен ViewState, а значит параметры будем передавать явным образом при обращении к сервису, значит будем делать серверную валидацию и проверять имеет ли пользователь право сделать то что он запросил.
    3. Использовать интерфейс ICallbackEventHandler. Это на мой взгляд довольно неплохой вариант.

      Остановлюсь на нём подробнее.

    Первое что нужно сделать это отнаследовать наш UserControl от ICallbackEventHandler и написать методы RaiseCallbackEvent и GetCallbackResult. Немного странно, что их 2. Первый отвечает за получение параметров от клиента, второй за возвращение результата.
    Выглядеть это будет примерно так

    public partial class SomeControl : UserControl, ICallbackEventHandler
        {
    
            #region Поля
    
            /// <summary>
            /// Идентификатор некоего файла
            /// </summary>
            private Guid _someFileId;
    
            #endregion
    
            #region Реализация ICallbackEventHandler
    
            /// <inheritdoc />
            public void RaiseCallbackEvent(string eventArgument)
            {
                //сначала управление придёт сюда
                try
                {
                    dynamic args = JsonConvert.DeserializeObject<dynamic>(eventArgument);
    
                    _someFileId = (Guid) args.SomeFileId;
                    string type = (string) args.Type;
                }
                catch (Exception exc)
                {
                    //логируем ошибку
                    throw;
                }
    
            }
    
            /// <inheritdoc />
            public string GetCallbackResult()
            {
                //затем сюда
                try
                {
                    //делаем что-то полезное
    
                    return JsonConvert.SerializeObject(new
                    {
                        Action = actionName,
                        FileId = _someFileId,
                    });
                }
                catch (Exception exc)
                {
                    //логируем ошибку
                    throw;
                }
            }
    
            #endregion
    
        }
    

    Это была серверная часть. Теперь клиентская

    var SomeControl = {
    
        _successCallbackHandler: function (responseData) {
            let data = JSON.parse(responseData);
            switch (data.Action) {
                case "continue":
                    //делаем что-то на клиенте
                    break;
                case "success":
                    //или делаем что-то другое
                    break;
                case "fail":
                    //или делаем это
                    break;
                default:
                    //не рекомендую использовать alert, но для примера пойдёт
                    alert("Произошла ошибка при получении данных от сервера");
                    break;
            }
        },
    
        _failCallbackHandler: function() {
            alert("Произошла ошибка при получении данных от сервера");
        },
    
    }
    

    Это ещё не всё. Нам ещё необходимо сгенерировать JS что-бы связать все наши функции

            protected override void OnLoad(EventArgs e)
            {
                base.OnLoad(e);
                //Добавляем на страницу SomeControl.js, если его ещё нет
                Page.ClientScript.RegisterClientScriptInclude(Page.GetType(), "SomeControl", "/Scripts/controls/SomeControl.js?v=2.24.0");
                string callbackArgument = //задаём некий тип
                //***Тут самое интересное.*** Регистрируем в JS в объекте SomeControl функцию CallServer. Никогда так не называйте объекты и функции, это только для примера
                ScriptManager.RegisterStartupScript(Page, Page.GetType(), "SomeControl.Initialize",
                    $@"SomeControl.CallServer = function(someFileId) {{
                        let args = JSON.stringify({{ SomeFileId : someFileId, Type: '{callbackArgument}' }});
                        {Page.ClientScript.GetCallbackEventReference(this, "args", "SomeControl._successCallbackHandler", string.Empty, "SomeControl._failCallbackHandler", true)};
                     }};",
                    true);
    
                //Регистрируем контрол как асинхронный
                ScriptManager.GetCurrent(Page)?.RegisterAsyncPostBackControl(this);
            }
    

    Это очевидно code behind контрола.

    Самое интересное — это генерация JS функции методом GetCallbackEventReference.

    Передаём в него

    • ссылку на наш контрол
    • имя JS-переменной, значение которой будет передано на сервер в метод RaiseCallbackEvent через eventArgument (строкой выше сериализуем объект в JSON для передачи и собственно устанавливаем значение этой самой переменной args)
    • имя JS-функции обратного вызова для случая успешного выполнения
    • контекст выполнения (я им не пользуюсь)
    • имя JS-функции обратного вызова для случая если что-то пошло не так
    • будем валидировать средствами ASP.NET пришедший на сервер запрос

    Как это всё будет работать в связке?

    Из JS мы можем вызвать SomeControl.CallServer, эта функция создаст локальную переменную args и передаст управление функции, которая сделает запрос на сервер через AJAX.
    Далее управление передаётся серверному методу RaiseCallbackEvent. Всё что было в клиентской переменной args теперь попало в серверный входной параметр eventArgument.
    После выполнения RaiseCallbackEvent управление будет передано GetCallbackResult.

    Строка, которую мы вернём через return будет отправлена на клиента и попадёт во входной параметр функции SomeControl._successCallbackHandler, то есть в responseData.
    Если на каком-то этапе серверный код выдаст Exception, то управление будет передано клиентскому SomeControl._failCallbackHandler

    Ещё необходимо сказать про ViewState. ViewState передаётся с клиента на сервер, и им можно пользоваться, но только в режиме ReadOnly, т.к. обратно на клиента ViewState не отправляется.

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

    Второй вопрос, который я хочу осветить в этой статье — это кликабельные div-ы или как можно вызвать обновление UpdatePanel со стороны клиента.

    Зачем нужны кликабельные div-ы, можно же просто использовать <asp:Button>?
    Мне нравиться, что div можно сверстать как мне хочется, я не ограничен рамками input type=«button»

    Для реализации нужно отнаследоваться от интерфейса IPostBackEventHandler

    У него всего 1 метод

    public void RaisePostBackEvent(string eventArgument)

    Теперь, как и в предыдущем случае, нам необходимо сгенерировать JS для вызова этого метода

    Выглядит это так

    Page.ClientScript.GetPostBackEventReference(this, callbackArgument)

    callbackArgument задаётся на сервере и поменять его на клиенте не выйдет. Но всегда можно что-то положить в HiddenField. У нас же полноценный PostBack

    Теперь результат выполнения GetPostBackEventReference можно повесить на onclick любого div или span или вообще чего угодно.

    Или просто вызвать из JS по таймеру.

    Обязательно регистрируем контрол как асинхронный (на OnLoad вызываем

    ScriptManager.GetCurrent(Page)?.RegisterAsyncPostBackControl(this);
    ), иначе даже будучи внутри UpdatePanel будет вызван синхронный PostBack и обновится вся страница, а не только содержимое UpdatePanel

    Используя 2 описанных выше метода я реализовывал, например, такой сценарий.

    Пользователь нажал на кнопку, на сервер ушёл маленький запрос на длительную операцию (10-15 сек), пользователю пришёл короткий ответ, при разборе которого клиентский скрипт вызывает setTimeout. В setTimeout передаётся функция для callback на сервер для того, чтобы узнать о результатах запрошенной ранее операции. Если результат готов, то вызываем PostBack в UpdatePanel — происходит обновление заданной UpdatePanel. Если же результат ещё не готов, то опять вызываем setTimeout.

    Всем кто всё ещё работает с Web Forms — удачи, надеюсь статья сделает ваши системы быстрее и красивее, и пользователи скажут вам слова благодарности.
    • +19
    • 3,1k
    • 9
    Поделиться публикацией

    Похожие публикации

    Комментарии 9
      +1
      Давным давно в одном, даже по тем временам, легаси проекте на WebForms я добавлял поддержку ASP.NET Mvc. Даже статью тут писал, habr.com/post/153387
      Вы так не пробовали делать? Это конечно не спасет от поддержки текущего приложения, но хотябы новую функциональность писать удобнее будет, ну и потихоньку мигрировать можно.
        0
        Не хочу сейчас связываться в MVC, т.к. в ближайшее время будем делать плавный переход на ASMX-сервисы + Angular. Но там полностью меняется всё отображение для конкретной роли (point of view). В идеале старый и новый GUI не будут пересекаться.
        В конечном счёте планирую от текущего сайта оставить только ASMX-сервисы, которые затем неспешно переписать на Web API.
        Благо за последнее время почти всю логику изолировали от страниц и контролов
          0
          неспешно переписать на Web API.

          Простите, я просто разницы между MVC и Web API не вижу, вернее уже забыл, что она есть.
          Но насколько я помню Web API точно также прикручивается.

          В моем случае было невозможно просто создать еще одно MVC/WebApi приложение рядом, т.к. в то время вся логика была в одном WebForms приложении (прям все в одном проекте). Поэтому я и прикрутил MVC в тот-же проект.
          И потом уже переиспользовал легаси код в MVC обертке. Другими словами легаси код обернул в слой контроллеров и сервисов.
        0
        Следующие шаги:

        1. Сделать базовый класс для подобных контролов и унаследоваться от него

        2. Пометить методы в контроле которые будут вызываться на сервере атрибутом

        3. В базовом классе через рефлекшен вызывать эти методы

        4. Автоматически генерировать весь клиентский JS код в отдельном JS файле (при билде проекта или через Roslyn, но сейчас наверное лучше TS вместо JS) для вызовов базируясь на сигнатурах этих методов.

        По крайней мере у нас так было в своё время. В C# коде всё чистенько и для JS работают автокомплиты, проверки кода и т.п.

        Сделать базовый класс займёт некоторое время, но потом времени экономиться очень много, особенно на усилиях тестировщиков.
          0
          4. Автоматически генерировать весь клиентский JS код в отдельном JS файле (при билде проекта или через Roslyn, но сейчас наверное лучше TS вместо JS) для вызовов базируясь на сигнатурах этих методов.

          ПодскАжите в какую сторону копать для автогенерации JS?
            0
            Любой способ подойдет, хоть консольное приложение. Но что-либо с поддержкой шаблонов делает это проще.

            Мы пользуем T4.

            Но лучше конечно генерировать TypeScript, а из него получать JS, даже если TypeScript не используется. JS получается гораздо лучше, плюс можно все потроха вывести в базовый TypeScript класс.
          –1
          Не проще добавить обработчик ashx?
            0

            Какой смысл минимизировать трафик, когда в почти любом SPA передается по несколько мб.

              0
              Минимизировать нужно всегда и всё, будь то трафик, поля в БД или потребление RAM. От этого в конечном счёте зависит скорость работы системы и её отклик для пользователя. Конечно при условии наличия времени. Но если можно сделать рефакторинг или оптимизацию не затягивая сроки задач, то их надо делать. Иначе система быстро превратится в очень медленную помойку

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

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