- PVSM.RU - https://www.pvsm.ru -

5 историй успеха, которые бы не случились без promises

Привет!

Сегодня мы хотели бы порассуждать о promises [1] и повторном использовании кода.
Так получилось, что две эти вещи вкупе несколько раз очень сильно нас выручили, и мы хотели бы поделиться тем, как это было.
5 историй успеха, которые бы не случились без promises - 1
Мораль проста: если вы еще не используете promise-ы, начните это делать!

Мы уже вкратце рассуждали на эту тему в двух наших статьях (один [2], ноль [3]). Собственно, эта статья должна была быть финальным разделом к предыдущей [2]. Но там чтиво тяжелое и большое, поэтому решили разбить на две.
Здесь мы расскажем несколько реальных историй о том, как они нас спасали.

Небольшое предисловие: в одном из наших приложений все запросы к back end отправляются через центральную точку — метод callService базового модуля, от которого наследуют все сервисы. На вход этот метод принимает параметры запроса, нормализует их и возвращает promise, на который вызывающая сторона может подписаться. Примерно вот так:

callService: function (settings)
        {
            //Нормализуем параметры вызова
            var callSettings = {
                type: settings.method || ETR.HttpVerb.POST,
                contentType: 'application/json; charset=utf-8',
                dataType: settings.dataType || undefined,
                url: settings.relativeUrl ? ETR.serviceRootURL + settings.relativeUrl : settings.url,
                data: settings.data,
                context: settings.context || this.requestContext,
                beforeSend: settings.beforeSend || undefined
            };
            //Вызываем
            return jquery.ajax(callSettings);
        }

И вот как подобная организация нас выручала.

Случай 1. Доступ к disposed-объектам

Проблема:

Когда мы писали приложение, описанное в нашей предыдущей статье [2], то реализовали его как SPA. Соответственно, пришлось очень плотно поработать над очисткой памяти, и буквально всё имело конвеер dispose. При этом, возможна ситуация, когда со страницы на сервер шлется запрос, и пользователь внезапно покидает страницу (например, он ткнул не тот пункт меню). View model текущего окна проходит конвеер dispose, пользователь уходит на другую страницу.
И тут с сервера возвращается результат запроса.
Говорить о том, что может случиться в такой ситуации, бессмысленно – случиться может всё, что угодно. Лучшее, что происходило — летела ошибка, и становилось понятно, что что-то не так.

Решение:

В упоминаемом выше методе callService добавили несколько строчек кода. Кратко их суть в том, что каждый promise мы запоминали. По завершении, либо при вызове метода dispose сервиса — снова забывали.

navThrottleKey: 'Any abracadabra. Guid for example or some string like this.',
callService: function (settings)
        {
            ...

            var promise = jquery.ajax(callSettings);
            var requestId = ++requestCounter;
            this.rejectsOnDispose[requestId] = promise;

            promise.always(_.bind(function ()
            {
                delete this.rejectsOnDispose[requestId];
            }, this));
            return promise;
        }
        dispose: function ()
        {
            for (var indexer in this.rejectsOnDispose)
            {
         
                this.rejectsOnDispose[indexer].abort(this.serviceThrottleKey);
                delete this.rejectsOnDispose[indexer];
            }
            this.rejectsOnDispose = null;
            this.requestContext = null;
        }

Вуаля. Десяток строчек кода решил проблему по всему приложению.

Отметим дополнительным комментарием поле

this.serviceThrottleKey

Это гарантированно уникальная строка, которая позволяет “опознать” описываемую ситуацию и не вызывать обработчики конвеера fail, который неизбежно запускается при вызове метода abort promise-а. То есть мы перехватываем свой же fault и глушим его, чтобы не выводить пользователю на UI сообщение об ошибке.

Случай 2. Usability

Проблема:

Бывает такая ситуация, что какой-то вызов к сервису выполняется долго. И, пока он не выполнится, мы не можем давать пользователю что-либо делать (например, когда это загрузка initial данных). При этом хочется показывать пользователю сообщение о том, что необходимо подождать, и заблокировать экран.

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

Решение:

Решение лежит все так же в нашем методе callService. Допишем туда еще пяток строчек кода (предыдущие удалим для простоты чтения кода).

callService: function (settings, statusMessage)
        {
            ...

            var sid = statusMessage ? statusTracker.trackStatus(statusMessage) : null;
            var promise = jquery.ajax(callSettings);
            
            promise.done(_.bind(function ()
            {
                if (sid !== null)
                {
                    statusTracker.resolveStatus(sid, ETR.ProgressState.Done);
                }
            }, this)).fail(_.bind(function ()
            {
                if (sid !== null)
                {
                    statusTracker.resolveStatus(sid, ETR.ProgressState.Fail);
                }
            }, this));
            return promise;
        }

Суть проста: если нам передана некая строка statusMessage как параметр, то этот вызов хочет показывать сообщение при вызове.
Показываем так:

statusTracker.trackStatus(statusMessage)

Прячем вот так:

statusTracker.resolveStatus(sid, ETR.ProgressState.Fail);

Объект statusTracker, в свою очередь, работает по следующей логике:

  1. При вызове метода trackStatus мы не показываем сообщение, а присваиваем ему некий уникальный номер и его же возвращаем (используем номер, который возвращается вызовом setTimeout [4]).
  2. По прошествии определенного тайминга (у нас 300 мс), если не поступала команда resolveStatus, то мы начинаем отображать это сообщение и блокируем экран.
  3. По приходу команды resolveStatus мы меняем иконку статуса (success либо fail) и прячем сообщение в режиме анимации с небольшой задержкой. Это избавляет нас от “моргающего” экрана, когда время задержки (300мс) практически совпало со временем выполнения запроса.

Выглядит это примерно вот так:
5 историй успеха, которые бы не случились без promises - 2

Случай 3. Двойные вызовы к сервисам

Проблема:

У нас возникла ситуация, когда несколько объектов на странице могут попросить одни и те же данные. Ходить на сервер за одними и теми же данными не хочется (как и показывать одинаковые статусы по типу описанных выше). Попробуем что-нибудь придумать.

Решение:

Решение лежит все в том же методе callService. Суть проста. Определить, что некоторый запрос может вызываться несколько раз, и при этом будет удовлетворительно вернуть один и тот же результат — дело вызывающей стороны. Дополняем метод callService еще десятком строчек:

callService: function (settings, statusMessage, singletKey)
        {
            …

            if (singletKey)
            {
                var singlet = wcfDispatcherDef.singletsCalls[singletKey];
                if (singlet)
                {
                    var singletResolver = jquery.Deferred();
                    singlet.done(function ()
                    {
                        singletResolver.resolveWith(callSettings.context, arguments);
                    }).fail(function ()
                    {
                        singletResolver.rejectWith(callSettings.context, arguments);
                    });
                    return singletResolver;
                }
            }
 	    
            if (singletKey)
            {
                wcfDispatcherDef.singletsCalls[singletKey] = promise;
            }
            …
        }

Примечание: подобный подход лучше использовать с осторожностью, поскольку в случае reference типов [5] можно наловить очень много неочевидных проблем. Для решения потенциальных проблем, если позволяет performance, можно использовать jquery.extend [6] с флагом deep или банальный JSON.parse(JSON.stringify()) [7].

Случай 4. О кэшировании данных

Проблема:

Пожалуй, большое описание тут не требуется. Необходимо кэшировать данные на клиенте на определенное время.

Решение:

Решением стал метод, который лежит в нашем базовом классе сервисов. На вход он принимает функцию того же контракта, что и callService, и параметры кэширования. Возвращает он функцию-обертку, которая перед вызовом сервиса посмотрит в кэш:

wrapWithCache: function (fn, cacheKey, expirationPolicy)
        {
            return _.wrap(fn, function (initialFn)
            {
                var cacheResult = cacheService.getCacheValue(cacheKey);
                if (cacheResult !== undefined)
                {
                    var q = jquery.Deferred();
                    _.delay(function (scope)
                    {
                        q.resolveWith(scope, [cacheResult]);
                    }, 0, this.requestContext || this);
                    return q;
                } else
                {
                    return initialFn.apply(this, Array.prototype.slice.call(arguments, 1))
                    .done(_.bind(function (result)
                    {
                        cacheService.setCacheValue(cacheKey, result, expirationPolicy);
                    }, this));
                }
            });
        }

Примечание 1: на самом деле, использование underscore delay [8] (или нативного setTimeout) на случай использования jquery.ajax не является абсолютно обязательным, поскольку все обработчики для jquery.Deffered, объявленные после его обработки, все равно будут вызваны [8]. Но всё-таки, асинхронную по своей сути модель работы нарушать не стоит, поэтому используем _.delay.

В итоге, если нам хочется обернуть кэшем какой-либо вызов, — вот такой, к примеру:

        {
            return this.callService({
                method: ETR.HttpVerb.POST,
                url: this.someServiceUrl + '/getSomeList',
                data: request
            }).fail(this.commonFaultHandler);
        }

то дописываем одну строчку кода в сервис:

this.getSomeList = this.wrapWithCache(this.getSomeList, 'some-cache-key', moment().add(30, 'm'));

Примечание 2: проблемы, оговоренные в предыдущей истории, так же справедливы и для этой: осторожней с reference types.

Случай 5. Об обработке ошибок

Проблема:

Случилась у нас как-то история: жило-было приложение, жило хорошо, не жаловалось.

И тут возникло пожелание — некоторые пользователи должны иметь возможность смотреть данные другого пользователя. При этом самому пользователю нам тут показать нечего, а вот если выберет, чьё показать — обязательно покажем. На сервере все это решили просто. Обычный пользователь, который не имеет права что-то смотреть, получает ошибку 403 Forbidden, и итог примерно вот такой:
5 историй успеха, которые бы не случились без promises - 3

А для крутых пользователей результат должен быть вот такой:
5 историй успеха, которые бы не случились без promises - 4
После выбора пользователю присваивается некий токен и… Да не важно. Рассказ о том, как показать диалог выбора.
По всему приложению… Уже написанному и выпущенному в production…

Решение:

Спасло то, что на клиенте у нас уже был общий конвеер обработки ошибок. Суть его сводилась к массиву обработчиков, каждый из которых смотрел, может ли он обработать возникшую ошибку. Если один конкретный смог, метод возвращает true, и остальные не вызываются. Если не смог — возвращаем false, и попытка обработать ошибку продолжается.
Примечание: в случае, когда надо обработать совершенно конкретную ошибку в совершенно конкретном месте, вызвать common-овый конвеер обработки — дело обработчика fail конкретного сервиса. Делает он это, если не опознал в ошибке то, что он призван обработать.

Всё что нам понадобилось для решения проблемы — написать метод, который на старте приложения проверял права пользователя. И, в случае чего, вставлял в конвеер еще один обработчик статуса 403, который и покажет заветный диаложек.

Пара часов работы и — готово.

Спасибо всем, кто дочитал. До скорых встреч!

Автор: eastbanctech

Источник [9]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/76838

Ссылки в тексте:

[1] promises: http://blog.mediumequalsmessage.com/promise-deferred-objects-in-javascript-pt1-theory-and-semantics

[2] один: http://habrahabr.ru/company/eastbanctech/blog/244745/

[3] ноль: http://habrahabr.ru/company/eastbanctech/blog/212197/

[4] setTimeout: http://javascript.ru/setTimeout

[5] reference типов: http://stackoverflow.com/questions/13266616/primitive-value-vs-reference-value

[6] jquery.extend: http://api.jquery.com/jquery.extend/

[7] JSON.parse(JSON.stringify()): http://stackoverflow.com/questions/7914968/cloning-whats-the-fastest-alternative-to-json-parsejson-stringifyx

[8] underscore delay: http://underscorejs.org/#delay

[9] Источник: http://habrahabr.ru/post/245361/