В компании SDVentures мы часто используем на проектах связку React + RxJS. Это довольно таки нетрадиционная связка, так что о ней мало что можно найти в интернете. Поэтому постараюсь рассказать о том, почему мы с командой стали её использовать и чем это может быть полезно вам.  

Много бизнес-логики мы пишем на клиенте

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

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

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

  1. Связанные с некритичными и индивидуальными для пользователя таймерами. Создание для каждого пользователя своего таймера на бекенде может чересчур нагружать систему;

  2. Где данные, которые необходимы для определения показа определенного функционала, уже присутствуют на клиенте. Быстрее считать данные на нём с помощью агрегации нескольких источников, чем тратить время на HTTP запрос к бэкенду.

  3. Связанные с A/B тестированием. Как обычно бывает, большая часть экспериментов признаются неудачными, поэтому эксперимент проще и быстрее проверить на клиентской стороне, а если он покажет результат, то уже сделать необходимые изменения на стороне бэкенда. Поверьте, так будет гораздо проще, чем сразу вносить несколько возможных веток в сложную распределенную систему.

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

Как мы дошли до связки React и RxJS?

К такой связке мы пришли постепенно и нам, конечно же, потребовалось некоторое время, чтобы на ней стало удобно разрабатывать что-то новое и поддерживать старый функционал. Раньше для описания клиентской бизнес-логики мы использовали Flux. Но описывать сложные пайплайны, упомянутые раньше, было неудобно, получалось нечитаемо. Юнит-тесты практически не писались, требовалось много бойлерплейта.

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

На тот момент активно развивались и пользовались популярностью фреймворки Redux и MobX, однако они не упрощали работу с пайплайнами относительно Flux, а Redux также требовал на порядок больше бойлерплейта. В общем, везде свои проблемы. У доброй части моих коллег уже имелся опыт работы с Rx* фреймворками в нативной мобильной и .Net разработке, поэтому тут поступило предложение рассмотреть вариант использования RxJS.

На просторах интернета и самого Хабра есть много замечательных статей по теме RxJS,  в чем отличие Observable от Promise, поэтому не буду касаться этого в своем материале. Скажу только, что RxJS в общем случае — не замена MobX или Redux. Это просто отличный фреймворк для декларативного описания бизнес-логики. При желании его можно подружить с Redux — есть даже готовые библиотеки для реализации Middleware Redux на RxJS (redux-observable). Мы же при использовании RxJS реализовали свои lightweight сторы и не трогали другие React-ориентированные фреймворки.

А теперь, пожалуй, перейдем к тому, чем мы руководствовались при выборе RxJS:  

  • Читабельность.

    1. Код становится декларативным. К этому нас побуждают ленивые вычисления, ведь Observable это лишь набор инструкций – он начнет выполняться, только если на него подписаться.

    2. Мы можем без проблем выделять и переиспользовать какие-то часто используемые Observable сценарии в отдельные переменные или методы.

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

    4. Удобные преобразования одной асинхронной операции в другую (аналог Promise.then, только декларативный) избавляют нас от callback hell. 

  • Тестируемость. Фреймворк предоставляет хороший набор инструментов для удобного тестирования. Есть свой собственный способ тестирования через marble диаграммы, присутствуют удобные утилиты для тестирования сложной логики с таймерами.

  • Скорость разработки. RxJS содержит большое количество встроенных операторов для построения Observable. Он не ограничивается базовыми filter, map, merge, zip. Здесь есть удобные операторы для добавления логики троттлинга, можно удобно работать с таймерами, буферами, агрегациями и др.

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

Типичный сценарий бизнес-логики

Теперь вернемся к тому, как можно на RxJS описать типичный (для большинства продуктов компаний) сценарий сложной клиентской бизнес-логики. Представим следующие требования:

Пользователь хочет отправить подарок другому пользователю (к примеру, цветы на день рождения). Нам необходимо при нажатии кнопки отправки первым делом проверить, является ли пользователь авторизованным, или же использует демо режим. Если не авторизован, то запустить процесс входа пользователя на сайт. Если же пользователь авторизован, либо успешно вошел на сайт в результате предыдущего шага, происходит попытка отправки подарка. Начинается она с того, что мы должны отобразить диалог для подтверждения пользователем отправки. После получения подтверждения должен выполнится запрос к API c попыткой отправить подарок. Если он не был успешно завершен из-за нехватки кредитов, то необходимо выполнить попытку пополнения баланса пользователя. После успешного пополнения баланса необходимо отобразить алерт о том, что началась отправка подарка.

На RxJS функция отправки может выглядеть следующим образом:

/* Dependencies
function userIsUsingDemoMode(): Observable<boolean>
 
function updateUserCredentials(): Observable<Result<void, 'cancelled'>>
 
function executeSendPresentRequest(
    present: Present,
    recipientId: string
): Observable<Result<void, 'insufficient-funds'>>
 
function askUserForPresentSendingConfirmation(present: Present, recipientId: string): Observable<boolean>
 
function refillBalance(): Observable<Result<void, 'cancelled'>>
 
function showPresentSuccessfullySentPopup(present: Present, recipientId: string): void
 */
 
function sendPresent(present: Present, recipientId: string): Observable<Result<void, 'unauthorized' | 'insufficient-funds' | 'cancelled'>> {
    const authorizeUser = userIsUsingDemoMode().pipe(
        take(1),
        switchMap(userIsUsingDemoMode => userIsUsingDemoMode ? updateUserCredentials() : of(Result.success()))
    )
 
    const sendPresentWithBalanceRefilling: Observable<Result<void, PresentSendingError>> = executeSendPresentRequest(
        present,
        recipientId
    ).pipe(
        switchMap(result => {
            return result.error === 'insufficient-funds'
                ? refillBalance().pipe(
                    switchMap(refillingResult => refillingResult.isSuccessful ? sendPresentWithBalanceRefilling : of(result))
                )
                : of(result)
        })
    )
 
    return authorizeUser.pipe(
        switchMap(authorizationResult => {
            return authorizationResult.isSuccessful
                ? askUserForPresentSendingConfirmation(present, recipientId).pipe(
                    switchMap(confirmed => confirmed ? sendPresentWithBalanceRefilling : of(Result.failure('cancelled')))
                )
                : of(Result.failure('unauthorized'))
        }),
        tap(result => {
            if (result.isSuccessful) {
                showPresentSuccessfullySentPopup(present, recipientId)
            }
        })
    )
}

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

Связка с React

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

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

export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial: T): T
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList): T | undefined
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial?: T): T | undefined {
    const [latestValue, setLatestValue] = useState(initial)
  
    const subscription = useRef<Subscription | undefined>(undefined)
    const prevDeps = useRef(deps)
  
    if (!subscription.current || !hookDepsAreEqual(deps, prevDeps.current)) {
        if (subscription.current) {
            prevDeps.current = deps
            subscription.current.unsubscribe()
            setLatestValue(initial)
        }
  
        subscription.current = src().pipe(catchErrorJustLog()).subscribe(setLatestValue)
    }
  
    useEffect(() => {
        return () => {
            if (subscription.current) {
                subscription.current.unsubscribe()
            }
        }
    }, [])
  
    return latestValue
}

Как мы видим, хук подписывается на Observable и записывает в state значения, которые были из него получены. Если компонент анмаунтится, то происходит отписка от Observable. Если же меняются deps-ы, то происходит отписка от старого Observable и подписка на новый.

Однако эта версия не содержит оптимизации. В проекте мы используем версию, которая не вызывает перерисовку компонента, если значение из Observable выстрелит синхронно или если новое значение не отличается от старого:

export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial: T): T
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList): T | undefined
export function useObservable<T>(src: () => Observable<T>, deps: DependencyList, initial?: T): T | undefined {
    const [, reload] = useState({})
    const store = useRef<UseObservableHookStore<T>>({
        value: initial,
        subscription: undefined,
        deps: deps,
        subscribed: false
    })
    useEffect(() => {
        const storeValue = store.current
        return (): void => {
            storeValue.subscription && storeValue.subscription.unsubscribe()
        }
    }, [])
     if (!store.current.subscription || !hookDepsAreEqual(deps, store.current.deps)) {
        if (store.current.subscription) {
            store.current.subscription.unsubscribe()
            store.current.value = initial
            store.current.deps = deps
            store.current.subscribed = false
        }
        store.current.subscription = src()
            .pipe(catchErrorJustLog())
            .subscribe(value => {
                if (store.current.value !== value) {
                    store.current.value = value
                    if (store.current.subscribed) {
                        reload({})
                    }
                }
            })
        store.current.subscribed = true
    }
     return store.current.value
}

Рассмотрим, как это можно использовать в React на примере компонента отображающего статус присутствия пользователя:

/* Dependencies
 class UserPresence {
    readonly presence: Observable<{ online: boolean, devices: string[] }
    constructor(userId: string)
} 
 */

type Props = {
    userId: string
}

export const UserOnlineStatus = memo((props: Props) => {
    const userIsOnline = useObservable(() => {
        return new UserPresence(props.userId).presence.pipe(
            map(it => it.online)
        )
    }, [props.userId])
    return <span>{userIsOnline ? 'Online' : 'Offline'}</span>
})

Для выполнения действий (action-ов) у нас есть дополнительный хук useObservableAction, который при необходимости отпишется от Observable при анмаунте компонента.

export function useObservableAction<Args extends any[]>(
    action: (...args: Args) => Observable<any>,
    deps: DependencyList,
    unsubscribeOnUnmount: boolean = true
): (...args: Args) => void {
    const subscription = useRef<Subscription>()
    useEffect(() => {
        return (): void => {
            if (subscription.current && unsubscribeOnUnmount) {
                subscription.current.unsubscribe()
            }
        }
    }, [])
 
    return useCallback((...args) => {
        if (subscription.current) {
            subscription.current.unsubscribe()
        }
        subscription.current = action(...args).pipe(catchErrorJustLog()).subscribe()
    }, deps)
}

/* Dependencies
class UserRelations {
    constructor(userId: string)
    userIsMarkedAsFavorite(targetId: string): Observable<boolean>
    markUserAsFavorite(targetId: string, favorite: boolean): Observable<void>
}
 */
 
type Props = {
    userId: string
    targetId: string
}
  
export const ToggleFavoriteButton = memo((props: Props) => {
    const userRelations = useMemo(() => new UserRelations(props.userId), [props.userId])
     
    const targetUserIsMarkedAsFavorite = useObservable(() => userRelations.userIsMarkedAsFavorite(props.targetId), [props.targetId, userRelations])
 
    const toggleFavorite = useObservableAction(() => {
        return userRelations.markUserAsFavorite(props.targetId, !targetUserIsMarkedAsFavorite)
    }, [targetUserIsMarkedAsFavorite, props.targetId, userRelations], false)
 
 
    if (typeof targetUserIsMarkedAsFavorite === 'undefined') {
        return null
    }
  
    return <button onClick={toggleFavorite}>Toggle Favorite</button>
})

Подведем итоги

К чему мы пришли в сухом остатке? Пожалуй, стоит начать с того, что RxJS — не полноценный фреймворк. Он может использоваться для декларативного описания кода, но все еще остается проблема хранения и восстановления состояния, например, для SSR. Наша команда предпочла использовать самописные Rx сторы, которые мы называем DataModel. Они хранят состояние с возможностью указания ttl; поддерживают сериализацию и десериализацию, которую мы используем в SSR. Модели нашей бизнес-логики уже достают данные из DataModel и их комбинаций. Но никто не запрещает вам использовать Redux, MobX и другие фреймворки для работы с состояниями и иметь Rx прослойку для описания сложных асинхронных преобразований. Как говорится, на все воля ваша. 

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

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

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


  1. markelov69
    13.01.2022 19:18
    +2

    Жесть, аж кровь из глаз идёт глядя на всё это. Ребята, открою вам страшную тайну, есть такая штука — называется MobX. Вот ее то и нужно использовать в связке с реактом, увы больше никаких альтернатив нет, только лишь убожество.


    1. bouncycastle
      13.01.2022 23:55

      Разработчикам на Angular с этим нужно каждый день жить.


    1. IvanIvanc
      14.01.2022 10:49
      +1

      Полностью согласен, на вид полная дичь и костыли. MobX сейчас лучший выбор


    1. ogregor
      14.01.2022 23:42

      Да а ещё есть mobx-state-tree и даже mobx-keystone. Все опробовано в проде.


  1. Alexandroppolus
    13.01.2022 20:38
    +1

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

    В самом хуке имеется хрень - плотная мутабельная работа со стором вне useEffect. Небольшая засада в том, что надо возвращать store.current.value, потому всю эту работу не получится просто взять и обернуть в useEffect, но судя по всему, тут достаточно оставить "снаружи" только вычисление возвращаемого значения, а стор как-нибудь переживет запаздывание, он всё равно не вылазит наружу и не используется в рендере.

    Пример с отправкой подарка не впечатлил - на промисах (через async/await, конечно), это делается проще. Единственное, что тут придется модалки авторизации/подтверждения/etc тоже промисифицировать, но это не проблема.


    1. funca
      13.01.2022 23:49
      +1

      Promise это значение, а Observable - процесс. Основной недостаток промисов внутри эффектов - цепочку промисов невозможно кансельнуть когда компонент анмаунтится (а если расставлять внутри проверки наподобие AbortController у fetch, то код становится ещё страшнее, чем на RxJS).


      1. Alexandroppolus
        14.01.2022 10:14
        +2

        В приведенном варианте sendPresent после каждого шага делается проверка положительного результата. Причем в старом добром колбэчном стиле из нулевых: код неудержимо растет в ширину и едет вправо. При использовании async/await эта проверка навтыкается между авайтами: if (!success) return; Да, лишние строки, зато код остаётся "вертикальным". Понимаю, что дело вкуса, но мне вертикальный код нравится больше.


        1. funca
          16.01.2022 01:07

          Да, код лучше писать максимально просто и примеры в статье выглядят спорными (как будто чисто императивное решение написали операторами). Но сам опыт в целом интересный.

          Обычно RxJS приносит с собой массу особенностей и проблем: отдельную парадигму реактивного программирования, функциональный стиль, проблемы с поддержкой, отладкой и автотестами. К тому же это низкоуровневая библиотека, а не готовое решение какой-либо насущной задачи. Observable уже давно пытаются притащить в стандарт, но результатов пока нет, да и энтузиазма кажется с годами уже поубавилось. Microsoft вроде бы и сами его не шибко используют.


  1. bouncycastle
    13.01.2022 23:57

    Есть готовая реализация стопы на основе RxJS - Ngrx. Заточена конечно под ангуляр (как и ангуляр под rxjs) но мб и с реактом мб юзабельно.


    1. funca
      14.01.2022 00:16

      Ещё полгода тому назад он тянул в зависимостях половину ангуляра.


  1. skeks
    14.01.2022 10:49

    Можете назвать какие-то плюсы для использования RxJS вместо Redux, кроме как бойлер плейт(уже есть redux-toolkit) и удобной асинхронной логиков(с чем я бы поспорил)?

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

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

    Redux очень хорошо дебажится практически из коробки для веба и rn. Есть что-то подобное для RxJS?


  1. DarthVictor
    14.01.2022 12:54

    На тот момент активно развивались и пользовались популярностью фреймворки Redux и MobX, однако они не упрощали работу с пайплайнами относительно Flux, а Redux также требовал на порядок больше бойлерплейта. В общем, везде свои проблемы.

    А можно у вас попросить пример того как бы вы написали аналогичный sendPresent код на Redux и MobX? Мне вот не очевидно, что код на генераторах (через redux-saga или через встроенные в MobX flow) будет менее понятным.


  1. noodles
    15.01.2022 14:30

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

    Шило-на-мыло. А трата времени на изучение большого количества операторов?