Публикуется от имени jbubsk. Мы в Тинькофф Банке сто лет боролись с проблемой контекста текущего запроса на сервер. Это момент — когда приходит тикет о том, «что это за ошибка и почему она здесь?» — очень тонкий, и бьет разработчиков по самому больному. В этой статье расскажем, как нам удалось решить эту проблему.


Чтобы понять человека, нужно думать как человек



Представьте, что находясь на некой странице приложения, пользователь нажал на кнопку отправки формы или поиска в списке. Время ответа может оказаться большим и неважно, по какой причине: то ли это дорогая и ресурсоемкая операция, то ли интернет медленный, а может быть пользователь — супергерой, и время для него идет слишком медленно.
И вот запрос уже в пути, данные вовсю обрабатываются, но обрабатываются с ошибкой, которую нужно показать пользователю. Клиент интернет-банка, не дожидаясь ответа, переходит на другую страницу в приложении. Он радостно наблюдает выписку по счетам, и вдруг всплывает ошибка: «Дорогой Петр Михалыч, операция была отклонена, а причина этому — луна в Козероге и финансовый кризис». Но Петр Михалыч уже увлечен выпиской и знать не хочет, что не сработал запрос с предыдущей страницы.
Это плохо. Контекст приложения уже другой. Запрос, оставшийся в пендинге, желательно отменить, чтобы избежать сообщения об ошибке на странице, к которой ошибка уже не относится.

Как мы делаем интернет-банк для бизнеса



С приходом 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)


  1. webmasterx
    06.09.2016 14:41
    +9

    Не надо бояться страшных зверей и новых технологий.

    Полностью согласен. Если у всех россиян 100 мбит, LTE, intel core i5, айфон последней модели и закрыты все вкладки в браузере кроме сайта тинькофа.
    (на моммент публикации размер главной страницы весит уже 2.5Мб вместо 3


  1. 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


    1. KovVlad
      06.09.2016 17:06
      -1

      спасибо, поправил


    1. jbubsk
      06.09.2016 18:43

      Ссылка на injector получается в обход DI

      Да, на это пошли в виду внесения минимальных изменений. Решение с DI требует рефакторить сотни компонентов пробрасывая инжектор в родителя.
      Из ZoneService возвращается нетипизированный объект, хотя zone.d.ts существует

      Спасибо, упустили.


      1. 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



  1. qwase
    06.09.2016 17:06

    чОткий пацан


  1. kemsky
    06.09.2016 18:20

    Zone.js все еще сыроват, применять надо аккуратно. Мне не удалось его подружить c XMLHttpRequest (валится с ошибкой при попытке переиспользовать объект запроса), в то время как в ангуляре2 все работает.


  1. vintage
    06.09.2016 22:25

    Вы вполне могли бы использовать реактивные переменные ($jin.atom, cellx, ko), которые умеют деактивироваться при потере подписчиков и не костылять на ZoneJS.


    1. lalaki
      06.09.2016 22:43

      Как конкретно их использовать — оборачивать в них запросы к API? Разве не потребует это множества изменений кода приложения?


      И разве для этого не надо будет от них отписываться явно, для чего нужно будет написать кучу $onDestroy в компонентах? В чем же выигрыш тогда?


      1. vintage
        06.09.2016 23:29
        +1

        Потребует улучшения архитектуры, да :-) Когда я интегрировал атомы с ангуляром получалось как-то так:


        1. Ангуляровская компонента — это инстанс какого-то класса.
        2. У инстанса есть реактивные свойства.
        3. Когда инстанс убивается ($onDestroy) — прибиваются все его реактивные свойства.
        4. Когда меняется какое-либо реактивное свойство — вызывается отложенный рендеринг (digest).
        5. Каждое реактивное свойство имеет формулу.
        6. Формула может обращаться к другим реактивным переменным, например, к модели, а та к хттп ресурсу.
        7. Реактивные переменные сами отслеживают от кого зависят и кто зависит от них.
        8. Как только зависимых не остаётся, реактивная переменная самоуничтожается.
        9. Реактивная переменная, привязанная к запросу, при уничтожении останавливает запрос.

        А код компоненты может выглядеть как-то так:


        class MyProfile extends MyComponent {
            @frp get userName() {
                return HTTP.get('/user/').name
            }
        }

        DI добавьте по вкусу :-)


        1. lalaki
          07.09.2016 13:05

          а $onDestroy в данном подходе реализует кто? я так предполагаю, @frp записывают себя в некий реестр в инстансе MyProfile, с которым потом работает MyComponent#onDestroy()?


          основная мысль вроде ясна, и теперь с ней согласен: Зоны в данной задаче излишни — есть задача синхронизировать ЖЦ компонент и запросов, которые уже друг о друге знают, поэтому достаточно при уничтожении компоненты пометить выпущенные ею запросы как "неактуальные"; для этого у запросов должен быть соотв. интерфейс;


          в вашем варианте это решается оборачиванием запросов (и аналогичных ресурсов) в реактивные переменные; и у автора, и у вас запросы учитываются на уровне методов компоненты с помощью декоратора метода (@cancellable vs @frp), только у автора все отслеживает зона, в вашем (по моему предположению) все отслеживает сам компонент как инстанс MyComponent.


          Верно понял вашу мысль?


          1. vintage
            07.09.2016 15:19

            Да, только компонента контролирует ЖЦ не всего и вся, а только своих собственных реактивных свойств, а от чего они там зависят — её не касается.


            1. lalaki
              13.09.2016 08:41

              Разумеется) Спасибо!


  1. lalaki
    07.09.2016 13:13
    +1

    На второй взгляд пример кажется не очень жизненным: если пользователь начинает какую-то операцию, он же должен узнать о ее результате, иначе он будет рассчитывать на успех; поэтому обычно уведомления об ошибках (а бывает, и об успехе) отображаются в некой области, не зависящей от текущего контекста; классический пример — ButterBar (желтая полоска вверху) в Gmail. Для такого отображения не нужно отслеживать ЖЦ компоненты, вызвавшей запрос. Компонента при этом может также обработать результаты и по-своему.