Conceitos, Ideias, Estratégias
Conceitos, Ideias, EstratégiasCache control via queries persistidas

Cache control via queries persistidas

O GraphQL normalmente opera via POST, executando todas as queries contra um único endpoint e passando os parâmetros no corpo da requisição. A URL desse único endpoint produzirá respostas diferentes, o que significa que ela não pode ser armazenada em cache (pelo menos, não usando a URL como identificador).

Portanto, a forma padrão de suportar cache em GraphQL é na camada do cliente, por meio do cliente Apollo e bibliotecas similares, que armazenam em cache os objetos retornados de forma independente entre si, identificando-os pelo seu ID global único.

(Em contraste, ao armazenar em cache no servidor, normalmente usamos a URL como identificador e armazenamos em cache os dados de todas as entidades da resposta juntos.)

Mas essa solução tem várias desvantagens:

  • A aplicação passa a executar mais JavaScript no lado do cliente. Acessar o site por um celular de baixo custo resultará em perda de desempenho
  • A aplicação fica mais complexa, com mais partes móveis, pois agora também precisamos nos preocupar em implementar a camada de cache
  • Nem todos entendem JavaScript (por exemplo: o site pode ser codificado em PHP), mas agora lidar com JS também se torna uma responsabilidade

Uma solução muito melhor é usar o cache HTTP. Vamos ver as pré-condições necessárias para que isso funcione.

Acessando GraphQL via GET

Usar o cache HTTP significa que armazenaremos em cache a resposta GraphQL usando a URL como identificador. Isso tem 2 implicações:

  1. Devemos acessar o único endpoint do GraphQL via GET
  2. Devemos passar a query e as variáveis como parâmetros de URL

Então, se o único endpoint é /graphql, a operação GET pode ser executada contra a URL /graphql?query=...&variables=....

Isso se aplica à recuperação de dados do servidor (via operação query). Para modificar dados (via operação mutation), ainda devemos usar POST. Não há problema aqui, pois as mutations sempre são executadas de forma nova; não podemos armazenar em cache os resultados de uma mutation, portanto não usaríamos o cache HTTP com ela de qualquer forma.

Essa abordagem funciona (e até é sugerida no site oficial), mas há certas considerações às quais devemos prestar atenção.

Codificando queries GraphQL via parâmetro de URL

Uma query GraphQL normalmente abrange múltiplas linhas. Por exemplo:

{
  posts {
    id
    title
  }
}

No entanto, não podemos inserir essa string de múltiplas linhas diretamente no parâmetro de URL.

A solução é codificá-la. Por exemplo, o cliente GraphiQL codificará a query acima assim:

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

Certo, isso funciona. Mas não parece muito bom, certo? Quem consegue entender essa query?

Uma das virtudes do GraphQL é que suas queries são muito fáceis de entender. Com alguma prática, assim que vemos a query, a compreendemos imediatamente. Mas depois de codificada, tudo isso desaparece, e apenas as máquinas conseguem entendê-la; o ser humano fica fora da equação.

Outra solução seria substituir todas as quebras de linha da query por um espaço, o que funciona porque quebras de linha não adicionam nenhum significado semântico à query. Assim, a query acima pode ser representada como:

?query={ posts { id title } }

Isso funciona bem para queries simples. Mas se você tiver uma query muito longa, com muitos { } abertos e fechados, e com argumentos de campos e directives, fica cada vez mais difícil de entender.

Por exemplo, esta 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
    }
  }
}

Se tornaria esta query em uma única linha:

{ 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 } } } 

Mais uma vez, executar a query funcionará, mas não saberemos o que estamos executando.

E se a query também contiver fragments, então esqueça completamente — não há como entendê-la.

As queries persistidas vêm ao resgate

Se passar a query na URL não é satisfatório, que outra opção temos? Bem, não passar a query na URL!

Essa é a abordagem chamada de "persisted query": armazenamos a query no servidor e usamos um identificador (como um ID numérico, ou uma string única produzida aplicando um algoritmo de hashing com a query como entrada) para recuperá-la. Por fim, passamos esse identificador como parâmetro de URL, em vez da query.

Por exemplo, a query poderia ser identificada pelo ID 2908 (ou um hash como "50ac3e81"), e então executamos a operação GET contra a URL /graphql?id=2908. O servidor GraphQL recuperará então a query correspondente a esse ID, irá executá-la e retornará os resultados.

O Gato GraphQL torna tudo isso ainda mais simples: uma persisted query é implementada como um tipo de post personalizado, portanto podemos criar uma e publicá-la como qualquer post comum, e o slug que escolhermos (que por padrão é baseado no título que inserimos) se tornará seu identificador. As queries persistidas tornam a implementação do cache HTTP trivial.

Calculando o valor max-age

O cache HTTP funciona enviando o header Cache-Control na resposta, com um valor max-age indicando por quanto tempo a resposta deve ser armazenada em cache, ou no-store indicando para não armazená-la em cache.

Como o servidor GraphQL calculará o valor max-age para a query, considerando que campos diferentes podem ter valores max-age diferentes?

A resposta é: obter o valor max-age de todos os campos solicitados na query e descobrir qual é o menor. Esse será o max-age da resposta.

Por exemplo, digamos que temos uma entidade do tipo User. Seguindo o comportamento atribuído a essa entidade, podemos definir por quanto tempo o campo correspondente pode ser armazenado em cache:

🛠 Seu ID nunca mudará ⇒ Damos ao campo id um max-age de 1 ano

🛠 Sua URL será atualizada muito raramente (se é que alguma vez será) ⇒ Damos ao campo url um max-age de 1 dia

🛠 O nome da pessoa pode mudar de tempos em tempos (por exemplo: para adicionar um status, ou para dizer "Milton (usa máscara)") ⇒ Damos ao campo name um max-age de 1 hora

🛠 O karma do usuário no site pode mudar a qualquer momento (por exemplo: depois que alguém vota positivamente no seu comentário) ⇒ Damos ao campo karma um max-age de 1 minuto

🛠 Se estamos consultando os dados do usuário logado, então a resposta não pode ser armazenada em cache de forma alguma (independentemente de qual campo estamos buscando) ⇒ O max-age deve ser no-store

Como resultado, a resposta às seguintes queries GraphQL terá os seguintes valores max-age (neste exemplo, ignoramos o max-age para o campo Root.users, mas na prática ele também será levado em conta):

QueryValor max-age
{
  users {
    id
  }
}
1 ano
{
  users {
    id
    url
  }
}
1 dia
{
  users {
    id
    url
    name
  }
}
1 hora
{
  users {
    id
    url
    name
    karma
  }
}
1 minuto
{
  me {
    id
    url
    name
    karma
  }
}
no-store (não armazenar em cache)

Criando a Cache Control List

Depois de identificarmos o max-age para cada campo, inserimos essas informações por meio de uma Cache Control List:

Definindo uma política de cache control

O Gato GraphQL então calculará automaticamente o valor max-age da resposta e o enviará de volta como o header HTTP Cache-Control.