State Management

GraphQL-кеш как состояние

В GitHub, чей публичный API построен на GraphQL, один объект Issue показан сразу в трёх местах: в списке задач, в карточке репозитория и в шапке самой задачи. Запрос изменил статус Issue, и все три места обновились разом, хотя пришли разными запросами. Это работа нормализованного кэша Apollo: он не хранит ответы как плоские снимки, а раскладывает их по объектам с типом и id. Один Issue#42 в кэше один на всё приложение. Поэтому в GraphQL-клиентах кэш это не вспомогательный слой при запросах - кэш сам и есть серверное состояние.

  • GitHub: публичный GraphQL API, где один объект показан во многих местах и обновляется через общий кэш
  • Shopify Storefront и Admin: GraphQL-данные о товарах и заказах, нормализованные по типу и id
  • Apollo Client в дашбордах и админках: после мутации кэш обновляется и все вьюхи объекта перерисовываются разом
  • urql в более лёгких проектах: тот же нормализованный кэш через пакет Graphcache, но компактнее
  • Соцграфы и ленты, где сущность (пользователь, пост) встречается в десятках мест одного экрана

Предварительные знания

  • Разделение серверного и клиентского состояния и идея кэша как источника данных
  • Базовый GraphQL: запросы, мутации, типы и поля
  • Понимание, что один и тот же объект может встречаться в разных частях ответа
  • Server-state против client-state

Откуда взялась нормализация кэша в GraphQL

GraphQL Facebook открыла в 2015 году, и почти сразу встал вопрос клиентского кэша. Первый клиент Facebook, Relay, ввёл строгую нормализацию: каждый объект с глобальным id хранится в кэше один раз, а запросы лишь ссылаются на него. В 2016 году команда Apollo (тогда Meteor Development Group) выпустила Apollo Client с более доступным API, сохранив ту же идею нормализованного кэша по __typename и id. Позже Formidable выпустила urql как более лёгкую альтернативу, где нормализация подключается отдельным пакетом Graphcache. Во всех трёх клиентах общий принцип один: кэш это нормализованный граф объектов, и он же и есть состояние.

Нормализованный кэш и почему он и есть состояние

Кэш в TanStack Query и SWR хранит ответы плоскими снимками по ключу: под ключом лежит то, что вернул запрос, как есть. Нормализованный кэш в Apollo и urql устроен иначе. Он разбирает ответ на отдельные объекты и хранит каждый по уникальному ключу - обычно это пара __typename и id, например Issue:42. Один и тот же объект, встретившийся в разных запросах, в кэше существует один раз, а запросы лишь ссылаются на него.

Объект Issue:42 из обоих запросов кэшируется как одна запись. Если мутация изменила его status, и список, и детальная карточка отразят изменение разом, потому что обе ссылаются на одну запись в кэше. В плоском кэше пришлось бы инвалидировать оба запроса отдельно. Здесь же кэш это связный граф объектов, а не набор независимых снимков, поэтому он и выступает единым источником серверного состояния.

  • Плоский кэш (Query, SWR) — Хранит ответ запроса целиком под ключом. Один объект в двух запросах - две копии. Синхронизация через инвалидацию ключей.
  • Нормализованный кэш (Apollo, urql) — Разбирает ответ на объекты по __typename + id. Один объект - одна запись на всё приложение. Правка записи обновляет все ссылки разом.

Условие нормализации: у объекта должен быть стабильный идентификатор, который клиент умеет вычислить. По умолчанию это id или _id вместе с __typename. Если у типа нет id, его нельзя нормализовать, и Apollo хранит такой объект встроенным в родителя, а не отдельной записью.

Чем нормализованный кэш Apollo отличается от плоского кэша TanStack Query?

Чтение и запись кэша напрямую

Раз кэш это состояние, к нему нужен прямой доступ - не только через запросы к сети. Apollo даёт пары методов на клиенте кэша. readQuery и writeQuery читают и пишут данные в форме целого запроса. readFragment и writeFragment работают точечнее - с одним объектом по его ключу, без привязки к конкретному запросу. Это позволяет менять отдельную запись графа, и все вьюхи, которые на неё ссылаются, обновятся.

В urql прямой доступ к кэшу устроен похоже через пакет Graphcache: в update-резолверах доступны методы cache.writeFragment и cache.readFragment, а также cache.invalidate для удаления записи. Принцип общий для обоих клиентов: писать в граф можно адресно, по ключу объекта, а не только перезапрашивая весь запрос целиком.

Метод (Apollo)УровеньНазначение
readQuery / writeQueryЦелый запросПрочитать или записать данные в форме запроса
readFragment / writeFragmentОдин объектТочечно прочитать или изменить запись по ключу
cache.modifyПоля объектаИзменить отдельные поля записи без полного фрагмента
cache.evictЗаписьУдалить объект из кэша по ключу

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

Зачем в Apollo нужны readFragment и writeFragment в дополнение к readQuery и writeQuery?

Обновление кэша после мутации

После мутации в GraphQL кэш обновляют двумя путями. Первый и самый простой: попросить мутацию вернуть изменённый объект с тем же __typename и id, и нормализованный кэш сам подменит запись. Это работает для правки существующего объекта без всякого ручного кода - клиент сопоставит ответ с записью по ключу. Второй путь нужен, когда меняется состав коллекции: добавление или удаление элемента списка.

Когда мутация добавляет или удаляет элемент списка, нормализация сама не догадается вставить или убрать его из коллекции - ссылку на список надо поправить вручную в update-функции. Там через cache.modify меняют поле-список объекта или через cache.evict удаляют запись. Это тот же класс задачи, что инвалидация списка после мутации в TanStack Query, только решённый прямой правкой графа, а не пометкой устаревшим и рефетчем.

  • Правка существующего объекта: вернуть его из мутации с __typename и id, кэш обновится сам
  • Добавление в список: update-функция вставляет ссылку на новый объект через cache.modify
  • Удаление из списка: cache.evict убирает запись, затем cache.gc чистит висячие ссылки
  • Альтернатива ручной правке: refetchQueries перезапрашивает затронутые запросы, как инвалидация в Query

Выбор между прямой правкой кэша и рефетчем такой же, как между setQueryData и invalidateQueries в TanStack Query. Прямая правка мгновенна и не ходит в сеть, но требует кода под каждый случай. Рефетч проще и надёжнее в сверке, но добавляет сетевой запрос.

Мутация изменила поле status существующего объекта Issue. Что обычно достаточно сделать, чтобы кэш Apollo обновился?

Связь с другими темами

Урок про кэш как состояние. Дальше тема связана так:

  • Server-state против client-state — Нормализованный кэш это предельное выражение идеи, что серверные данные это кэш, а не переменная
  • TanStack Query: мутации и optimistic — Обновление кэша после мутации решает ту же задачу, что инвалидация в Query, но иными средствами

Итог

  • В GraphQL-клиентах (Apollo, urql) нормализованный кэш сам и есть серверное состояние, а не вспомогательный слой при запросах
  • Нормализация: ответы не хранятся плоскими снимками, а раскладываются по объектам с ключом __typename плюс id. Один объект в кэше один на всё приложение
  • Поэтому правка одного объекта обновляет все места, где он показан, даже если они пришли разными запросами
  • Чтение и запись кэша идут напрямую: readQuery/readFragment и writeQuery/writeFragment в Apollo дают точечный доступ к графу
  • После мутации кэш обновляют двумя путями: рефетч затронутых запросов или прямая правка кэша в update-функции под мгновенный отклик

Связанные уроки

  • sm-30-server-vs-client-state — Нормализованный кэш это конкретная реализация идеи, что серверные данные это кэш, а не переменная
  • sm-32-tanstack-mutations — Обновление кэша после мутации в GraphQL решает ту же задачу, что инвалидация в TanStack Query
GraphQL-кеш как состояние

0

1

Войти