image

Руководство по использованию mergeMap и forkJoin вместо простых подписок для множественных запросов к API.

В этой статье я покажу два подхода к обработке множественных запросов в Angular с использованием mergeMap и forkJoin.

Содержание:


  1. Проблема
  2. subscribe
  3. mergeMap
  4. forkJoin
  5. Комбинируем mergeMap и forkJoin
  6. Сравнение subscribe с mergeMap и forkJoin

Проблема


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

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

Я покажу вам простое приложение, где нам потребуется сделать 3 запроса к тестовому API (https://jsonplaceholder.typicode.com):

  1. Авторизуемся и запрашиваем информацию о пользователе
  2. На основе информации о пользователе получим список постов пользователя
  3. На основе информации о пользователе получим список альбомов, созданных пользователем

subscribe – обычный способ обрабатывать запросы в Angular, но есть более эффективные методы. Сначала мы решим задачу с использованием subscribe, а затем улучшим решение при помощи mergeMap и forkJoin.

subscribe


Довольно простой способ. Делаем первый запрос к API. Затем, во вложенной подписке, чтобы можно было использовать первый ответ, делаем еще два запроса к API.

image

mergeMap


Этот оператор лучше всего использовать когда нам нужно вручную контролировать порядок запросов.

Итак, когда мы используем mergeMap?
Когда результат первого запроса к API нам нужен для того, чтобы сделать следующий.

image

Посмотрите на пример, мы видим, что для второго запроса нужен userId из ответа первого вызова.

Обратите внимание:

  1. flatMap – алиас для mergeMap
  2. mergeMap поддерживает несколько активных внутренних подписок одновременно, поэтому можно создать утечку памяти с такими долгоживущими подписками

forkJoin


Этот оператор подойдет, если нам нужно сделать несколько запросов и важен результат каждого. То есть можно сгруппировать несколько запросов, запустить их параллельно и вернуть только один observable.

Итак, когда мы используем forkJoin?

Когда запросы могут выполняться параллельно и не зависеть друг от друга.

image

Комбинируем mergeMap и forkJoin


Обычно в разработке мы сталкиваемся с ситуацией когда нужно сделать несколько запросов которые зависят от результата выполнения какого-то другого запроса. Давайте посмотрим как это можно провернуть с использованием mergeMap и forkJoin.

image

Так мы избежали вложенных подписок и разбили код на несколько небольших методов.

Сравнение обычной подписки с mergeMap и forkJoin


Единственная разница, которую я заметил, это парсинг HTML.

Давайте посмотрим на время, которое занял парсинг при использовании обычной подписки:

image

Теперь посмотрим сколько парсится HTML с использованием mergeMap и forkJoin

image

Я сравнивал результат несколько раз и пришел к выводу, что парсинг с mergeMap и forkJoin всегда быстрее но разница не очень большая (~100ms).
Самое важное – это способ сделать код более понятным и читабельным.

Подводя итог


Мы можем использовать RxJS для обработки множественных запросов в Angular. Это помогает нам писать более читаемый и поддерживаемый код. Ну и в качестве бонуса, мы видим небольшое увеличение производительности, если используем RxJS способы вместо обычных подписок.

Надеюсь, статья была полезна! Подписывайтесь на меня в Medium и Twitter. Не стесняйтесь комментировать и задавать вопросы. Буду рад помочь!

Исходники тут.

Комментарии (17)


  1. asvishnyakov
    15.10.2019 20:42
    -2

    Вы сделали вместо одного говнокода другой говнокод. Вонять не перестало. Http запросы никогда не будут возвращать результат более одного раза. То, что http client в angular возвращает observable вместо promise лишь говорит о криворукости создателей angular. Просто приводите observable к promise через toPromise() и используете нормальный линейный код с async — await.


    1. anotherpit
      15.10.2019 21:38
      +1

      Тоже топил за это долгое время, пока не понял вот какой момент.

      Принципиальное отличие Promise от Observable, из-за которого Promise действительно должны использоваться для HTTP-запросов, — одноразовость Promise.

      Но принципиальное отличие Observable от Promise, из-за которого Observable в реальности используются для HTTP-запросов, — отменяемость Observable.

      Поэтому у создателей Angular были не руки кривые, а выбор хреновый: Promise без возможности отмены или Observable без возможности явно объявить одноразовость.


      1. Xuxicheta
        16.10.2019 08:55
        +1

        не только отмена. Отмену можно реализовать без проблем с помощью своей обертки над xhr или используя AbortController

        Потоки позволяют включать http-запросы в цепочки операторов, используя всю мощь Rxjs.
        Конечно, многие операторы умеют работать и с промисами тоже, но удобнее когда все однородно.
        Кроме того, для «одноразовых» потоков тоже есть подходящие операторы, которые реагируют на завершение потока (например concatMap).

        Помимо HttpClient из Ангуляра для работы с запросами rxjs еще предлагает пакет ajax и метод fromFetch

        Когда мы видим — человек предлагает переехать с rx на async — это просто человек не умеет работать с rxjs, пока еще.
        Сам высказывал такие мысли, когда только перешел на Ангуляр.


        1. anotherpit
          16.10.2019 11:10

          Отмену можно реализовать без проблем с помощью своей обертки над xhr или используя AbortController


          Когда вы разработчик приложения, можно. Но когда вы разработчик Ангуляра, уже сложнее. Вы встаёте перед необходимостью ввести ещё один интерфейс (CancellablePromise a.k.a. OneTimeObservable), который толком не совместим ни с Promise, ни с Observable.

          Конечно, многие операторы умеют работать и с промисами тоже, но удобнее когда все однородно.


          У единообразия есть ценность, согласен. Но в случае с RxJS, к сожалению, получается всё однородно монструозно, а не однородно изящно, как хотелось бы. Впрочем, тут уже начинается вкусовщина, и предполагаю, что после этой фразы вы меня тоже занесёте в число тех, кто «пока не умеет работать с RxJS».


          1. Xuxicheta
            16.10.2019 11:31

            Представьте сценарий типа

            • юзер переходит по роуту с параметрами
            • запрашиваются соответствующие данные (хоть с бэка хоть откуда еще)
            • по данным после обработки строится форма
            • юзер должен ввести в форму N параметров и нажать или на нажать Ввод
            • после обработки данных выводится диалоговое окно, где юзер принимает какое-то решение
            • производится запрос предварительной проверки
            • выводится еще окно, скажем по таймеру
            • данные сохраняются


            C помощью rxjs я могу написать все это в одну осмысленную функциональную цепочку. Моструозно? Есть способ изящно собрать несколько событий разной природы в последовательность действий, чтобы окинуть это одним взглядом и понять что проиходит?

            Что касатеся Ангуляра у него помимо http-запросов еще очень много всего на потоках, было бы странно выделять http в промисы, потом все равно придется конвертить, для вышеописанных целей например.


            1. anotherpit
              16.10.2019 15:52

              было бы странно выделять http в промисы, потом все равно придется конвертить, для вышеописанных целей например.


              Этот аргумент работает в обе стороны. Конвертить придётся только при использовании в одном контексте с Observable. При использовании в контексте с другими промисами — не придётся. Реализация HTTP-запросов не знает и не должна знать, в каком контексте её будут использовать. Она должна экспоузить наружу корректный контракт, точно описывающий её семантику. А семантика такова, что это одноразовое отменяемое асинхронное действие.


              1. Xuxicheta
                16.10.2019 17:25

                В контексте Ангуляра промисы как правило не нужны. а вот потоки встречаются повсеместно. Работа с бэкендом почти всегда соседствует или с какими-то реакциями, или с представлением.
                Промисы появляются в основном если имеешь дело с какими-нибудь современными браузерными апи.

                Можете попробовать поработать с fetch или axios вместо HttpClient, сравнить для себя.


  1. tbl
    16.10.2019 11:26

    Ух блин, в основе концепции rx — это чистые функции, а тут спокойно this модифицируем, с таким «грязным» подходом что-нибудь где-нибудь будет постоянно протекать, да и сопутствующие undefined-значения надо будет постоянно превозмогать (будь то elvis-ооператоры или if). Если у вас все темплейты компонентов чуть более, чем полностью покрыты директивами *ngIf, то ладно, но я бы пересмотрел архитектуру и общий подход.


  1. Grazdan
    16.10.2019 11:27

    Я дико извиняюсь, но зачем нам сложности на фронте с 3 запросами, если можно по одному запросу забрать всё? Походу тут имеет место быть ошибка архитектуры


  1. vBlake
    16.10.2019 11:28

    Вашу статью да на пару дней бы раньше. Недавно сам столкнулся при рефакторинге чужого проекта, увидел callback-hell из observable и переписывая убил пару часов на то, что бы найти красивое решение (ангуляр до этого глубоко не щупал), пришёл к тому же решению, что и автор.


  1. Xuxicheta
    16.10.2019 11:46

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

    this.user$ = this.userService.get().pipe(
      map(users => users[0]),
      shareReplay(1)
    );
    this.userName$ = this.user$.pipe(pluck('username'));
    this.posts$ = this.user$.pipe(
      switchMap(user => this.postsService.get(user.userId)),
    )
    

    а подписки перенесем в шаблон компонента с помощью AsyncPipe.

    Это будет следующий этап освоения rx в Ангуляре.

    forkJoin тут даже не нужен, компонент должен сам вытаскивать свои данные через реактивную цепочку, а тут вы наоборот, делаете запрос и впихиваете ему свойства.


    1. kubk
      16.10.2019 14:07

      Полностью согласен. Такой подход обладает следующими преимуществами:
      — Нет нужды в ручной отписке, AsyncPipe сам отпишется при уничтожении компонента.
      — Можем использовать OnPush стратегию обнаружения изменений для улучшения производительности.
      — Нет внешнего состояния в this, код компактный и проще воспринимается.

      Если почитать ещё оригинальные статьи автора, то обнаружится, что учить ему ещё очень рано: в примерах ручные подписки, нет отписок, код слаботипизированный: levelup.gitconnected.com/communicate-between-angular-components-using-rxjs-7221e0468b2

      Надеюсь, что новички будут читать не только статьи, но и комментарии к ним, иначе научатся вредным подходам.


    1. alexs0ff
      16.10.2019 15:55

      запросы в потоки и работать с ними реактивно

      В этом случае, да — отличные замечания. Но, к сожалению, в обычной жизни не всегда можно обойтись без side effects, типичный пример — роутинг. Если нам при некоторых пришедших значениях с сервера нужно перебросить юзера на другой путь, то «здравствуй подписка на время жизни компонента».


      1. Xuxicheta
        16.10.2019 17:32

        Это реакция на событие, почему «к сожалению»? Событие всегда нужно обрабатывать руками.
        Подписка существует всегда, просто если данные нужны только чтобы показать их в шаблоне, то можно спрятать подписку в пайп.
        В вашем примере кстати тоже можно, но едва ли нужно.


    1. amakhrov
      16.10.2019 19:43

      А потом вдруг заметили, что в шаблоне мы два раза подписались на this.posts$, и побежали добавлять shareReplay(1) и к нему тоже.


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


      Если же захотим эти данные использовать, допустим, в обработчике события, то с потоками становится неудобно, и таки проще делать подписку и сохранять значения в this.


      1. Xuxicheta
        16.10.2019 20:21

        Ну я не хотел углубляться далеко, там конечно лучше publishReplay + refCount на все запросы навешивать.
        Лично я акиту предпочитаю, получать данные в резолвере и класть в стор, отображать из стора. В обоих случаях подписки скрыты.


  1. dark_gf
    16.10.2019 15:51

    А зачем в коде лишний map?
    Я бы написал так:

        this.http.get('https://jsonplaceholder.typicode.com/users?username=Bret').pipe(
          mergeMap( users => {
            const user = users[0];
            this.userName = user.username;
            
            return forkJoin([
              this.http.get(`https://jsonplaceholder.typicode.com/posts?userId=1`), 
              this.http.get(`https://jsonplaceholder.typicode.com/albums?userId=1`)
            ]);
          })
        )