Кэширование ответов на GraphQL с помощью Envelop


Эта статья была опубликована в Четверг, Август 19, 2021 автором Laurin Quast @ The Guild Blog

Краткое введение в кэширование

Огромные операции запросов GraphQL могут замедлить работу вашего сервера, поскольку глубоко вложенные наборы выбора могут вызвать множество последующих чтений базы данных или вызовов других удаленных служб. Такие инструменты, как DataLoader, могут уменьшить количество одновременных и последующих запросов посредством пакетной обработки и кэширования во время выполнения одной операции GraphQL. Такие функции, как @defer и @stream, помогают постепенно передавать клиентам частицы результатов, которые медленно извлекаются. Однако при последующих запросах мы снова и снова попадаем в одно и то же узкое место.

Что если нам вообще не нужно проходить фазу выполнения для последующих запросов, выполняющих одну и ту же операцию запроса с одними и теми же переменными?

Общепринятой практикой сокращения медленных запросов является использование кэширования. Существует множество типов кэширования. Например, мы можем кэшировать все HTTP-ответы на основе POST-тела запроса или кэш в памяти в бизнес-логике резольвера полей GraphQL, чтобы реже обращаться к медленным сервисам.

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

В строгой среде REST API кэширование сущностей значительно проще, поскольку каждая конечная точка представляет один ресурс, и поэтому метод GET может быть кэширован, а метод PATCH может использоваться для автоматического аннулирования кэша для соответствующего GET запроса, который описывается через HTTP путь (/api/user/12).

С GraphQL такие вещи становятся намного сложнее и запутаннее. Во-первых, у нас обычно есть только одна конечная точка HTTP /graphql, которая принимает только запросы POST. Результат выполнения операции запроса может содержать множество различных типов сущностей, поэтому нам нужны различные стратегии кэширования GraphQL API.

SaaS-сервисы, такие как FastQL и GraphCDN, начали появляться, предоставляя прокси для вашего существующего GraphQL API, которые волшебным образом добавляют кэширование на основе ответов. Но как это вообще работает?

Как работает кэширование ответов GraphQL?

Кэширование операций запроса

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

Пример: Работа с запросом GraphQL

query UserProfileQuery($id: ID!) {
  user(id: $id) {
    __typename
    id
    login
    repositories
    friends(first: 2) {
      __typename
      id
      login
    }
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Пример: Переменные GraphQL

{
  "id": "1"
}
Войти в полноэкранный режим Выход из полноэкранного режима

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

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

OperationCacheKey (e.g. SHA1) = hash(GraphQLOperationString, Stringify(GraphQLVariables))
Войти в полноэкранный режим Выход из полноэкранного режима

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

OperationCacheKey (e.g. SHA1) = hash(GraphQLOperationString, Stringify(GraphQLVariables), RequestorId)
Вход в полноэкранный режим Выход из полноэкранного режима

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

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

Аннулирование кэшированных операций запросов GraphQL

Давайте посмотрим на возможный результат выполнения операции GraphQL.

Пример: Результат выполнения операции GraphQL

{
  "data": {
    "user": {
      "__typename": "User",
      "id": "1",
      "login": "dotan",
      "repositories": ["codegen"],
      "friends": [
        {
          "__typename": "User",
          "id": "2",
          "login": "urigo"
        },
        {
          "__typename": "User",
          "id": "3",
          "login": "n1ru4l"
        }
      ]
    }
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Многие фронтенд-фреймворки кэшируют результаты операций GraphQL в нормализованном кэше. Идентификатором для хранения отдельных объектов результата операции GraphQL в кэше обычно является поле id типов объектов для схем, использующих глобальные уникальные идентификаторы, или соединение полей __typename и id для схем, использующих не глобальные поля идентификаторов.

Пример: Нормализованный клиентский кэш GraphQL

{
  "User:1": {
    "__typename": "User",
    "id": "1",
    "login": "dotan",
    "repositories": ["codegen"],
    "friends": ["$$ref:User:2", "$$ref:User:3"]
  },
  "User:2": {
    "__typename": "User",
    "id": "2",
    "login": "urigo"
  },
  "User:3": {
    "__typename": "User",
    "id": "3",
    "login": "n1ru4l"
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Для результата выполнения IDS сущностей, которые могут быть использованы для признания операции недействительной, следующие: User:1, User:2 и User:3.

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

Entity   List of Operation cache keys that reference a entity

User:1   OperationCacheKey1, OperationCacheKey2, ...
User:2   OperationCacheKey2, OperationCacheKey3, ...
User:3   OperationCacheKey3, OperationCacheKey1, ...
Вход в полноэкранный режим Выход из полноэкранного режима

Это позволяет нам отслеживать, какие операции GraphQL должны быть аннулированы, когда определенная сущность становится неактуальной.

Остается вопрос, как мы можем отследить, что сущность становится несвежей?

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

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

Общим шаблоном при отправке мутаций от клиентов является выбор и возвращение затронутых/мутировавших сущностей с набором выбора.

Для нашего примера выше возможной мутацией может быть добавление нового хранилища в поле repositories сущности пользователя.

Пример: Мутация GraphQL

mutation RepositoryAddMutation($userId: ID, $repositoryName: String!) {
  repositoryAdd(userId: $userId, repositoryName: $repositoryName) {
    user {
      id
      repositories
    }
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Пример: Результат выполнения мутации GraphQL

{
  "data": {
    "repositoryAdd": {
      "user": {
        "id": "1",
        "repositories": ["codegen", "envelop"]
      }
    }
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

В данном конкретном случае все операции, которые выбирают User:1, должны быть признаны недействительными.

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

Кэш ответов Envelop

Плагин envelop response cache теперь предоставляет примитивы и реализацию эталонного хранилища в памяти для внедрения такого кэша со всеми вышеупомянутыми функциями на любом сервере GraphQL.

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

Добавить кэш ответов к существующей настройке сервера envelop GraphQL так же просто, как добавить плагин:

import { envelop } from '@envelop/core'
import { useResponseCache } from '@envelop/response-cache'

const getEnveloped = envelop({
  plugins: [
    // ... other plugins ...
    useResponseCache()
  ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

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

import { envelop } from '@envelop/core'
import { useResponseCache, createInMemoryCache } from '@envelop/response-cache'
import { emitter } from './event-emitter'

const cache = createInMemoryCache()

emitter.on('invalidate', entity => {
  cache.invalidate([
    {
      typename: entity.type,
      id: entity.id
    }
  ])
})

const getEnveloped = envelop({
  plugins: [
    // ... other plugins ...
    useResponseCache({ cache })
  ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Поведение кэша может быть полностью настроено. TTL может быть задан глобально или более детально для каждого типа или координат схемы.

import { envelop } from '@envelop/core'
import { useResponseCache } from '@envelop/response-cache'

const getEnveloped = envelop({
  plugins: [
    // ... other plugins ...
    useResponseCache({
      // cache operations for 1 hour by default
      ttl: 60 * 1000 * 60,
      ttlPerType: {
        // cache operation containing Stock object type for 500ms
        Stock: 500
      },
      ttlPerSchemaCoordinate: {
        // cache operation containing Query.rocketCoordinates selection for 100ms
        'Query.rocketCoordinates': 100
      },
      // never cache responses that include a RefreshToken object type.
      ignoredTypes: ['RefreshToken']
    })
  ]
})
Войти в полноэкранный режим Выход из полноэкранного режима

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

import { envelop } from '@envelop/core'
import { useResponseCache } from '@envelop/response-cache'

const getEnveloped = envelop({
  plugins: [
    // ... other plugins ...
    useResponseCache({
      // context is the GraphQL context that would be used for execution
      session: context => (context.user ? String(context.user.id) : null),
      // never serve cache for admin users
      enabled: context => (context.user ? isAdmin(context.user) === false : true)
    })
  ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Не хотите автоматически аннулировать кэш на основе мутаций? Также настраивается!

import { envelop } from '@envelop/core'
import { useResponseCache } from '@envelop/response-cache'

const getEnveloped = envelop({
  plugins: [
    // ... other plugins ...
    useResponseCache({
      // some might prefer invalidating only based on a database write log
      invalidateViaMutation: false
    })
  ]
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Хотите глобальный кэш на Redis? Создайте кэш, реализующий интерфейс Cache, и поделитесь им с сообществом!

export type Cache = {
  /** set a cache response */
  set(
    /** id/hash of the operation */
    id: string,
    /** the result that should be cached */
    data: ExecutionResult,
    /** array of entity records that were collected during execution */
    entities: Iterable<CacheEntityRecord>,
    /** how long the operation should be cached */
    ttl: number
  ): PromiseOrValue<void>
  /** get a cached response */
  get(id: string): PromiseOrValue<Maybe<ExecutionResult>>
  /** invalidate operations via typename or id */
  invalidate(entities: Iterable<CacheEntityRecord>): PromiseOrValue<void>
}
Вход в полноэкранный режим Выход из полноэкранного режима

Более подробную информацию обо всех возможных вариантах конфигурации можно найти в документации по кэшу ответов на Plugin Hub.

Что дальше?

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

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

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

У вас есть идеи, вы хотите внести свой вклад или сообщить о проблемах? Начните обсуждение/проблему на GitHub или свяжитесь с нами в чате!

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