February 28, 2019

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 зрелая, имеет хорошую документацию, хороший инструментарий, большое количество мидлварей, и несет ссобой меньше оверхеда в случае работы с локальным состоянием

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

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