Концепции, идеи, стратегии
Концепции, идеи, стратегииCache control через persisted queries

Cache control через persisted queries

GraphQL, как правило, работает через POST, выполняя все queries против единственного endpoint и передавая параметры через тело запроса. URL этого единственного endpoint будет возвращать разные ответы, а значит, его нельзя кешировать (по крайней мере, не используя URL в качестве идентификатора).

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

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

Но у этого решения есть несколько недостатков:

  • Приложение получает больше JavaScript для выполнения на стороне клиента. Доступ к сайту с бюджетного мобильного телефона приведёт к потере производительности
  • Приложение становится сложнее, с большим количеством движущихся частей, поскольку теперь нужно также беспокоиться о реализации слоя кеширования
  • Не все понимают JavaScript (например, сайт может быть написан на PHP), но теперь работа с JS тоже становится ответственностью

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

Доступ к GraphQL через GET

Использование HTTP-кеширования означает, что мы будем кешировать ответ GraphQL, используя URL в качестве идентификатора. Это имеет 2 следствия:

  1. Мы должны обращаться к единственному endpoint GraphQL через GET
  2. Мы должны передавать query и переменные как параметры URL

Таким образом, если единственный endpoint — /graphql, операцию GET можно выполнить по URL /graphql?query=...&variables=....

Это применяется к получению данных с сервера (через операцию query). Для изменения данных (через операцию mutation) мы по-прежнему должны использовать POST. Здесь нет никакой проблемы, поскольку мутации всегда выполняются заново; мы не можем кешировать результаты мутации, поэтому HTTP-кеширование всё равно не использовалось бы с ней.

Этот подход работает (и даже рекомендуется на официальном сайте), но есть определённые аспекты, на которые нужно обратить внимание.

Кодирование GraphQL queries через параметр URL

GraphQL query обычно занимает несколько строк. Например:

{
  posts {
    id
    title
  }
}

Однако мы не можем ввести эту многострочную строку непосредственно в параметр URL.

Решение — закодировать её. Например, клиент GraphiQL закодирует приведённую выше query следующим образом:

%7B%0A%20%20posts%20%7B%0A%20%20%20%20id%0A%20%20%20%20title%0A%20%20%7D%0A%7D

Хорошо, это работает. Но выглядит не очень, правда? Кто вообще может понять эту query?

Одно из достоинств GraphQL — его queries очень легко читать. При некотором опыте, увидев query, мы сразу же её понимаем. Но после кодирования всё это теряется, и только машины могут её понять; человек выпадает из уравнения.

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

?query={ posts { id title } }

Это хорошо работает для простых queries. Но если у вас очень длинная query, открывающая и закрывающая множество { }, с аргументами полей и директивами, то её становится всё труднее понимать.

Например, эта query:

{
  posts(limit:5) {
    id
    title @titleCase
    excerpt @default(
      value:"No title",
      condition:IS_EMPTY
    )
    author {
      name
    }
    tags {
      id
      name
    }
    comments(
      limit:3,
      order:"date|DESC"
    ) {
      id
      date(format:"d/m/Y")
      author {
        name
      }
      content
    }
  }
}

Превратилась бы в эту однострочную query:

{ posts(limit:5) { id title @titleCase excerpt @default(value:"No title", condition:IS_EMPTY) author { name } tags { id name } comments(limit:3, order:"date|DESC") { id date(format:"d/m/Y") author { name } content } } } 

И снова выполнение query сработает, но мы не будем знать, что именно выполняем.

А если query ещё и содержит фрагменты — тогда можно совсем забыть об этом, никак не разобраться.

Persisted queries приходят на помощь

Если передача query в URL неудовлетворительна, какой ещё у нас есть вариант? Что ж, не передавать query в URL!

Именно этот подход называется «persisted query»: мы сохраняем query на сервере и используем идентификатор (например, числовой ID или уникальную строку, полученную применением алгоритма хеширования с query в качестве входных данных) для её получения. Наконец, мы передаём этот идентификатор как параметр URL вместо самой query.

Например, query может быть идентифицирована с ID 2908 (или хешем вроде "50ac3e81"), и тогда мы выполняем операцию GET по URL /graphql?id=2908. Сервер GraphQL затем получит query, соответствующую этому ID, выполнит её и вернёт результаты.

Gato GraphQL делает это ещё проще: persisted query реализована как пользовательский тип записи, поэтому мы можем создать её и опубликовать как обычную запись, а выбранный нами slug (который по умолчанию основан на введённом заголовке) станет её идентификатором. Persisted queries делают реализацию HTTP-кеширования тривиальной.

Вычисление значения max-age

HTTP-кеширование работает путём отправки заголовка Cache-Control в ответе со значением max-age, указывающим время, в течение которого ответ должен кешироваться, или no-store, указывающим не кешировать его.

Как сервер GraphQL будет вычислять значение max-age для query, учитывая, что разные поля могут иметь разные значения max-age?

Ответ таков: получить значение max-age для всех полей, запрошенных в query, и выяснить, какое из них наименьшее. Именно оно и станет значением max-age ответа.

Например, предположим, что у нас есть сущность типа User. Следуя поведению, назначенному этой сущности, мы можем назначить, как долго можно кешировать соответствующее поле:

🛠 Его ID никогда не изменится ⇒ Мы даём полю id значение max-age 1 год

🛠 Его URL будет обновляться очень редко (если вообще будет) ⇒ Мы даём полю url значение max-age 1 день

🛠 Имя человека может меняться время от времени (например, чтобы добавить статус или написать «Милтон (в маске)») ⇒ Мы даём полю name значение max-age 1 час

🛠 Карма пользователя на сайте может меняться в любое время (например, после того как кто-то проголосовал за его комментарий) ⇒ Мы даём полю karma значение max-age 1 минута

🛠 Если запрашиваются данные авторизованного пользователя, ответ нельзя кешировать вообще (независимо от того, какое поле мы получаем) ⇒ Значение max-age должно быть no-store

В результате ответ на следующие GraphQL queries будет иметь следующие значения max-age (в этом примере мы игнорируем max-age для поля Root.users, но на практике оно также будет учитываться):

QueryЗначение max-age
{
  users {
    id
  }
}
1 год
{
  users {
    id
    url
  }
}
1 день
{
  users {
    id
    url
    name
  }
}
1 час
{
  users {
    id
    url
    name
    karma
  }
}
1 минута
{
  me {
    id
    url
    name
    karma
  }
}
no-store (не кешировать)

Создание Cache Control List

После того как мы определили max-age для каждого поля, мы вводим эту информацию через Cache Control List:

Определение политики cache control

Gato GraphQL затем автоматически вычислит значение max-age ответа и отправит его обратно в виде HTTP-заголовка Cache-Control.