Как описать асинхронную цепочку запросов и не сломать всё? Просто? Не думаю!

Я автор менеджера состояния Reatom и сегодня хочу вам рассказать про главную киллер-фичу redux-saga и rxjs и как теперь её можно получить проще, а так же про грядущие изменения в стандарте ECMAScript.

Речь пойдёт об автоматической отмене конкурентных асинхронных цепочек — обязательном свойстве при работе с любым REST API и другими более общими асинхронными последовательными операциями.

▍ Базовый пример



const getA = async () => {
  const a = await api.getA();
  return a;
};

const getB = async (params) => {
  const b = await api.getB(params);
  return b;
};

export const event = async () => {
  const a = await getA();
  const b = await getB(a);
  setState(b);
};

Пример максимально банален, большинство писали такой код: нужно запросить с бекенда сначала одни данные, потом на основе их запросить конечные данные с другого эндпоинта. Ситуация осложняется, если первые данные зависят от пользовательского ввода, чаще всего это какие-то фильтры или сортировки в таблице. Пользователь что-то меняет, мы делаем запрос, пользователь меняет что-то ещё, а нам уже прилетел ответ от предыдущего запроса и пока новый не завершится, отображается «weird state».



Но это ещё ерунда, подавляющее большинство бекенд серверов не следит за очерёдностью запросов и может ответить сначала на второй запрос, а потом на первый — у пользователя это отразится данными к старым фильтрам, а новые данные так и не появятся — «WAT state».



Как избежать WAT state с примера на картинке? Да вроде просто, отменять последний запрос.



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

Как это можно было бы сделать самому? Проще всего отмену добавить через версионирование запросов.

let aVersion = 0;
const getA = async () => {
  const version = ++aVersion;
  const a = await api.getA();
  if (version !== aVersion) throw new Error("aborted");
  return a;
};

let bVersion = 0;
const getB = async (params) => {
  const version = ++bVersion;
  const b = await api.getB(params);
  if (version !== bVersion) throw new Error("aborted");
  return b;
};

export const event = async () => {
  const a = await getA();
  const b = await getB(a);
  setState(b);
};

Бойлерплейтненько? Но это не всё. Мы исправили только «WAT state», а как же «weird state»?



Наши попытки отменить предыдущий запрос ни к чему не приводят, потому нам нужно версионировать всю цепочку!

const getA = async (getVersion) => {
  const version = getVersion();
  const a = await api.getA();
  if (version !== getVersion()) throw new Error("aborted");
  return a;
};

const getB = async (getVersion, params) => {
  const version = getVersion();
  const b = await api.getB(params);
  if (version !== getVersion()) throw new Error("aborted");
  return b;
};

let version = 0
const getVersion = () => version
export const event = async () => {
  version++
  const a = await getA(getVersion);
  const b = await getB(getVersion, a);
  setState(b);
};

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

Зато задача решена! Отмена цепочки предотвращает «weird state»



«WAT state» — тоже не может больше появиться.



Но код выглядит ещё более бойлерплейтненько? Сейчас мы можем использовать нативный AbortController, который уже хорошо поддерживается в браузерах и node.js.

const getA = async (controller) => {
  const a = await api.getA();
  controller.throwIfAborted();
  return a;
};

const getB = async (controller, params) => {
  const b = await api.getB(params);
  controller.throwIfAborted();
  return b;
};

let controller = new AbortController();
export const event = async () => {
  controller.abort("concurrent");
  controller = new AbortController();
  const a = await getA(controller);
  const b = await getB(controller, a);
  setState(b);
};

Стало лучше и, надеюсь, понятнее, но это всё ещё выглядит неудобно и многословно, контроллер приходится перепрокидывать руками, стоит оно того? На моей практике так никто не делал, потому что переписывать все функции, чтобы оно нормально друг с другом взаимодействовало и код был консистентнее, никто не будет. Точно так же, как никто не делает вообще все функции async, подробнее об этом можно прочитать в How do you color your functions?. Важно понять, что описанный пример максимально упрощённый, а в реальных задачах поток данных и соответствующая проблема могут быть намного сложнее и серьёзнее.

Какие есть альтернативы? rxjs и redux-saga позволяют вам описывать код в своём специфическом API, которое под капотом автоматически трекает конкурентные вызовы асинхронных цепочек и может отменять устаревшие. Проблема с этим именно в API — оно ну очень уж специфичное, как по виду, так и по поведению — порог входа достаточно большой. Хоть и меньше чем в $mol — да, он тоже умеет в автоматическую отмену.


import { from, Subject } from 'rxjs';
import { switchMap } from 'rxjs/operators';

const getA = async () => {
  const a = await api.getA();
  return a;
};

const getB = async (params) => {
  const b = await api.getB(params);
  return b;
};

export const event$ = new Subject();
event$
  .pipe(
    switchMap(() => from(getA())),
    switchMap((a) => from(getB(a)))
  )
  .subscribe((b) => setState(b));


В @reduxjs/toolkit есть createListenerMiddleware, в API которого есть некоторые фичи из redux-saga, которые позволяют решать примитивные случаи этой проблемы. Но отслеживание цепочки более локальное и не так хорошо интегрировано во всё API тулкита.

Ещё варианты?

▍ Контекст


В этой статье мы обсуждаем только автоматическую отмену, но задача более общая — смотреть на асинхронный контекст вызова. На бекенде асинхронный контекст есть уже давно и является важным инструментом надёжного кода. В node.js есть AsyncLocalStorage и сейчас идёт обсуждение по его внедрению в стандарт (Ecma TC39 proposal slides)! Код с ним мог бы выглядеть так, для каждой цепочки в context будет свой собственный AbortController:


const context = new AsyncContext(new AbortController());

const getA = async () => {
  const a = await api.getA();
  context.get().throwIfAborted();
  return a;
};

const getB = async (params) => {
  const b = await api.getB(params);
  context.get().throwIfAborted();
  return b;
};

export const event = async () => {
  context.get().abort("concurrent");
  context.set(new AbortController());
  const a = await getA();
  const b = await getB(a);
  setState(b);
};


Я не представляю как можно писать сложную (асинхронную и конкурентную, многоступенчатую) логику без асинхронного контекста. Точнее, как делать это надёжно и просто.

Есть ли возможность использовать его уже сейчас, какие-то полифилы? К сожалению, нет. Тима ангуляра уже давно пытается это сделать с zone.js, но покрыть все кейсы так и не получилось.

Но можно вернуться к вопросу о пробросе первым аргументом какого-то контекстного значения. Именно так сделано в Reatom — первым аргументом всегда приходит ctx. Это конвенция, которая соблюдается во всех связанных функция и потому она очень удобная, в ctx содержится несколько полезных свойств и методов для реактивности и управления сайд-эффектами, он иммутабелен и помогает этом в дебаге, а ещё его можно переопределять для упрощения тестирования!

Но вернёмся к нашим баранам — автоматическая отмена. В пакете reatom/async есть фабрика reatomAsync для заворачивания асинхронных функций в трекер контекста, которая автоматически ищет в пришедшем ctx AbortController и подписывается на него. Сам контроллер можно отменить вручную или использовать оператор withAbort, который будет за вас отменять конкурентные запросы.


import { reatomAsync, withAbort } from '@reatom/async'

const getA = reatomAsync(async (ctx) => {
  const a = await api.getA();
  return a;
});

const getB = reatomAsync(async (ctx, params) => {
  const b = await api.getB(params);
  return b;
});

export const event = reatomAsync(async (ctx) => {
  const a = await getA(ctx);
  const b = await getB(ctx, a);
  setState(b);
}).pipe(withAbort());

Прелесть в том, что это уже существующее API и добавить поддержку AbortController было не сложно. И это очень простой паттерн — перепрокидывание первого аргумента, он не требует специфических знаний или изучения новых концепций — стоит просто принять эту конвенцию и писать на несколько символов больше возможного. Но по необходимости мы можем прозрачно расширять контекст, добавляя в него необходимые фичи. Что важно, передаваемый контекст иммутабелен и если в каком-то редком случае вам не будет хватать @reatom/logger контекст просто инспектировать и дебажить, в документации есть гайд про это.

Повторюсь, важное отличие реализации отмены в Reatom от rxjs и redux-saga является в использовании нативного AbortController, который уже является стандартом, используется в браузерах и node.js, а также множества других библиотек! Внутри reatomAsync сам контроллер можно достать напрямую из контекста (ctx.controler) и подписаться на событие отмены или прокинуть signal в нативный fetch. Отменять существующий браузерный запрос — хорошая практика, т.к. одновременно может существовать лишь ~6 соединений. И в случае с другими библиотека, которые не предоставляют AbortController, запросы отмененные в приложении, но зависшие в браузере могут тормозить новые запросы и получение свежих данных.

Круто ещё и то, что Reatom и его вспомогательные пакеты разрабатываются в одной монорепе и очень хорошо интегрируются друг с другом. Например, onConnect из пакета @reatom/hooks тоже прокидывает AbortController и отменяет его при отписке переданного атома — это работает проще и прозрачнее useEffect и возвращаемого колбека очистки в React.

Статья также доступна в видеоформате:

Это всё, что я хотел рассказать. Знаете ли вы другие библиотеки, которые позволяют делать автоматическую отмену? Как вам вариант с ручным версионированием и прокидыванием AbortController, делали ли вы так когда-нибудь?

Telegram-канал с розыгрышами призов, новостями IT и постами о ретроиграх ????️

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


  1. nin-jin
    11.04.2023 14:05
    +5

    На $mol это было бы как-то так, если дословно переводить:

    const getA = sync( ()=> {
      const a = sync( api ).getA()
      return a
    } )
    
    const getB = sync( params => {
      const b = sync( api ).getB( params )
      return b
    } )
    
    const event = task( () => {
      const a = getA()
      const b = getB(a)
      setState( b )
    } )

    И так, если нормально делать:

    class App extends Object {
    
      @mem api() {
        return new Api
      }
      
      @act getA() {
        const a = this.api().getA()
        return a
      }
    
      @act getB( params ) {
        const b = this.api().getB( params )
        return b
      }
    
      @mem state() {
        const a = this.getA()
        const b = this.getB(a)
        return b
      }
      
    }


  1. fransua
    11.04.2023 14:05

    Неделю назад написал эту штуку для своей библиотеки @cmmn/cell, но еще не добавил в либу
    Выглядит так:

    const a = new CellQuery<ResultA>('/api/a');
    const b = new CellQuery<ResultB>(() => a.isFetching ? undefined : `/api/a/${a.data.id}`);
    

    и в реакте

    const {isFetching, data} = useCell(b);
    return isFetching ? <Skeleton/> : <div>{data}</div>;
    

    CellQuery чекает зависимости и вызывает fetch если что-то изменилось. Если уже идет запрос, он отменяется.


  1. LyuMih
    11.04.2023 14:05
    +1

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

    Плохо, когда этого совсем нет)


  1. amakhrov
    11.04.2023 14:05

    Повторюсь, важное отличие реализации отмены в Reatom от rxjs и
    redux-saga является в использовании нативного AbortController, который
    уже является стандартом, используется в браузерах и node.js, а также
    множества других библиотек

    Все же он используется не напрямую, а внутри withAbort() абстракции. Что тоже "специфический АПИ". А завязка внутри на AbortController - наоборот делает реализацию менее универсальной. Например, в rxjs - unsubscribe можно написать для любой асинхронной операции, даже если она не поддерживает AbortController. К примеру, XmlHttpRequest.


    1. artalar Автор
      11.04.2023 14:05

      Это хороший комментарий! Примеры в статье очень простые и мы вызываем reatomAsync из reatomAsync, но ctx и контроллер в нем просачивается на любую глубину вызовов через все атомы, экшены и хуки и может быть использован через onCtxAbort, например.


  1. yuriy-bezrukov
    11.04.2023 14:05
    -1

    Rxjs придумали в 1875.

    Люди до 1875 - как отменять цепочки асинхронных событий???


  1. mvv-rus
    11.04.2023 14:05
    +1

    Уважаемые фронтовики (или передовики, как правильно?)!
    Как задовик, я понимаю ваши проблемы и даже немного сочувствую. Но — не разделяю.
    Например, я в примере (точнее, на диаграмме) вижу нарушение принципа "дураку полработы не показывают": ну вот зачем после getA надо показывать промежуточные результаты пользователю (которого чисто для пользы дела следует считать дураком)? Лучше было бы дождаться getB, собрать всю полученную инфорацию в кучу и сразу обновить отображаемые данные. Ведь в коде примера, на самом деле, оно так и сделано, если я правильно понял смысл заклинания setState. Версионирование при таком подходе работает на ура — версию достаточно проверить один раз, перед обновлением отображаемых пользователю данных. В примере, насколько я понял это стоит сделать в setState. Благо, вы там на фронте можете себе позволить дождаться конца каждой цепочки, не экономя на запросах к серверу — задовики как-нибудь справятся, да ;-).
    Во-вторых, я рад, что до фронта наконец-то добрался шаблон скоординированной отмены (который AbortController, в C# это — CancellationToken и все что вокруг него). Вот проблемы с прокидыванием его я как раз понимаю — у нас так же мучаться приходится. И решение примерно то же самое — контекст. И даже для прокидывания контекста ничего особо изобретать не нужно — для этого есть объекты и ключевое слово this в их методах. У вас на фронте тоже так же можно, только чуть побольше "бойлерплейта" надо — например, писать "function" вместо "=>" (ну, и список параметров переставить по месту). Как по-моему, то лишние 6 символов на функцию того стоят. Может, попробуете? В комменте nin-jin примерно показано, как это делается (подсказка: его любимый $mol для этого, в общем-то, не нужен).
    А вот чего, по-моему, делать не стоит — так это использовать для хранения контекста глобальную переменую — даже которая на уровне модуля. Ибо это — костыль жуткий: что вы делать-то будете, когда вам потребуется параллельно выполнять две цепочки? Или это — чисто для примера? Так для примера я бы блок не поленился нарисовать — у вас же модуль и там всегда "use strict", так что всего два символа — и любой приверженец методологии разработки SO-Driven Development будет в безопасности (относительной) ;-)
    А что по поводу Reatom, то я к лишней зависмости всегда отношусь с подозрением (я не люблю зависимости, хотя, допускаю, что я просто не умею их готовить). Потому пофантазирую: нельзя ли то же самое сделать через Proxy в чисто конкретных местах?


    PS Надеюсь, то что я тут говорю тут про задачи (Task/promise) а не про async/await это никого не смущает? Если вдруг смущает — могу пояснить.
    PPS Немного побуду Шишковым: "to track" переводится с английского (в данном контексте) как "отслеживать". Так что, можно вместо изобретения своих словоуродцев использовать его. Впрочем, лично для меня это не страшно — читать русские тексты по IT методом обратного перевода я привык со времен ЕС ЭВМ — но я за других беспокоюсь.
    PPS Если чо не то сказал — извиняйте: ну, не фронтовик я (и не передовик).


    1. nin-jin
      11.04.2023 14:05

      А как это делается без $mol?


      1. mvv-rus
        11.04.2023 14:05

        Что-то типа такого (основываюсь на вашем примере из второго листинга, а так как JS — не мой родной язык, то если что не так — подправьте)


        {
          class App {
            constructor(controller_name) {
               this.сontrollerName = controller_name;
               this.controller = new AbortController();
            }
        
            async event(getA, getB) {
               if(this.controller) controller.abort(controllerName);
               this.controller = new AbortController();
               const a = await this.getA();
               const b = await this.GetB(a);
               // controller.throwIfAborted() добавить по вкусу
               return b;
            }
          }
        
          async function getA() {
             let a;
             //...do smth
            this.controller.throwIfAborted();
             //...do smth more
            //при необходимости повторить предыдущие две строчки до готовности
            return a
          }
        
          async function getB(a) {
             let b;
             //...do smth with a
            this.controller.throwIfAborted();
             //...do smth more with a
            //при необходимости повторить предыдущие две строчки до готовности
            return b
          }
        }


        1. nin-jin
          11.04.2023 14:05

          У вас даже близко не то же самое: функции другого цвета, куча бойлерплейта, ручное прокидывание аборт контроллера и прерывание функций, ещё и апи потерялось.


          1. mvv-rus
            11.04.2023 14:05

            Дык вопрос-то был, как это сделать без этого вашего $mol в принципе, а не как это сделать красиво. Кароче, вы мне тут напишите — код работать будет вообще, в принципе? Если да, то мне для иллюстрации достаточно.
            PS Api — это DI-котейнер, или что? Если DI-котейнер, то Api не нужен потому что я написал PoC: нужные функции передаются напрямую, как параметры. Впихнуть DI-котейнер несложно, только вот он число сущностей умножит, а я старался упростить.


            И да, getA и getB просто не читайте: они тут чисто для иллюстрации, кужа скоординированную отмену впихивать, дабы не гадал никто.


            1. nin-jin
              11.04.2023 14:05

              api - фасад к серверу, который собственно и выполняет запросы.


              1. mvv-rus
                11.04.2023 14:05

                Это — свой объект для каждой цепочки запросов? Тогда вот вам и готовый контекст, куда можно AbortController засунуть. Ну и, чтобы сделать такой, какой-то особый фреймворк не нужен — достаточно создать объект и прописать в нем нужные методы. Вы согласны?


                1. nin-jin
                  11.04.2023 14:05

                  Общий для всех.


                  1. mvv-rus
                    11.04.2023 14:05

                    Ну, это ваш выбор. Не смею навязывать свой.
                    Хотя… Если прикладные программы всегда получают его через new Api, как у вас в примере, то можно совершенно прозрачно поменять функцию Api так, чтобы она возвращала новый объект, унаследованный от основного объекта Api, но со своим собственным AbortController.


                    1. nin-jin
                      11.04.2023 14:05

                      Как получают не важно, api - один объект, который реализует много разных методов.


                      1. mvv-rus
                        11.04.2023 14:05

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


    1. artalar Автор
      11.04.2023 14:05
      +1

      Привет, спасибо за такой подробный комментарий, но вы многое упускаете :)

      • "зачем показывать промежуточные результаты?"

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

      • "версию достаточно проверить один раз"

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

      • "контекст"

        Это общее слово и тут нужно понимать про какой контекст мы говорим, в каком контексте :) Есть контекст модели - инстанса. Есть асинхронный контекст вызова - процедуры. Абсолютно разные вещи.

        Для асинхронного контекста сама переменная "context" - просто токен, данные по которому меняются и для каждой асинхронной цепочки свои.

      • "лишней зависмости"

        В жаваскрипте плохой STD и вообще нет фреймворков, вы либо пишите вагоны велосипедов, либо пишите говнокод без хороших практик. Отсутствие библиотек - очень тревожный звоночек. У библиотек документация и тесты будут точно лучше внутренних реализаций, у нас такая культура во фронте. Кстати, бандлсайз Reatom очень маленький: 2кб core и еще 2-4кб на async и остальные вспомогательные либы. Сам React весит 44.


      1. mvv-rus
        11.04.2023 14:05

        Привет, спасибо за такой подробный комментарий, но вы многое упускаете :)
        «Нельзя объять необъятное» (Козьма Прутков). Я всего лишь демонстрирую взгляд с другой стороны.
        Мы всегда стараемся показать данные как можно быстрее в хорошем интерфейсе.
        А эти данные в таком неполном виде точно для пользователя какую-то ценность имеют? Если да, и если данные отображаются в нескольких частях (типа основной записи и детализации или наоборот, каких-то суммарных значение), то ваш варинт — обновление интерфейса несколько раз в процессе запроса — имеет смысл, и однократной проверкой номера версии тогда не обойтись. Но в примере — в коде, а не на диаграмме- обновление интерфейса было одно, и я на это обратил внимание.
        Тем не менее, идея с версией обновления мне лично не кажется особо хорошей, шаблон скоординированной отмены мне нравится больше. Может, потому что привык — он в C# уже больше 10 лет существует.
        встречаются более сложные примеры с несколькими отдельными визуально, но связанными логически интерфейсами,

        Мое мнение — делать обновление таких частей методом(ами) одного объекта, который и будет контекстом для всей цепочки. Благо JS позволяет много чего лишнего, чего лишены разработчики на языках со статической типизацией ;-), в данном случае — заимствование методов из других объектов.
        нужно понимать про какой контекст мы говорим, в каком контексте

        В контексте здешнего обсуждения контекст это исключительно про набор взаимосвязанных асинхронно выполняющихся запросов к серверу. Контекст приложения оставляем в стороне, как и само приложение, каково бы оно ни было (а они ведь разные бывают, не так ли?).
        у нас такая культура во фронте
        Дык, я в чужой монастырь со своим уставом лезть и не собираюсь. Нелюбовь к зависимостям — это мое личное предпочтение. А насчет библиотек — которые не от лидеров рынка, а от таких же разработчиков — я бы не был столь оптимистичен Хотя, возможно, у вас там на фронте с кодом приложений ещё хуже, не знаю.