Чтобы понять человека, нужно думать как человек
Представьте, что находясь на некой странице приложения, пользователь нажал на кнопку отправки формы или поиска в списке. Время ответа может оказаться большим и неважно, по какой причине: то ли это дорогая и ресурсоемкая операция, то ли интернет медленный, а может быть пользователь — супергерой, и время для него идет слишком медленно.
И вот запрос уже в пути, данные вовсю обрабатываются, но обрабатываются с ошибкой, которую нужно показать пользователю. Клиент интернет-банка, не дожидаясь ответа, переходит на другую страницу в приложении. Он радостно наблюдает выписку по счетам, и вдруг всплывает ошибка: «Дорогой Петр Михалыч, операция была отклонена, а причина этому — луна в Козероге и финансовый кризис». Но Петр Михалыч уже увлечен выпиской и знать не хочет, что не сработал запрос с предыдущей страницы.
Это плохо. Контекст приложения уже другой. Запрос, оставшийся в пендинге, желательно отменить, чтобы избежать сообщения об ошибке на странице, к которой ошибка уже не относится.
Как мы делаем интернет-банк для бизнеса
С приходом Angular 1.5 мы начали использовать его замечательные components. Они отлично легли в руки разработчиков, код стал лаконичнее, и наша команда с большим удовольствием впитывает все новшества. К сожалению, в любой разработке внедрение чего-то нового не обходится без трудностей.
Ситуация с Петром Михалычем важна независимо от того, есть ли у ангуляра компоненты или нет. Можно, конечно, передавать какую-то ссылку на экземпляр компонента, и из этого же компонента из функции в функцию вниз по цепочке до непосредственно сервиса, который делает запрос на сервер. Если надо, то по этой ссылке просто отменить через этот сервис запрос из пула, в котором он будет храниться. Сам компонент предоставляет удобный хук $onDestroy для таких целей.
Хотя это вполне работающий сценарий, хочется, чтобы в коде было меньше такой вермишели. Цепочка вызовов может быть длинной.
Создание контекста запроса на сервер само по себе непросто. Новое решение должно быть встроено в уже большую рабочую систему. Сильно усложняло задачу то, что нельзя было ломать работающий код. К счастью, вспомнились статьи про контекст во втором ангуляре. После копания в эту сторону, на помощь пришел Zone.js — тот самый, который успешно прикрутили в Angular 2 Многие не понимают, что это за зверь. Даже, несмотря на отсутствие примеров, отличающихся от измерения тайминга, получилось применить контекст зон. Некоторые недопонимания о природе зон все же остались и живут в моей голове.
Вот наше решение проблемы:
class AccountsController extends BaseController {
...
constructor(private accountsService: AccountsService) {
super()
}
@cancelable
getAccounts() {
this.accountsService.list(...).then(accounts => this.accounts = accounts);
}
}
class BaseController implements ng.IComponentController {
public serializeId: string;
constructor() {
this.serializeId = `${Date.now()}_${Math.random()}`;
}
$onDestroy() {
const injector: ng.auto.IInjectorService = angular.element(window.document.body).injector();
const apiService: ApiService = <ApiService>injector.get('ApiService');
apiService.cancelRequests(this.serializeId);
}
}
Декоратор @cancelable помогает нам на самом верхнем уровне положить вызов метода компонента в зону, привязав зону к экземпляру компонента. Привязка выглядит куда лучше цепочки пробрасываний этого референса из функции в функцию.
Так выглядит декоратор:
export function cancelable(targetInstance: any, key: string, targetFunction: any) {
return {
value: function (...args: any[]) {
let result;
zoneService.zone.current.run(() => {
zoneService.zone.reference = this.serializeId;
result = targetFunction.value.apply(this, args);
});
return result;
}
};
}
zoneService выглядит совсем просто
export class ZoneService {
get zone() {
return (<any>window).Zone;
}
}
export default new ZoneService();
Наш бизнес-сервис accountsService, который с помощью реализации апи-сервиса получает данные от сервера:
class AccountsService {
...
constructor(private apiService: ApiService) {
}
list(...): ICancelablePromise<AccountsDto> {
return this.apiService.makeGetRequest<AccountsDto>(...);
}
...
}
Ну и собственно сам apiService:
class ApiService {
private requestsPool: Map<string, ICancelablePromise<any>[]> = new Map<string, ICancelablePromise<any>[]>();
...
public makeRequest<T>(...): ICancelablePromise<T> {
const requestService = new HttpRequestService<T>(...);
this.httpRequestConfigService.getConfig(...)
.then(httpRequestConfig => requestService.doHttpRequest(httpRequestConfig));
this.addRequestToPool(zoneService.zone.reference, requestService.requestDeferred.promise);
return requestService.requestDeferred.promise;
}
public cancelRequests(requestKey: string) {
const requests = this.requestsPool.get(requestKey);
if (requests) {
_.forEach(requests, request => request.cancel());
this.requestsPool.delete(requestKey);
}
}
...
private addRequestToPool(key: string, value: ICancelablePromise<any>) {
const requests = this.requestsPool.get(key) || [];
requests.push(value);
this.requestsPool.set(key, requests);
}
}
В апи-сервисе мы записываем каждый запрос в пул, при это ключом является zoneService.zone.reference, который установился в зону на самом верхнем уровне в цепочке вызовов функций в декораторе.
...
zoneService.zone.current.run(() => {
zoneService.zone.reference = this.serializeId;
result = targetFunction.value.apply(this, args);
});
...
Запустив функцию в зоне, в любом последующем звене цепочки вызовов мы получим контекст вызова, просто получив зону. Это одним махом решает проблему контекстов для глубоких цепочек вызовов.
Ссылка на экземпляр компонента доступна в необходимом месте. Теперь в методе $onDestroy базового контроллера BaseController мы просто вызываем
...
apiService.cancelRequests(this.serializeId);
...
и получаем желаемый результат.
Не надо бояться страшных зверей и новых технологий.
Комментарии (14)
impwx
06.09.2016 14:42+9Идея хорошая, но есть пара комментариев. Во-первых, а нужен ли вам для вашего кейса весь Zone.js? Из всего функционала вы использовали только одну функцию, которую можно было бы реализовать самому в ~10 строчек:
window.zone = { context: {}, run: function(callback) { var backup = zone.context; zone.context = {}; callback(); zone.context = backup; } }
Во-вторых, в примерах попадаются какие-то мелкие досадные вещи, которые должны вызывать вопросы на code review:
- Ссылка на
injector
получается в обход DI - Из
ZoneService
возвращается нетипизированный объект, хотя zone.d.ts существует - «Пул запросов» — это не
pull
, аpool
jbubsk
06.09.2016 18:43Ссылка на injector получается в обход DI
Да, на это пошли в виду внесения минимальных изменений. Решение с DI требует рефакторить сотни компонентов пробрасывая инжектор в родителя.
Из ZoneService возвращается нетипизированный объект, хотя zone.d.ts существует
Спасибо, упустили.lalaki
06.09.2016 21:43+1ссылку на injector можно передавать с помощью условного
BaseController.setCurrentInjector()
при старте приложения — это позволит и уйти от предположения, чтоbody
— корневой элемент приложения, и в принципе уберет ненужную ответственность изBaseController
текущая реализация довольно хрупка — разработчики должны помнить, что нужно вызывать
super.constructor()
(а, при написании своего$onDestroy
, иsuper.$onDestroy()
), причем такие ошибки, особенно с$onDestroy
, могут заметить очень нескоро, и, скорее всего, уже пользователи;
корень проблемы — в использовании наследования —
class AccountsController extends BaseController
:
решение: превратить
BaseController
в декоратор класса, оборачивающий/создающий методы$onInit
и$onDestroy
- Ссылка на
kemsky
06.09.2016 18:20Zone.js все еще сыроват, применять надо аккуратно. Мне не удалось его подружить c
XMLHttpRequest
(валится с ошибкой при попытке переиспользовать объект запроса), в то время как в ангуляре2 все работает.
vintage
06.09.2016 22:25Вы вполне могли бы использовать реактивные переменные ($jin.atom, cellx, ko), которые умеют деактивироваться при потере подписчиков и не костылять на ZoneJS.
lalaki
06.09.2016 22:43Как конкретно их использовать — оборачивать в них запросы к API? Разве не потребует это множества изменений кода приложения?
И разве для этого не надо будет от них отписываться явно, для чего нужно будет написать кучу
$onDestroy
в компонентах? В чем же выигрыш тогда?vintage
06.09.2016 23:29+1Потребует улучшения архитектуры, да :-) Когда я интегрировал атомы с ангуляром получалось как-то так:
- Ангуляровская компонента — это инстанс какого-то класса.
- У инстанса есть реактивные свойства.
- Когда инстанс убивается ($onDestroy) — прибиваются все его реактивные свойства.
- Когда меняется какое-либо реактивное свойство — вызывается отложенный рендеринг (digest).
- Каждое реактивное свойство имеет формулу.
- Формула может обращаться к другим реактивным переменным, например, к модели, а та к хттп ресурсу.
- Реактивные переменные сами отслеживают от кого зависят и кто зависит от них.
- Как только зависимых не остаётся, реактивная переменная самоуничтожается.
- Реактивная переменная, привязанная к запросу, при уничтожении останавливает запрос.
А код компоненты может выглядеть как-то так:
class MyProfile extends MyComponent { @frp get userName() { return HTTP.get('/user/').name } }
DI добавьте по вкусу :-)
lalaki
07.09.2016 13:05а
$onDestroy
в данном подходе реализует кто? я так предполагаю,@frp
записывают себя в некий реестр в инстансеMyProfile
, с которым потом работаетMyComponent#onDestroy()
?
основная мысль вроде ясна, и теперь с ней согласен: Зоны в данной задаче излишни — есть задача синхронизировать ЖЦ компонент и запросов, которые уже друг о друге знают, поэтому достаточно при уничтожении компоненты пометить выпущенные ею запросы как "неактуальные"; для этого у запросов должен быть соотв. интерфейс;
в вашем варианте это решается оборачиванием запросов (и аналогичных ресурсов) в реактивные переменные; и у автора, и у вас запросы учитываются на уровне методов компоненты с помощью декоратора метода (
@cancellable
vs@frp
), только у автора все отслеживает зона, в вашем (по моему предположению) все отслеживает сам компонент как инстансMyComponent
.
Верно понял вашу мысль?
lalaki
07.09.2016 13:13+1На второй взгляд пример кажется не очень жизненным: если пользователь начинает какую-то операцию, он же должен узнать о ее результате, иначе он будет рассчитывать на успех; поэтому обычно уведомления об ошибках (а бывает, и об успехе) отображаются в некой области, не зависящей от текущего контекста; классический пример — ButterBar (желтая полоска вверху) в Gmail. Для такого отображения не нужно отслеживать ЖЦ компоненты, вызвавшей запрос. Компонента при этом может также обработать результаты и по-своему.
webmasterx
Полностью согласен. Если у всех россиян 100 мбит, LTE, intel core i5, айфон последней модели и закрыты все вкладки в браузере кроме сайта тинькофа.
(на моммент публикации размер главной страницы весит уже 2.5Мб вместо 3