Pull to refresh
3005.91
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

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

Level of difficultyMedium
Reading time7 min
Views9.7K

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

Я автор менеджера состояния 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 — да, он тоже умеет в автоматическую отмену.

Вот пример на rxjs.


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 asyncContext = new AsyncContext(new AbortController());

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

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

export const event = async () => {
  asyncContext.get().abort("concurrent");
  asyncContext.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());

Код выглядит максимально просто, потому что минимально отличается от изначального примера. А оверхед на размер бандла в три раза меньше rxjs. Прелесть еще в том, что ctx — это уже существующее API в Reatom и добавить поддержку 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 и постами о ретроиграх 🕹️
Tags:
Hubs:
Total votes 33: ↑30 and ↓3+38
Comments21

Articles

Information

Website
ruvds.com
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
ruvds