Por muitas vezes, fontes externas de dados podem ser o caminho crítico para o seu serviço, e é essencial assegurar-se de não o percorrer mais vezes que o necessário, uma vez que tempo e dinheiro andam lado a lado no que diz respeito ao desenvolvimento de software.
O cenário
Imagine uma aplicação utilizando GraphQL, onde é possível que um usuário poste diversas publicações, cada postagem possui o id do seu autor, e a partir desse id podemos buscar informações adicionais sobre o mesmo.
Como descrito, no resolver do atributo author seria feito o relacionamento da outra entidade.
author: {
type: User,
resolve: async (obj) => {
const author =
await prisma.$queryRaw`SELECT * FROM User WHERE id = ${obj.authorId}`;
return author ? author[0] : null;
},
},
Para acessar as postagens e o seu autor, teremos uma query como essa:
query allPosts {
getPosts {
title
author {
name
}
}
}
getPosts: {
type: new GraphQLList(Post),
resolve: async () => await prisma.post.findMany(),
},
Num cenário em que existem apenas 3 postagens, todas do mesmo autor, executando a query e registrando os logs do sistema no console, teríamos um resultado como esse:
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`published`, `main`.`Post`.`authorId` FROM `main`.`Post` WHERE 1=1 LIMIT ? OFFSET ?
prisma:query SELECT * FROM User WHERE id = ?
prisma:query SELECT * FROM User WHERE id = ?
prisma:query SELECT * FROM User WHERE id = ?
N + 1 problem
O ideal seria resolver essa query do GraphQL fazendo apenas duas consultas no banco de dados, uma para as postagens, e outra para cada um dos autores distintos. O que não é o caso aqui, apesar de termos apenas um usuário, ainda realizamos a mesma busca pelos seus dados 3 vezes.
O exemplo acima configura o n+1 problem, isto é, fazemos várias execuções desnecessárias, mesmo que os dados que desejávamos a princípio já tenham sido retornados, esse problema não é exclusivo do GraphQL, nem mesmo ocorre somente em acessos a bancos de dados.
Dataloader
Existem algumas formas de resolução para esse problema, a mais comum é utilizando o Dataloader, um utilitário criado a partir de uma das APIs internas do Facebook. Com o Dataloader, estratégias de batching e caching podem ser facilmente adicionadas à aplicação.
Analisando as consultas feitas ao banco de dados, podemos notar que o ponto de deficiência se encontra no resolver do atributo author, logo, precisamos inserir o Dataloader nessa função, substituindo a chamada direta ao banco de dados.
Para criar um loader, precisamos passar uma função que aceite um array de keys, e retorne um array de respostas com o mesmo tamanho. Podemos executar todas as consultas ao mesmo tempo através do método Promise.allSettled
, caso alguma Promise seja rejeitada, podemos substituir o valor do erro por null
, para que o array retornado seja do mesmo tamanho do array de keys fornecido.
Colocando em prática, teremos algo semelhante a isso:
const batchFn: BatchLoadFn<number, unknown> = async (keys) => {
const promises = keys.map(
(k) => prisma.$queryRaw`SELECT * FROM User WHERE id = ${k}`
);
const response = await Promise.allSettled(promises);
return response.map((r) => (r.status === "rejected" ? null : r.value));
};
export const userDataloader = new DataLoader(batchFn);
Com o loader pronto, podemos utilizá-lo no resolver, como cada postagem possui apenas um autor, utilizamos o método .load
, passando o id do autor como parâmetro.
author: {
type: User,
resolve: async (obj) => {
const author = await userDataloader.load(obj.authorId);
return author ? author[0] : null;
},
},
Assim, a primeira chamada a um determinado autor irá realizar a busca no banco de dados e adicionar estes dados ao cache, chamadas subsequentes irão consumir deste cache criado.
Executando a query novamente, com o Dataloader implementado, temos como resultado os seguintes logs:
prisma:query SELECT `main`.`Post`.`id`, `main`.`Post`.`title`, `main`.`Post`.`content`, `main`.`Post`.`published`, `main`.`Post`.`authorId` FROM `main`.`Post` WHERE 1=1 LIMIT ? OFFSET ?
prisma:query SELECT * FROM User WHERE id = ?
Fomos de 4 consultas no banco de dados para 2, imagine se o usuário tivesse dezenas ou centenas de posts, com certeza a diferença seria ainda mais significativa.
É importante destacar, que caso os dados na sua fonte sofram alguma alteração, o cache do Dataloader não terá conhecimento dessa mudança. Portanto, você precisa remover esse dado específico através do método .clear()
, e assim, chamadas futuras ao método .load()
irão atualizar o cache.
// mutation
changeOneUser: {
type: User,
args: {
id: {
type: GraphQLInt,
},
name: {
type: GraphQLString,
},
},
resolve: async (_, { id, name }) => {
const data = await prisma.user.update({
where: {
id,
},
data: {
name,
},
});
userDataloader.clear(id);
return data;
},
},
Finalizando
Apesar de não fazer parte da ideia inicial deste texto, é preciso destacar aqui o Prisma já possui uma solução para este problema através do método .findUnique
, optei por não utilizá-lo aqui para demonstrar como seria realizada uma implementação manual através da biblioteca em questão.
Outro ponto relevante a ser levantado, é de que o Dataloader pode ser usado também com outras linguagens além do Javascript, como Java, Go, Python e por aí vai.
E por fim, você pode acessar o restante do código através deste repositório: dataloader-example
Top comments (0)