5 историй успеха, которые бы не случились без promises
Привет Хабр! Сегодня мы хотели бы порассуждать о promises и повторном использовании кода.Так получилось, что две эти вещи вкупе несколько раз очень сильно нас выручили, и мы хотели бы поделиться тем, как это было.Мораль проста: если вы еще не используете promise-ы, начните это делать!
Мы уже вкратце рассуждали на эту тему в двух наших статьях (один, ноль). Собственно, эта статья должна была быть финальным разделом к предыдущей. Но там чтиво тяжелое и большое, поэтому решили разбить на две.Здесь мы расскажем несколько реальных историй о том, как они нас спасали.
Небольшое предисловие: в одном из наших приложений все запросы к 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-объектам Проблема: Когда мы писали приложение, описанное в нашей предыдущей статье, то реализовали его как 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, в свою очередь, работает по следующей логике: При вызове метода trackStatus мы не показываем сообщение, а присваиваем ему некий уникальный номер и его же возвращаем (используем номер, который возвращается вызовом setTimeout). По прошествии определенного тайминга (у нас 300 мс), если не поступала команда resolveStatus, то мы начинаем отображать это сообщение и блокируем экран. По приходу команды resolveStatus мы меняем иконку статуса (success либо fail) и прячем сообщение в режиме анимации с небольшой задержкой. Это избавляет нас от «моргающего» экрана, когда время задержки (300 мс) практически совпало со временем выполнения запроса. Выглядит это примерно вот так: Случай 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 типов можно наловить очень много неочевидных проблем. Для решения потенциальных проблем, если позволяет performance, можно использовать jquery.extend с флагом deep или банальный JSON.parse (JSON.stringify ()).Случай 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 (или нативного setTimeout) на случай использования jquery.ajax не является абсолютно обязательным, поскольку все обработчики для jquery.Deffered, объявленные после его обработки, все равно будут вызваны. Но всё-таки, асинхронную по своей сути модель работы нарушать не стоит, поэтому используем _.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, и итог примерно вот такой:
А для крутых пользователей результат должен быть вот такой: После выбора пользователю присваивается некий токен и… Да не важно. Рассказ о том, как показать диалог выбора.По всему приложению… Уже написанному и выпущенному в production…
Решение: Спасло то, что на клиенте у нас уже был общий конвеер обработки ошибок. Суть его сводилась к массиву обработчиков, каждый из которых смотрел, может ли он обработать возникшую ошибку. Если один конкретный смог, метод возвращает true, и остальные не вызываются. Если не смог — возвращаем false, и попытка обработать ошибку продолжается.Примечание: в случае, когда надо обработать совершенно конкретную ошибку в совершенно конкретном месте, вызвать common-овый конвеер обработки — дело обработчика fail конкретного сервиса. Делает он это, если не опознал в ошибке то, что он призван обработать.Всё что нам понадобилось для решения проблемы — написать метод, который на старте приложения проверял права пользователя. И, в случае чего, вставлял в конвеер еще один обработчик статуса 403, который и покажет заветный диаложек.
Пара часов работы и — готово.
Спасибо всем, кто дочитал. До скорых встреч!