Отменить нельзя продолжить

twbn4-z5t268afal9b2fih7prgo.png


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

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

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

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


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

ybwhtsmf7_slpz4htr_nbm70atg.png

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

67ee_vlphu2crtgtqcwm8rn8rea.png

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

c5aer2zcuvpg2v0zqmhba2jdevu.png

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

Как это можно было бы сделать самому?

Реализация без отмены.

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

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

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


Проще всего отмену добавить через версионирование запросов.

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;
};

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


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

rofceoaj9ns6llcujyf7apumetc.png

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

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
const event = async () => {
  version++
  const a = await getA(getVersion);
  const b = await getB(getVersion, a);
  setState(b);
};


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

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

woa-jaty3vdisgb0pvlu2ssbtyy.png

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

wxfdkm_pyp4-1s3nwxhrqnpnseq.png

Но код выглядит ещё более бойлерплейтненько? Сейчас мы можем использовать нативный 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 prevController = new AbortController();
const event = async () => {
  prevController.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 — да, он тоже умеет в автоматическую отмену.

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


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

▍ Контекст


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

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

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

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

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

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;
});

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, а также множества других библиотек!

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

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

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

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

© Habrahabr.ru