React-apollo - нишевое решение или универсальная таблетка? Часть 2.

После первой части меня беспокоило то, что я не коснулся всех аспектов о которых хотел рассказать, т.к. они требовали более глубокого погружения внутрянку apollo клиента. Теперь, благодоря комменнтарию @ilya-kvachenko у меня появился повод поделится информацией о том что проиходит за кулисами. Чтобы была понятно к чему я это все пишу, я буду отталиватся от точек зрения озвученных мои аппонентом по порядку:

"Твоя query не будет Observable"

Речь идет о компоненте <Async> пример которого я приводил в контексте того что apollo не изобрел ничего принципиально нового в архитектуре клиенсткого приложения.

Мой аналог <Async /> действительно не использует observable паттерн. Однако оба они реагируют на изменение состояния, loading, error, и получение финального ответа, хоть и реализации отличаются в фукнциональном плане они одинаковы. Впрочем если выйти за рамки примера разница есть - под капотом <Query/> использует аполло кэш, а <Async/> нет. Давайте остановимся на этом подробнее:

Что такое Apollo Cache

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

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

  1. Нам нужно аккумулировать одинаковые запросы на бекенд в короткий промежуток времени и упаковывать в один запрос.
  2. Поскольку браузер не кэширует ответы graphql нам необходимо реализовать свой кэш а-ля map { запрос: ответ } и брать айди пользователя из кэша

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

Что нам это дает?

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

Создается впечателние полностью изолированого компонента!

Можно ли доработать компонент <Async /> для достижения того-же самого поведения? Конечно, мы можем хранить и очередь запросов и ответы в redux хранилище, и при этом по-прежнему использовать любой промис а не только graphQl запросы для работы с ним. (причем к качестве ключа можно хранить саму функцию вызова, благо Map это позволяет.)

Но к большому сожалению эта замечательная обстракция имеет дыру не очевидную на первый вгляд. Не все данные запрошенные с бекенда статичны как id user-a. Иногда нам нужны свежие данные из одного и того-же запроса каждый раз. Для этого был придуман параметр клиента "fetchPolicy".

  • cache-first поведение по умолчанию. Сначала заглянет в кэш, и если ничего там не найдет сходит на бекенд, сохранит данные к кэш и вернет компоненту
  • cache-and-network: Тоже самое что cache-first только после получения данных из кэша будет отпавлен сетевой запрос на обновление значения к кэше. Здесь мы получаем двойной цикл рендера - сначала старые данные, потом новые.
  • network-only: Всегда будет отправлять запрос на бекенд даже если данные есть в кэше и перезапишет их новыми. Новые данные отдаст компоненту на рендер
  • cache-only: Посмотрит есть ли данные в кэше и вернет ошибку если их там нет.
  • no-cache: Тоже самое что network-only но данные не попадают в кеш после ответа бекенда.

Маленькое отсутпление:

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

Таким образом каждый из наших компонентов имеет сайд эффект (за исключением случая no-cache - который превращает все старания по оптимизациям в тыкву). Он не только запросил данные для себя но и обновил все остальные компоненты. Как мы будем выкручиватся в ситуации когда нам нужно обновлять данные в кеше с некоторой переодичностью (polling) ? У нас должна быть где-то темплейте у какого-то компонента квери с network-only а все оатльные квери должны учитывая это брать данные из cache-only . Абстрацкия протекла, у нас теперь есть разбросанные по приложению компоненты которые должны согласовывать между собой кто тут главный. Причем зависимость эта не прослеживаемая и не очевидная - только настроивший это однажды знает где искать тот самый компонент.

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

И так, резюмируем ответ на вопрос:

  1. <Async> компонент в приведенном примере ведет себя так же как <Query>
  2. <Async> компонент можно модифицировать до точного воспроизведения <Query> компонента но с профитом в виде отсуствия зависимсоти от graphql
  3. Кэш аппло создан для оптимизации запросов на бекенд
  4. Этот подход упрощает написание простого приложения но сильно усложняет его поддержку в нестанлартных ситуациях



Второй контраргумент Ильи начинается так:

Подход apollo близок к flux, flux это не архитектура а рекомендация, и все ключевые моменты очень напоминают flux. One-direction-data-flow, single sourсe of truth и так далее.

Близок ли он к flux мы сейчас рассмотрим, но перед этим я просто обязан поправить Илью в его непреднамеренном приуменьшении - flux это все таки архитектура. Архитекстурный стиль, но не рекомендация. Что бы что то работала как flux нудно соблюсти его ключевые принципы.

Один из центральных принципов звучит так:

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

Что ж по утверждению Ильи "Flux Store -> apollo-cache". Если это действительно так, то <Query> компонент является противоположностью выше описанного принципа - <Query> одновременно вызывает экшен, потом получает данные, диспатчит данные в нужное место в сторе и подписывает компонент на изменения. Это очень важно осознать и запомнить, потому что не понимание механизмов кэширования приводит к заблуждению будто:

Mutation это запись данных и передача в компонент ответа после записи. Query чтение данных

Это справедливо для квери отправленных на бекенд. Mutation запрос graphql отправит запрос на изменение на бекенд и запишет его ответ к кэш, далее передаст данные из кэша в рендер, так же как и query

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

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

Да, запись к кэш можно отключить благодоря fetch-policy отключив в данном случае механизмы оптимизации аплло для этого запроса, что неибежно породит лишние запросы на бекенд - мы хотели не менять состояние приложения и для этого отключили кэширование. Разве это то чего мы добивались?

Впрочем это не единственное противорече возникающие в данной ситуации. <Query> с cache-only возьмет данные только из кэша и квери с @client дерективой тоже. У нас снова две вещи отвечающие за одно и тоже что приводит нас к проблемам в духе "у меня два не связаных выключателя света а разных частях здания". Впрочем даже если мы вызвали квери с cache-only нам никто не запрещает делать из резолвера @cleint квери переданный в этот cache-only запрос, и наоборот. Как только мы начинаем использовать внуренние механимы кэша аполо мы лишаемся как либо гарантий по поводу того как это будет работать. А ведь в резолвере мы можем вобще писать данные в кэш любой другой квери случайно или специально. Итого у нас есть поведение - что-то пишется в кэш - и нам нужно проверить fetch-policy и дерективу @client ВСЕХ квери которые хоть как-то связаны с этими данными чтобы найти источник и проверить ВСЕ резолверы в нашем приложении.

Если мой аппонент это сознает я удивлен что заставляет его писать контраргументы вроде:

Для @client метки существует большой набор опций и ее поведение абсолютно предсказуемо.

Разберем следующее утверждение:

dispatcher это resolver grqphql

Что ж давайте взглянем на определение того что такое диспатчечр

dispatcher предназначен для передачи действий хранилищам. В dispatcher хранилища регистрируют свои функции обратного вызова (callback) и зависимости между хранилищами

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

В упрощённом варианте диспетчер может вообще не выделяться, как единственный на всё приложение. 

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

А сколько у нас есть способов изменить кэш аполло, давайте посчитаем:

  • client.writeCache
  • client.writeQuery
  • Вызов любой <Query> без fetch-policy: network-only или no-cache (в зависимости от того клиенсткая у нас квери или нет)
  • Вызовы любой <Mutation> без fetch-policy (см. выше)
  • Вызовы любой <Subscription> без fetch-policy (см. выше)

Контраргумент в виде

Это так, мы можем. Плохо (и принимаем это за правило) никогда не использовать direct cache.write

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

Годится только для выставления дефолтного состояния приложения при инициализации. Закрыли тему )).

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

Тут не Flux и не надо делать его буква в букву Flux'ом. Тут все есть быть близким к flux но другим.

Ну что ж redux тоже раширяет понятие flux и не следует ему буква в букву. Однако но не противоречит его основным идеям, и сохраняет все те ценности что дает на flux - предсказуемость поведения и простоту дебагинга.

__typename

"__typename" придется дать всем данным если ты от фонаря решишь положить их в кеш. Это либо дефолты которые определяешь в соответствующем месте или direct cache write (который мы не используем). Если работать через query/mutate/subscribe они все будут иметь тип который они получат от query.

Ну что ж это отличное предложение, теперь мне всего-лишь нужно писать отдельную квери с резолвером на каждое вложенное свойство!

т.е. Если я хочу положить в стейт обьект типа { a: {b { c: 'boom' }}}, мне надо написать квери для a, b и с обьектов и резолвер с кверей на них?

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

Тулсет Apollo

Тулы которые использую, работают нормально.'

Молю поделится тулами apollo которые ты используешь, потому что их официальные тулы для отслеживания состояния приложения не работают при доступе приложения через nginx (наш случай):

А если бы и не было nginx все равно крашится как только я хочу им воспользоваться


То что осталось за кадром - производительность

Квери которые мы пишем преобразуются в сложные обекты с которыми работает аплло. Он рекурсивно обходит их дробит на grpaphql фрагменты кэшируя их поотдельности. Эта нагрузка на cpu позволяет снизить нагрузку на сеть и оправдана если мы говорим о сетевых запросах. В случае "клиенстких" квери это лишняя нагрузка бесполезна. Более того кэш апполло раздувается по мере увеличения различных квери в приложении и его более медленная работа может быть не заметна на фоне сетевой задержки. Redux имея намного более простые механизмы будет заполнятся зарботчиком по мере надобности, благодоря чему он по прежнему сможет быстро реагировать на изменения локального состояния.

Архитектура предложенная apollo

Но с другой стороны apollo предложили свой вариант клиентской архитектуры

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

Резюмируем

  • Apollo определенно не является flux архитектурой.
  • Изначальное назначение апло кэша в оптимизации запросов на бекенд. Вмешиватся в его работу черевато, особенно без полного понимания его мезанизмов
  • То что предлагает аполо это просто набор хаков как можно использовать их кэш так как будто ты бекенд. Это противоречит принципу единственной ответственности (Single Responsibility). Высока вероятность того что локальное состояние приложения и кэш запросов с бекенда должны вести себя по разному и работать с разными мидлварями.
  • Использовать кэш аполло значит ставит нас в зависимость от испольования graphql.
  • Поддержка клиентских намного сложнее - мы размываем границу между внешним апи и внутренним состоянием приложения, но в замен не получаем ни достаточной изоляции компонентов ни предсказуемого поведения. Более того клиенские квери в отличии от настоящих квери не имеют автоматически генерируемой доументации, как следствие не имеют инстурментов для построения клиенстких кверей как в случае с настоящими квери запросами.
  • Экосистема redux зрелая, имеет хорошую документацию, хороший инструментарий, большое количество мидлварей, и несет ссобой меньше оверхеда в случае работы с локальным состоянием

Вместо послесловия

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

February 28, 2019
by Александр Дубинин
0
19

React-apollo - нишевое решение или универсальная таблетка?

Apollo сделали много классных инструментов и технологий для работы в связке с gprahql. Конечно же они не могли пройти мимо хайпового react и привнесли свой инстурмент и в эту экосистему и назвали его react-apollo. Давайте посмотрим что именно предлагает их инстурмент:

Query component

Центральная фича react-apollo это компонет <Query />. Он позволяет связать ваш компонент напрямую с graphQL запросом наприямую. Вот как это выглядит:

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      name
    }
  }
`;

const GoodDogsBrent = () => (
  <Query query={GET_DOGS}>
    {({ loading, error, data }) => {
      if (error) return <Error />
      if (loading || !data) return <Fetching />

      return <DogList dogs={data.dogs} />
    }}
  </Query>
)

У нас есть квери GET_DOGS которая просто передается компоненту, далее он берет всю работу на себя - сообщаяет нам о состоянии загрузки, ошибках, и в конце отдает нам данные. Т.е. автоматически поставляет нашему компоненту три локальных состояния. Почему эту круто я думаю обьяснять не нужно - мы только что описали логику "сходи-забери-отобрази" в 17 строчек кода с прелоадером и отображением ошибки.

Это впечатляет особенно тех кто не понаслышке знаком с тем как пишутся приложения на реакте сегодня - скорее всего вы пишите приложения на ныне популярной FLUX архитектуре, и вероятно для его реализации вы взляли redux. А это значит что для добавления подобного функциона вам потребовалось бы создать сначала создать хотябы один экшен креатор и редьюсер для добавления того-же функционала. Да состояние компонентов не глобальное и у каждого свое но всегда ли нам нужно шарить состояние отдельных модулей?

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

const GET_DOGS = gql`
  query GetDogs {
    dogs {
      id
      name
    }
  }
`;

const GoodDogsBrent = () => (
  <Async promise={client.qeury(GET_DOGS)}>
    {({ loading, error, data }) => {
      if (error) return <Error />
      if (loading || !data) return <Fetching />

      return <DogList dogs={data.dogs} />
    }}
  </Async>
)

Такой комонент пишется за час-два зависимости от вашего опыта работы с реактом, вот пример реализации в 26 строк форматированого кода. Он работает с любым промисом и никак не привязан к graphQL.

Круто, так давайте все так писать, это же намного проще чем FLUX

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

Фактически подход предложенный apollo лишен всех тех преимуществ что дает нам flux. Значит ли это что создатели apollo глупые люди - конечно же нет. Не все приложения настолько сложные что в них необходим flux. Давайте представим что у вас новостной сайт, каталог, блог или любое другое приложение действующие по простой схеме - получил данные -> показал их.

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

Но что если в середине проекта выяснится что наш католог стал магазином и теперь мне нужна корзина для покупок доступная везде?

К сожалению здесь красивая история заканчивается и начинается, что называется - не очень. Кодовая база уже написана а переписывать все не вариант (а так как вся логика хранится у нас прямо в верстке, и не выделена в отдельный слой переписывать придется действительно много). Так появились народные решения "react-redux поверх react-apollo" - когда каждый компонент может быть подконекчен к стору редакса и к аполо клиенту одновременно. Это выход из ситуации, но он дает дополнительную серию перезапусков рендер функций так как теперь у нас два источника состояния данных на которые нужно реактивно отреагировать (т.е. приложение полуается завернутым в <Provider> реакт-редакса и <AppoloProvider> реакт-аполло).

Для выхода из этой не очень красивой ситуации аполо предлогает следующее:

"Мы уже используем хранилище состояния внутри нашего клиента для реализации кэша и механизмов оптимизации запросов на бекенд - вот вам api от нашего кэша, ложите ваше состояние туда и проблема решена". И назвали это apollo-link-state.

Что вобще может пойти не так с apollo-link-state

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

Из клиентской квери вы можете выполнять другие клиенсткие или не клиентские квери. Однако не следует думать что это аналог редьюсера - вы не возвращаете новое состояние, вы просто напрямую пишете данные в кеш (write.cache) или пишите их маскируя под другую квери (write.query) или просто возвращаете из резолвера результат и он пишется как кэш клиентской квери, или не пишется в зависимости что вы указали в компонете <Query> через параметр cache policy. (Я даже не представляю что будет если для клиентской квери я выставлю network-only 🤯).

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

Так как это graphql экшенов у вас тоже нет. У вас есть мутации квери и подписки. Мутации меняют кэш а данные просто получают данные оттуда? Но постойте - квери же кэшируются апло - значит квери тоже мутируют стейт. Тогда в чем их отличие от мутаций? И если мы реактивно реагируем на изменения кэша после того как квери разрезолвится зачем нам подписки?

Разумеется ни о каком FLUX или однонаправленном потоке данных речи тут не идет. Мы начинаем ковырятся во внутрянке реализации apollo-client отчаянно пытаясь пофиксить отсуствие flux в apollo-react. Тулы для дебагинга и просмотра кэша на данный момент сломаны что тоже не добавляет оптимизма. Как будто этого мало - в виду зелености технологии информации очень мало даже в собственной документации apollo. Количество велосипедов стремительно ростет. Но решение есть.

Просто не используйте apollo-react там где нужен FLUX

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

February 27, 2019
by Александр Дубинин
1
55
Show more