Каждый картограф на своем месте с NgRx Effects!

Операторы отображения. Если вы выполняли NgRx какое-то время, вы, вероятно, знаете о споре, который часто возникает вокруг того, какой оператор сопоставления следует использовать в эффектах. Разногласия, путаница и часто меняющаяся дорожная карта.

Мы начали с switchMap наиболее существенного оператора наблюдаемого отображения в rxjs. Этот простой оператор позволяет нам «переключать» потоки уведомлений с наблюдаемых, переходя от исходного потока… исходного наблюдаемого… к новому или другому наблюдаемому.

Затем мы узнали, что у switchMap были некоторые потенциальные проблемы. Он отменяет ранее переключенные наблюдаемые, когда новые уведомления приходят на исходный. Итак, мы начали использовать exhaustMap, который не отменял бы никакие текущие или «в полете» наблюдаемые. Это было здорово, поскольку в большинстве случаев мы склонны делать запросы к веб-API в наших эффектах, и мы не хотим всегда отменять предыдущие запросы в пользу последующих запросов.

Конечно, потом мы узнали, что exhaustMap тоже не всегда так хорош! Вместо того, чтобы отменять текущие запросы, он просто отбрасывает последующие запросы до тех пор, пока предыдущий не будет завершен. Ушел. Навсегда. Итак, мы начали использовать concatMap.

Это оно! Это бог-картограф! Тот, который решит все наши проблемы. Это не отменяет ни предварительные требования, ни последующие! Это подойдет.

Конечно ... это не ... Мы неизбежно узнали, что concatMap, хотя он не отменяет и не отбрасывает что-либо и действительно будет стоять в очереди, постановка в очередь также не всегда является лучшим решением. Это может затруднить выполнение действий с высокой пропускной способностью и их последствия. Итак, мы начали использовать mergeMap, также называемый flatMap.

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

Э ... извините ... Нанесите Толкиена воском на минутку ... О_о

Что ж, mergeMap, оказывается, неплохо работает. Это не отменяет. Не падает. Не стоит в очереди. Это позволяет всему работать одновременно. Выглядит идеально, верно! Мы нашли нашего One Mapper! Возможно…

У каждого картографа свое место

Сегодня я собираюсь поделиться своими мыслями. Вместо того, чтобы аплодировать тому, что сообщество NgRx действительно нашло идеального картографа ... Я здесь, чтобы сказать, что у каждого картографа есть свое место. Нет единого картографа, который бы управлял ими всеми. ;)

Семантика

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

Что означает переключение и отображение? Что означает исчерпать и нанести на карту? Смысл этих вещей может помочь вам выбрать лучшего оператора сопоставления для каждого случая использования. Бывают случаи, когда switchMap именно то, что вам нужно, а в других случаях concatMap - именно то, что нужно для работы.

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

Отображение поведения

Чтобы иметь возможность выбрать правильный инструмент, подходящего картографа для работы, вы должны сначала понять уникальные особенности каждого инструмента. Вы должны понимать, что значит использовать каждый из них… должны понимать их семантику. С этой целью вот ключевые особенности каждого наблюдаемого оператора отображения в rxjs:

  • switchMap: не одновременно; Упреждающий; Отмена (последующая в пользу предшествующей)
  • exhaustMap: не одновременно; Настойчивый; Отбрасывание (предварительное в пользу последующего)
  • concatMap: не одновременно; Очередь; Последовательный (по одному)
  • mergeMap: одновременно; Параллельный; Одновременный (все вместе)

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

Некоторые варианты использования

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

Использование отмены

Несмотря на свое очернение, switchMap все еще имеет место в эффектах NgRx. Хотя это может быть не самый полезный картограф для каждого случая, иногда он находит свое место. Отличным примером могут быть реализации с опережением типа. С опережением ввода цель состоит в том, чтобы держать пользователя в курсе того, какие варианты выбора он может сделать во время набора текста. Таким образом, получение самых последних релевантных результатов как можно более активно становится критически важным. Кроме того, опережающие типы обычно используются при запросе больших объемов информации, которая обычно не помещается локально в состояние приложения, находящееся в памяти браузера.

@Effect({dispatch: true})
typeAheadSearch$: Observable<Action> = this.actions$.pipe(
    ofType<TypeAheadSearch>(SearchActions.TYPE_AHEAD_SEARCH),
    map(action => action.payload),
    exhaustMap(criteria => // Will DROP subsequents if prior exists!
        this.searchService.quickSearch(criteria).pipe(
            map(results => new TypeAheadResults({results})),
            catchError(err => new TypeAheadFailure({err})
    ))
);

В таком контексте ожидание завершения запроса перед тем, как предоставить другому более свежую и более актуальную информацию, противоречит интуиции. Отбрасывание последующих запросов до завершения предыдущих (exhaustMap) может привести к устаревшим или даже неверным результатам.

@Effect({dispatch: true})
typeAheadSearch$: Observable<Action> = this.actions$.pipe(
    ofType<TypeAheadSearch>(SearchActions.TYPE_AHEAD_SEARCH),
    map(action => action.payload),
    concatMap(criteria => // Will QUEUE...may delay!
        this.searchService.quickSearch(criteria).pipe(
            map(results => new TypeAheadResults({results})),
            catchError(err => new TypeAheadFailure({err})
    ))
);

Ожидание завершения предыдущих запросов перед попыткой последующих запросов (concatMap) может в конечном итоге привести к отображению правильной информации, но может иметь заметное отставание.

typeAheadSearch$: Observable<Action> = this.actions$.pipe(
    ofType<TypeAheadSearch>(SearchActions.TYPE_AHEAD_SEARCH),
    map(action => action.payload),
    mergeMap(criteria => // No guarantee of order!!
        this.searchService.quickSearch(criteria).pipe(
            map(results => new TypeAheadResults({results})),
            catchError(err => new TypeAheadFailure({err})
    ))
);

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

typeAheadSearch$: Observable<Action> = this.actions$.pipe(
    ofType<TypeAheadSearch>(SearchActions.TYPE_AHEAD_SEARCH),
    map(action => action.payload),
    switchMap(criteria => // Will CANCEL priors!! Yes!
        this.searchService.quickSearch(criteria).pipe(
            map(results => new TypeAheadResults({results})),
            catchError(err => new TypeAheadFailure({err})
    ))
);

Идеальный оператор сопоставления для нашего случая использования действительно тот, который отменяет предыдущие запросы в пользу последующих запросов. Оператор switchMap снова жив!

Другой потенциальный вариант использования switchMap может быть при загрузке страниц данных из очень большого набора данных. Чтобы поддерживать отзывчивый пользовательский интерфейс, если пользователь быстро листает страницы ... зачем тратить время на выполнение запросов, которые уже больше не нужны?

Очередь за гарантиями

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

saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<SaveEntity>(EntityActions.SAVE),
    map(action => action.payload),
    switchMap(entity => // May CANCEL critical operation!!
        this.entityService.save(entity).pipe(
            map(saved => new SaveEntitySuccess({saved})),
            catchError(err => new SaveEntityFailure({err})
    ))
);

В контексте сохранения изменений в сущности отмена предыдущего запроса в пользу последующего может привести к проблемам. В частности, это может быть верно для обновлений в стиле PATCH, когда для эффективности передается только измененная информация. С switchMap начальное сохранение для обновления списка дочерних объектов новым дочерним элементом может быть отброшено в пользу второго сохранения для обновления свойства этого конкретного дочернего объекта. Поскольку первоначальное сохранение было отменено, нет гарантии, что этот дочерний элемент будет существовать, и частичное обновление во втором сохранении не удастся.

saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<SaveEntity>(EntityActions.SAVE),
    map(action => action.payload),
    exhaustMap(entity => // May IGNORE subsequent operation!!
        this.entityService.save(entity).pipe(
            map(saved => new SaveEntitySuccess({saved})),
            catchError(err => new SaveEntityFailure({err})
    ))
);

Кроме того, если мы попытаемся использовать exhaustMap, мы рискуем, что два изменения, сохраненные в относительно быстрой последовательности (что может иметь место при автоматическом сохранении редактирования, что является обычной практикой в ​​современных приложениях), сохранят только первое изменение. Мы действительно можем добавить наш новый дочерний объект в список, но последующее редактирование этого дочернего объекта может быть потеряно.

saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<SaveEntity>(EntityActions.SAVE),
    map(action => action.payload),
    mergeMap(entity => // No guarantee of order!!
        this.entityService.save(entity).pipe(
            map(saved => new SaveEntitySuccess({saved})),
            catchError(err => new SaveEntityFailure({err})
    ))
);

Использование mergeMap также нежизнеспособно, поскольку, хотя мы действительно можем получить правильные результаты ... сначала добавив нашего потомка, а затем изменив его, точный порядок выполнения не может быть гарантирован в случае параллельного выполнения. Вероятно, они будут выполнены в надлежащем порядке, но есть шанс, что они этого не сделают. Мы вполне можем попытаться сначала сохранить обновление дочернего объекта, прежде чем оно будет создано.

saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<SaveEntity>(EntityActions.SAVE),
    map(action => action.payload),
    concatMap(entity => // Guarantees all, in order!!
        this.entityService.save(entity).pipe(
            map(saved => new SaveEntitySuccess({saved})),
            catchError(err => new SaveEntityFailure({err})
    ))
);

Что нам нужно, так это поставить в очередь наши запросы на сохранение, чтобы гарантировать, что не только каждый запрос выполняется, но и что они выполняются в правильном порядке. Единственный оператор, который отвечает этим требованиям, - это concatMap.

Изнуряющая идемпотентность

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

saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<DeleteEntity>(EntityActions.DELETE),
    map(action => action.payload),
    switchMap(entity => // One of em will go!
        this.entityService.delete(entity).pipe(
            map(saved => new DeleteEntitySuccess({saved})),
            catchError(err => new DeleteEntityFailure({err})
    ))
);
saveEntity$: Observable<Action> = this.actions$.pipe(
    ofType<DeleteEntity>(EntityActions.DELETE),
    map(action => action.payload),
    concatMap(entity => // All of em will go, but slow!
        this.entityService.delete(entity).pipe(
            map(saved => new DeleteEntitySuccess({saved})),
            catchError(err => new DeleteEntityFailure({err})
    ))
);
deleteEntity$: Observable<Action> = this.actions$.pipe(
    ofType<DeleteEntity>(EntityActions.DELETE),
    map(action => action.payload),
    mergeMap(entity => // All of em will go, fast!!
        this.entityService.delete(entity).pipe(
            map(saved => new DeleteEntitySuccess({saved})),
            catchError(err => new DeleteEntityFailure({err})
    ))
);

Хотя мы можем использовать любой оператор сопоставления для идемпотентных операций ... это не обязательно будет иметь большое значение, если мы переключаемся, или объединяем, или объединяем ... Зачем тратить усилия, когда в этом нет необходимости?

deleteEntity$: Observable<Action> = this.actions$.pipe(
    ofType<DeleteEntity>(EntityActions.DELETE),
    map(action => action.payload),
    exhaustMap(entity => // Only one is necessary...
        this.entityService.delete(entity).pipe(
            map(saved => new DeleteEntitySuccess({saved})),
            catchError(err => new DeleteEntityFailure({err})
    ))
);

С exhaustMap мы можем выполнить наше действие только один раз, и любые дополнительные запросы на удаление того же самого, поступившие до того, как наш предыдущий запрос был завершен, будут просто игнорироваться.

Параллельное сотрудничество

Так где же тут роль параллелизма? Когда параллелизм может оказаться полезным инструментом с реактивным состоянием? Возможные варианты использования здесь могут быть безграничными. Рассмотрим пользовательский интерфейс, который может загружать аналогичную информацию в разные части своего пользовательского интерфейса, при этом для создания каждого набора информации требуется сложная операция на стороне сервера? Скажем… отчеты, диаграммы и графики с разными критериями? Может быть, один и тот же отчет с разными периодами времени и степенью детализации?

buildReport$: Observable<Action> = this.actions$.pipe(
    ofType<BuildReport>(ReportActions.Build),
    map(action => action.payload),
    switchMap(criteria => // Will cancel all but the last!
        this.reportService.build(criteria).pipe(
            map(report => new BuildReportSuccess({report})),
            catchError(err => new BuildReportFailure({err})
    ))
);
buildReport$: Observable<Action> = this.actions$.pipe(
    ofType<BuildReport>(ReportActions.Build),
    map(action => action.payload),
    exhaustMap(criteria => // Will drop all but the first!
        this.reportService.build(criteria).pipe(
            map(report => new BuildReportSuccess({report})),
            catchError(err => new BuildReportFailure({err})
    ))
);

Мы не хотим отменять предыдущие запросы или отменять последующие запросы, поскольку каждый запрос имеет значение. Однако запрос такой отчетной информации часто выполняется вместе, поэтому время между отправками идентичных действий с разными критериями фактически равно нулю. Таким образом, оба switchMap и exhaustMap здесь нежизнеспособные операторы. Мы можем потерять нужную нам информацию.

buildReport$: Observable<Action> = this.actions$.pipe(
    ofType<BuildReport>(ReportActions.Build),
    map(action => action.payload),
    concatMap(criteria => // Gets all reports...but slowly!
        this.reportService.build(criteria).pipe(
            map(report => new BuildReportSuccess({report})),
            catchError(err => new BuildReportFailure({err})
    ))
);

Мы можем поставить запросы в очередь. Это будет гарантировать, что все действительно запрошены, и что мы действительно в конечном итоге получим требуемую информацию. Однако это представляет потенциальную проблему с точки зрения отзывчивости пользовательского интерфейса, и каждый из наших компонентов пользовательского интерфейса будет загружаться в очевидном… и сериализованном… порядке. Поцарапайте concatMap.

buildReport$: Observable<Action> = this.actions$.pipe(
    ofType<BuildReport>(ReportActions.Build),
    map(action => action.payload),
    mergeMap(criteria => // Gets em all, builds em at the same time!
        this.reportService.build(criteria).pipe(
            map(report => new BuildReportSuccess({report})),
            catchError(err => new BuildReportFailure({err})
    ))
);

Параллельное выполнение запросов на построение для каждого отчета - оптимальное решение. Мы знаем, что создание каждого отчета может занять пару секунд каждый, и вместо того, чтобы помещать каждый запрос в очередь и загружать их по одному, позвольте серверу обрабатывать их одновременно, чтобы все они попадали в пользовательский интерфейс, как только они будут готовы. Это mergeMap для победы!

Правильный инструмент для работы

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

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