Тайны кэша клиентов GraphQL — Введение

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

Это серия из двух частей, в которой я расскажу о кэшах клиентов GraphQL и сравню производительность кэша клиента.

Часть 1: Тайны кэша клиентов GraphQL — введение
Часть 2: Тайны кэша клиентов GraphQL — разборка


«В информатике есть только две трудные вещи: аннулирование кэша и именование вещей».
— Фил Картон

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

Краткое введение в GraphQL-клиенты

Традиционный (но не единственный) способ использования GraphQL — это API, который взаимодействует через HTTP POST-запросы. Поскольку ответ GraphQL API немного сложнее, чем простой REST-ответ, были созданы библиотеки, облегчающие вам жизнь при взаимодействии с такими API. Такие библиотеки, как Apollo client, Relay, URQL и т.д., помогают вам автоматически обрабатывать такие вещи, как пакетная обработка, кэширование, построение запросов, управление состоянием пользовательского интерфейса и многое другое.

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

Кэширование — не гео

Как «намекается» в названии этого поста, сегодня мы сосредоточимся на кэшировании. Кэширование в GraphQL, как и везде, используется для сохранения данных, полученных от какого-либо уровня данных (например, сервера), чтобы в следующий раз, когда нашему приложению понадобятся эти данные, оно могло прочитать их из нашей кэш-памяти вместо того, чтобы делать еще один сетевой запрос к нашему уровню данных. Таким образом, мы можем сэкономить драгоценные ресурсы и минимизировать время загрузки клиента. Вышеописанный тип кэша можно также назвать кэшем в памяти.

Однако реализовать и поддерживать кэш не так просто, как кажется, и каждый клиент делает это по-своему. Я исследовал в основном два популярных клиента — Apollo client и Relay.

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

Нормализация данных

Нормализация данных — это процесс реструктуризации некоторых данных для уменьшения избыточности. Возможно, вы слышали об этом из реляционных баз данных, где вам нужно пройти 5 нормальных форм, прежде чем вы сможете даже начать думать о счастье… 🙂 Оба клиента имеют нормализованный кэш (который сейчас является стандартом де-факто), и им необходимо выполнить этот процесс для преобразования JSON-блоба, который они получают от GraphQL-сервера, в реляционную структуру. Алгоритм, используемый для нормализации, различается между ними, однако принципы остаются одинаковыми.

Клиент Apollo

Как клиент GraphQL — Apollo является более простым и гибким из двух. Он обеспечивает более легкий способ начать работу, немного более полную документацию и, возможно, лучшую поддержку сообщества. Однако, когда дело доходит до реализации кэша, он, к сожалению, отстает.

Однако прежде чем мы перейдем к сравнению производительности этих двух систем, позвольте мне объяснить, как работает кэш Apollo. Как упоминалось ранее, они используют In-Memory кэш, который состоит из двух основных частей — EntityStore и ResultCache.

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

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

Алгоритм нормализации

Как уже упоминалось ранее, Apollo по умолчанию поддерживает нормализованный In-Memory кэш, и теперь я хотел бы в нескольких словах объяснить, как работает их алгоритм нормализации.

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

query getAllUsers {
  users {
    id
    firstname
    lastname
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Когда ответ GraphQL достигает клиента, он приходит в формате JSON и выглядит примерно так:

{
  "data": {
    "users": [
      {...},
      {...},
      {...},
    ]  
  }    
}
Войти в полноэкранный режим Выход из полноэкранного режима

Содержимое объекта data — это фактический ответ, поэтому с этого момента мы будем работать только с тем, что находится внутри data.
Итак, первым шагом для нормализации этих данных будет разделение нашего массива users на отдельные объекты.

{
  "__typename": "User",
  "id": 1,
  "firstname": "John",
  "lastname": "Doe"
}

{...}

{...}
Вход в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что поле __typename было добавлено в наш ответ, хотя мы не запрашивали его в нашем запросе. Это происходит потому, что клиент Apollo запрашивает это поле автоматически, даже если вы не сделали этого явно. В следующем шаге вы увидите, почему.

Теперь, когда мы извлекли все наши объекты, мы можем выполнить еще один шаг нормализации, который заключается в создании глобально уникального идентификатора для каждого объекта, чтобы его можно было сохранить в таблице поиска типа ключ-значение (Hashmap). По умолчанию клиент Apollo использует составной ключ, состоящий из __typename + id. Теперь вы понимаете, почему клиент Apollo запросил поле __typename автоматически. Однако не все объекты в нашем ответе всегда имеют уникальный id, который может быть в составном ключе, поэтому Apollo дает нам возможность выбрать, какие поля мы хотим использовать для создания этого уникального ключа-идентификатора, используя настройку typePolicies в конфигурации InMemoryCache.

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

{
  "__typename": "User",
  "id": 1,
  "firstname": "John",
  "lastname": "Doe",
  "address": {
    "__typename": "Address",
    "id": 1,
    "line1": ...,
    ...
  }
}

// The cache lookup table would look something like this after the normalization of the above object.

{
  "User:1": {
    "id": 1,
    ...
    "address": {
      "__ref": "Address:1"
    }
  },
  "Address:1": {
    "id": 1,    
    ...
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Однако важно отметить, что если это будут просто скалярные поля, скажем, массив объектов, которые не являются типом GraphQL, то они не будут извлечены из объекта.

И теперь вы знаете, как Apollo выполняет нормализацию, не так уж и сложно, правда? 🙂

Далее, давайте перейдем к нашему другу Relay.

Relay

Как уже упоминалось ранее, Apollo является более упрощенным и универсальным, в то время как Relay — более оптимизированным и узконаправленным. Я имею в виду, что в то время как Apollo Client имеет фреймворки для нескольких языков, Relay был создан специально для React и очень оптимизирован.

Однако, когда дело доходит до кэша, они не так уж сильно отличаются в своих основных реализациях. Если на минуту забыть о ResultCache от Apollo, то они действительно очень похожи. Однако, поскольку Relay основан на гранулированных фрагментах, а не на целых запросах, как Apollo, ему не нужен ResultCache. Именно поэтому клиенту Apollo пришлось добавить еще один уровень сложности к своему InMemoryCache, чтобы оптимизировать его для повторного чтения. (Повторные чтения целых запросов очень часты в apollo, и поэтому процесс мемоизации, выполняемый ResultCache, помогает ускорить этот процесс).

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

Алгоритм нормализации

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

Как вы уже знаете, клиент Apollo использует составной ключ из полей __typename + id и извлекает вложенные объекты, только если они имеют типы GraphQL (не скаляры). Однако Relay, с другой стороны, использует другой подход, где каждый вложенный объект извлекается в Record и ему присваивается DataId. Это глобально уникальный идентификатор в области действия кэша, который может быть получен из поля id объектов, или, если у них нет таких полей, он может быть основан на пути к записи от ближайшего объекта с id (такие id, основанные на пути, называются клиентскими id). Благодаря этой логике даже вложенные скалярные объекты всегда извлекаются и нормализуются.

Далее

Теперь, когда я объяснил основы работы кэша клиента GraphQL, я хотел бы показать вам соревнование между клиентами, сравнивая их кэши лоб в лоб. Используйте эту ссылку, чтобы прочитать об этом подробнее в части 2 — Тайны кэша клиентов GraphQL — The Showdown.

Оцените статью
devanswers.ru
Добавить комментарий