GraphQL com Apollo Server
O GraphQL é uma especificação e linguagem de query de dados criada pelo Facebook em 2012 e disponibilizado para o público em 2015, com a ideia de aumentar a produtividade no desenvolvimento e minimizar o tráfego de informações.
Diferente do REST, um serviço GraphQL possui somente um Endpoint (geralmente “/graphql”) e todas as consultas são realizadas efetuando um POST neste endpoint.
GraphQL vs. REST
Mas por que usar GraphQL, quando já temos o REST? Vamos considerar a API abaixo como um exemplo:
Com essa API, imagine que algum cliente necessite fazer uma tela com uma lista de todos os alunos juntamente com o curso e a escola. Nossa API permite resgatar essas informações chamando o Endpoint de listagem de alunos e, em seguida, o Endpoint de curso por ID e escola por ID para cada um dos alunos recebidos.
Agora, imagine que temos vários clientes diferentes (Web, Mobile, etc…), cada um com uma necessidade diferente, tanto de informações relacionadas a uma entidade ou mesmo de propriedades de uma entidade (se houver a necessidade, por exemplo, de montar uma lista com somente o nome da escola, não precisaríamos retornar às outras propriedades). Isto é um dos “problemas” que o GraphQL tenta resolver.
O projeto
Vamos construir uma API GraphQL em Node.Js, que utilizará uma API REST (API do exemplo acima, disponível para download no final do artigo) como sua base de informação. Podemos utilizar o GraphQL mesmo já existindo uma infraestrutura de API’s REST pronta. Será preciso o Node.Js e o npm instalados.
Apollo Server
Sendo uma especificação, o GraphQL possui diversas implementações em diversas linguagens. Em nosso exemplo, utilizaremos o Apollo, que é uma das implementações mais conhecidas do GraphQL.
Vamos iniciar criando nosso projeto executando o comando no SHELL:
npm init
Complete as informações necessárias e em seguida execute:
npm i graphql apollo-server-express graphql-import graphql-tools graphql-voyager body-parser-graphql dataloader axios
Veremos melhor a utilidade de cada uma das libs depois. Essencialmente, ao desenvolver um serviço GraphQL, precisamos entender dois conceitos: Schemas e Resolvers.
Schemas
Schema é onde as funcionalidades da sua API são descritas. Contém os tipos e propriedades destes tipos. Pense em algo como um Modelo Relacional de um banco de dados ou ORM. Olhando para a nossa API REST, os tipos serão Aluno, Escola e Curso.
Além destes tipos específicos de nossa implementação, o GraphQL possui dois tipos especiais que servem como “entrypoint” de nossa API. Estes tipos são o Query e o Mutation.
O tipo Query é o tipo que conterá os métodos de pesquisa da API e o tipo Mutation para alteração de dados. Toda API GraphQL deve conter o tipo Query, mas o tipo Mutation é opcional.
Vamos criar um arquivo chamado schemas/schema.graphql e vamos começar a configurar nosso Schema.
type Aluno {
id: Int,
nome: String,
email: String,
telefone: String,
ativo: Boolean,
escola: Escola,
curso: Curso
}
type Curso {
id: Int,
nome: String
}
type Escola {
id: Int,
nome: String,
telefone: String,
ativo: Boolean,
cursos: [Curso]
}
Não foge muito de declarações de entidades para utilizar em um ORM. Declaramos um tipo e listamos todas as propriedades e seus respectivos tipos. Para tipos escalares, o GraphQL já disponibiliza alguns de imediato: Int, Float, String, Boolean e ID (Tipo identificador único), mas também possibilita a criação de novos tipos. Note que podemos criar propriedades que apontam para os tipos declarados no schema (ex. Escola e Curso no tipo Aluno).
Se olharmos para o tipo Escola, veja que declaramos a cursos como [Curso] e não somente Curso. O tipo entre “[]” quer dizer que se trata de uma lista, portanto, uma lista de Curso.
Com nossos tipos criados, precisamos criar o tipo Query, obrigatório no GraphQL. Lembre-se que o tipo Query é o entrypoint de nossa API, portanto, é por ele que serão realizadas as consultas.
type Query {
alunos: [Aluno]
aluno(id: Int): Aluno
cursos: [Curso]
curso(id: Int): Curso
escolas: [Escola]
escola(id: Int): Escola
}
Para facilitar, fizemos uma tradução de nossa api REST para GraphQL. O tipo Query disponibiliza meios de retornar uma lista da entidade (ex: alunos: [Alunos]) ou uma entidade pelo seu ID (ex: aluno (id: Int): Aluno).
Como nosso GraphQL consumirá uma Api REST, utilizaremos a lib axios para nos auxiliar neste processo. Crie um arquivo chamado httpClient.js e adicione o código a seguir:
const axios = require('axios');
const client = axios.create({
baseURL: 'http://localhost:4000',
});
client.interceptors.request.use(req => {
console.log(req.url);
return req;
});
module.exports = client;
Aqui, somente setamos o baseURL da Api que será consumida e uma função que será executada toda vez que o axios executar um request (veremos mais pra frente o porquê).
Com o Schema e o client http criados, precisamos agora de nossos Resolvers.
Resolvers
Resolvers são funções responsáveis por “resolver” um tipo do schema. É onde vai residir a lógica de consulta dos dados disponibilizados pela Api. Cada função recebe 4 parâmetros:
- Parent: objeto que contém o resultado do resolver do tipo pai;
- Args: objeto que contém os argumentos passados para a propriedade;
- Context: objeto compartilhado entre todos os resolvers em uma operação. Utilizado para compartilhar informações por request, como autenticação por exemplo;
- Info: informações sobre o estado de execução da operação.
Criaremos um novo arquivo chamado resolvers.js e criaremos os resolvers de nosso tipo Query.
const api = require('./httpClient');
module.exports = {
Query: {
alunos() {
console.log('------------------------------------------------');
return api.get('/alunos')
.then(resp => resp.data);
},
aluno(_, args) {
console.log('------------------------------------------------');
return api.get(`/alunos/${args.id}`)
.then(resp => resp.data);
},
cursos() {
console.log('------------------------------------------------');
return api.get('/cursos')
.then(resp => resp.data);
},
curso(_, args) {
console.log('------------------------------------------------');
return api.get(`/cursos/${args.id}`)
.then(resp => resp.data);
},
escolas() {
console.log('------------------------------------------------');
return api.get('/escolas')
.then(resp => resp.data);
},
escola(_, args) {
console.log('------------------------------------------------');
return api.get(`/escolas/${args.id}`)
.then(resp => resp.data);
}
}
};
A implementação é bem simples. Criamos um objeto onde temos uma propriedade com o nome do tipo em nosso schema (Query) e dentro dele declaramos as funções que devem retornar os dados, sendo que estas simplesmente executam uma chamada em nossa API REST.
Para os resolvers que necessitam de um parâmetro de entrada, para pesquisas pelo “id”, recuperamos o valor passado pelo objeto args e o nome do parâmetro (args.id).
Já temos tudo que precisamos para nossa implementação de uma API GraphQL. Vamos criar agora nosso arquivo principal que juntará tudo o que já foi criado e subirá o serviço. Crie um arquivo chamado index.js e adicione o código a seguir:
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const bodyParser = require('body-parser-graphql');
const { makeExecutableSchema } = require('graphql-tools');
const { express: voyager } = require('graphql-voyager/middleware');
const { importSchema } = require('graphql-import');
const typeDefs = importSchema(__dirname + '/schemas/schema.graphql');
const resolvers = require('./resolvers');
const app = express();
const schema = makeExecutableSchema({ typeDefs, resolvers });
const server = new ApolloServer({ schema });
app.use(bodyParser.graphql());
app.use('/voyager', voyager({ endpointUrl: '/graphql' }));
server.applyMiddleware({ app, path: '/graphql' });
const port = process.env.NODE_PORT || 5000;
app.listen({ port }, () => {
console.log(`Server running at http://localhost:${port}${server.graphqlPath}`);
});
Agora temos bastante coisa acontecendo. Vamos por partes:
const typeDefs = importSchema(__dirname + '/schemas/schema.graphql');
Como definimos nosso schema em um arquivo .graphql separado, precisamos carregá-lo de alguma maneira. Utilizamos a Lib graphql-import para isto.
const schema = makeExecutableSchema({ typeDefs, resolvers });
Aqui fazemos um “bind” entre o schema e os resolvers através do método makeExecutableSchema da Lib graphql-tools. Esta Lib nos permite criar mocks, logar erros, entre outras coisas. Neste exemplo não estamos utilizando nenhuma destas funcionalidades, mas elas podem ser bem úteis em um projeto real.
const server = new ApolloServer({ schema });
...
server.applyMiddleware({ app, path: '/graphql' });
Criamos uma instância do Apollo Server e depois registramos o express como nosso servidor HTTP. O Apollo Server é capaz de ser executado sem um servidor externo, mas aqui utilizamos o express para acrescentar algumas funcionalidades à nossa aplicação via middlewares.
app.use(bodyParser.graphql());app.use('/voyager', voyager({ endpointUrl: '/graphql' }));
Estes são os middlewares utilizados pela aplicação. O primeiro é para nossa aplicação suportar requisições com o “content-type: application/graphql”, facilitando o uso de nossa API sem depender de clients GraphQL. E o segundo middleware criará uma representação visual de nosso schema, funcionando como uma documentação de nosso serviço.
Para subir nossa aplicação, precisamos executar o seguinte comando via SHELL na pasta da API REST de exemplo e na pasta de nossa API GraphQL:
node src/index.js
GraphQL Queries
As queries de um serviço GraphQL geralmente são feitas através de uma requisição do tipo POST, informando um JSON com o seguinte formato:
{
"query": "...",
"operationName": "...",
"variables": { "myVariable": "someValue", ... }
}
Somente a propriedade query é obrigatória. Ela deve conter a consulta que será realizada pela nossa API.
Uma query GraphQL se resume em solicitar propriedades específicas de um tipo, algo como se estivéssemos declarando um objeto JavaScript. Vamos relembrar nosso tipo Query, que é o entrypoint da nossa API:
type Query {
alunos: [Aluno] aluno(id: Int): Aluno
cursos: [Curso] curso(id: Int): Curso
escolas: [Escola] escola(id: Int): Escola
}
Se quisermos uma lista dos nomes de todos os alunos, devemos usar a Query:
query {
alunos {
nome
}
}
Informar a string “query” é opcional. Então, se quisermos retornar o nome e telefone da escola de ID 1, utilizamos a query:
{
escola(id: 1) {
nome,
telefone
}
}
Lembre-se do middleware que utilizamos para aceitar requisições com o “content-type: application/graphql”? Com ele, não precisamos montar a estrutura em JSON que mencionei anteriormente, podemos simplesmente enviar a nossa query GraphQL diretamente (não se esqueça de informar o content-type).
Para facilitar a construção de nossas queries, o Apollo Server já disponibiliza automaticamente uma interface com documentação e autocomplete de nosso serviço, via uma Lib chamada GraphiQL. Experimente acessar o endpoint de nossa API via navegador.
Estas últimas queries foram simples, nosso GraphQL chamou somente um endpoint de nossa API REST.
Declaramos em nosso schema que o tipo Aluno contém uma propriedade curso do tipo Curso. E se tentássemos recuperar uma lista com os nomes de todos os alunos juntamente com o nome do curso relacionado? Vamos tentar com a query abaixo:
{
alunos {
nome,
curso {
nome
}
}
}
Não funcionou, e por um motivo bem simples. Lembra que os Resolvers são funções responsáveis por resolver um tipo do Schema? Criamos um resolver para o tipo Query, mas não criamos nenhum para o tipo Aluno. O nosso serviço sabe como buscar as informações de alunos do tipo Query mas não sabe de onde tirar as informações de curso do tipo Aluno. Na verdade, temos ainda escola em Aluno e cursos em Escola.
Vamos criar todos os resolvers que faltam agora:
const api = require('./httpClient');
module.exports = {
Query: {
alunos() {
console.log('------------------------------------------------');
return api.get('/alunos')
.then(resp => resp.data);
},
aluno(_, args) {
console.log('------------------------------------------------');
return api.get(`/alunos/${args.id}`)
.then(resp => resp.data);
},
cursos() {
console.log('------------------------------------------------');
return api.get('/cursos')
.then(resp => resp.data);
},
curso(_, args) {
console.log('------------------------------------------------');
return api.get(`/cursos/${args.id}`)
.then(resp => resp.data);
},
escolas() {
console.log('------------------------------------------------');
return api.get('/escolas')
.then(resp => resp.data);
},
escola(_, args) {
console.log('------------------------------------------------');
return api.get(`/escolas/${args.id}`)
.then(resp => resp.data);
}
},
Aluno: {
curso(parent, _) {
return api.get(`/cursos/${parent.idCurso}`)
.then(resp => resp.data);
},
escola(parent, _) {
return api.get(`/escolas/${parent.idEscola}`)
.then(resp => resp.data);
}
},
Escola: {
cursos(parent, _) {
return Promise
.all(parent.cursos.map(c => api.get(`/cursos/${c}`)))
.then(resp => resp.map(r => r.data));
}
}
};
Dois pontos de atenção aqui. O primeiro é que começamos a usar o parâmetro parent dos resolvers. Esse parâmetro, como dito anteriormente, retorna o resultado do resolver do tipo pai. Então, no resolver curso do tipo Aluno, o parâmetro parent corresponde ao objeto aluno retornado pelo resolver do tipo Query. E como este resolver retorna uma lista de alunos, o resolver curso do tipo Aluno será chamado para cada aluno da lista.
Confuso?! Agora é uma boa hora para olhar o nosso log de chamadas:
Agora talvez fique mais simples de entender. Quando fazemos a query em alunos, nosso GraphQL irá chamar a listagem de alunos da API REST (/alunos). Com esta lista de alunos, precisamos descobrir o nome do curso de cada um. Para isso, o nosso serviço chama o endpoint de curso por ID da API REST (/cursos/{id}) com o valor da propriedade idCurso de cada um dos alunos retornados na consulta anterior. O fato de serem dois endpoints diferentes é completamente transparente para quem está efetuando a query do GraphQL.
O segundo ponto é a implementação do resolver de Escola. Cada escola possui uma lista de cursos, portanto, para “resolver” cada Escola, precisamos efetuar várias chamadas ao endpoint de curso por ID. Para isso, montamos uma lista de promises e juntamos todas utilizando a função Promisse.all(). Esta função retornará somente após todas as chamadas serem realizadas.
Agora, podemos realizar queries que consultam diferentes endpoints de maneira extremamente simples!
Infelizmente, toda esta simplicidade nos traz alguns problemas. Vamos analisar nosso log de chamadas:
/alunos
/cursos/10
/escolas/6
/cursos/16
/escolas/15
/cursos/5
/escolas/16
/cursos/3
/escolas/24
/cursos/5
/escolas/14
/cursos/20
/escolas/8
/cursos/3
/escolas/24
/cursos/21
/escolas/4
/cursos/7
/escolas/21
/cursos/27
/escolas/1
/cursos/18
/escolas/11
/cursos/28
/escolas/6
/cursos/28
/escolas/6
/cursos/18
/escolas/5
/cursos/7
/escolas/2
/cursos/29
/escolas/13
/cursos/21
/escolas/18
/cursos/25
/escolas/6
/cursos/1
/escolas/6
/cursos/17
/escolas/14
/cursos/7
/escolas/13
/cursos/22
/escolas/19
/cursos/18
/escolas/11
/cursos/14
/escolas/8
/cursos/2
/escolas/4
/cursos/7
/escolas/23
/cursos/24
/escolas/21
/cursos/19
/escolas/15
/cursos/15
/escolas/12
/cursos/6
/escolas/16
/cursos/28
/escolas/6
/cursos/4
/escolas/5
/cursos/27
/escolas/20
/cursos/25
/escolas/11
/cursos/7
/escolas/13
/cursos/29
/escolas/1
/cursos/22
/escolas/24
/cursos/25
/escolas/19
/cursos/30
/escolas/1
/cursos/21
/escolas/10
/cursos/3
/escolas/22
/cursos/23
/escolas/14
/cursos/27
/escolas/2
/cursos/10
/escolas/17
/cursos/15
/escolas/12
/cursos/19
/escolas/18
/cursos/15
/escolas/12
/cursos/25
/escolas/22
/cursos/1
/escolas/16
/cursos/13
/escolas/20
/cursos/14
/escolas/13
/cursos/19
/escolas/4
/cursos/8
/escolas/9
/cursos/5
/escolas/23
/cursos/24
/escolas/7
/cursos/15
/escolas/23
/cursos/11
/escolas/12
/cursos/14
/escolas/13
/cursos/29
/escolas/22
/cursos/23
/escolas/19
/cursos/1
/escolas/11
/cursos/27
/escolas/20
/cursos/9
/escolas/19
/cursos/5
/escolas/8
/cursos/7
/escolas/21
/cursos/9
/escolas/24
/cursos/25
/escolas/14
/cursos/9
/escolas/24
/cursos/11
/escolas/23
/cursos/11
/escolas/21
/cursos/15
/escolas/10
/cursos/19
/escolas/2
/cursos/12
/escolas/18
/cursos/20
/escolas/19
/cursos/15
/escolas/3
/cursos/3
/escolas/7
/cursos/13
/escolas/14
/cursos/13
/escolas/23
/cursos/2
/escolas/2
/cursos/7
/escolas/2
/cursos/27
/escolas/7
/cursos/2
/escolas/4
/cursos/11
/escolas/7
/cursos/4
/escolas/20
/cursos/22
/escolas/24
/cursos/1
/escolas/5
/cursos/3
/escolas/7
/cursos/7
/escolas/3
/cursos/1
/escolas/17
/cursos/16
/escolas/24
/cursos/5
/escolas/19
/cursos/17
/escolas/13
/cursos/3
/escolas/12
/cursos/14
/escolas/4
/cursos/5
/escolas/21
/cursos/24
/escolas/21
/cursos/5
/escolas/8
/cursos/8
/escolas/14
/cursos/11
/escolas/18
/cursos/18
/escolas/9
Somente com esta query simples, nossa API GraphQL precisou realizar 202 chamadas para a API REST. E isso não é nada bom…
Felizmente, temos uma maneira de mitigar este problema.
DataLoader
O DataLoader é uma Lib criada e mantida pela GraphQL Foundation nos ajuda com o problema acima através de carregamento batch de informações e cache.
Vamos criar um novo arquivo chamado dataLoaders.js e criar dataloaders para as chamadas aos endpoints de escola e curso por ID.
const api = require('./httpClient');
let DataLoader = require('dataloader');
module.exports = {
curso: new DataLoader(ids => {
return Promise
.all(ids.map(id => api.get(`/cursos/${id}`)))
.then(resp => resp.map(r => r.data));
}),
escola: new DataLoader(ids => {
return Promise
.all(ids.map(id => api.get(`/escolas/${id}`)))
.then(resp => resp.map(r => r.data));
})
};
Cada dataloader receberá uma lista de id’s que devem ser consultados. Estes id’s serão cacheados para que nunca nossa API pesquise um id duas vezes. Este comportamento deve ser mantido somente dentro de uma mesma request.
Devemos alterar nosso arquivo index.js para compartilhar os dataloaders entre nossos resolvers. O código final do arquivo ficará da seguinte maneira:
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const bodyParser = require('body-parser-graphql');
const { makeExecutableSchema } = require('graphql-tools');
const { express: voyager } = require('graphql-voyager/middleware');
const { importSchema } = require('graphql-import');
const typeDefs = importSchema(__dirname + '/schemas/schema.graphql');
const resolvers = require('./resolvers');
const dataLoaders = require('./dataLoaders');
const app = express();
const schema = makeExecutableSchema({ typeDefs, resolvers });
const server = new ApolloServer({
schema,
context: () => {
return {
dataLoaders
};
}
});
app.use(bodyParser.graphql());
app.use('/voyager', voyager({ endpointUrl: '/graphql' }));
server.applyMiddleware({ app, path: '/graphql' });
const port = process.env.NODE_PORT || 5000;
app.listen({ port }, () => {
console.log(`Server running at http://localhost:${port}${server.graphqlPath}`);
});
A função da propriedade “context” será executada uma vez por request à nossa API e, em nosso caso, ela somente retornará os nossos dataloaders. Agora precisamos atualizar os resolvers de Aluno e Escola para utilizar os dataloaders.
O código do arquivo resolvers.js ficará assim:
const api = require('./httpClient');
module.exports = {
Query: {
alunos() {
console.log('------------------------------------------------');
return api.get('/alunos')
.then(resp => resp.data);
},
aluno(_, args) {
console.log('------------------------------------------------');
return api.get(`/alunos/${args.id}`)
.then(resp => resp.data);
},
cursos() {
console.log('------------------------------------------------');
return api.get('/cursos')
.then(resp => resp.data);
},
curso(_, args) {
console.log('------------------------------------------------');
return api.get(`/cursos/${args.id}`)
.then(resp => resp.data);
},
escolas() {
console.log('------------------------------------------------');
return api.get('/escolas')
.then(resp => resp.data);
},
escola(_, args) {
console.log('------------------------------------------------');
return api.get(`/escolas/${args.id}`)
.then(resp => resp.data);
}
},
Aluno: {
curso(parent, _, ctx) {
return ctx.dataLoaders.curso.load(parent.idCurso);
},
escola(parent, _, ctx) {
return ctx.dataLoaders.escola.load(parent.idEscola);
}
},
Escola: {
cursos(parent, _, ctx) {
return ctx.dataLoaders.curso.loadMany(parent.cursos);
}
}
};
Os dataloaders são executados pela função “load” (ou “loadmany” caso precise repassar vários id’s de uma vez só) e informando o identificador único para pesquisa.
Se agora tentarmos executar a mesma pesquisa novamente, nosso log de chamadas será:
/alunos
/cursos/10
/cursos/16
/cursos/5
/cursos/3
/cursos/20
/cursos/21
/cursos/7
/cursos/27
/cursos/18
/cursos/28
/cursos/29
/cursos/25
/cursos/1
/cursos/17
/cursos/22
/cursos/14
/cursos/2
/cursos/24
/cursos/19
/cursos/15
/cursos/6
/cursos/4
/cursos/30
/cursos/23
/cursos/13
/cursos/8
/cursos/11
/cursos/9
/cursos/12
/escolas/6
/escolas/15
/escolas/16
/escolas/24
/escolas/14
/escolas/8
/escolas/4
/escolas/21
/escolas/1
/escolas/11
/escolas/5
/escolas/2
/escolas/13
/escolas/18
/escolas/19
/escolas/23
/escolas/12
/escolas/20
/escolas/10
/escolas/22
/escolas/17
/escolas/9
/escolas/7
/escolas/3
Foram feitas 55 chamadas, diferente das 202 de antes e, se analisarmos as chamadas, podemos ver que em nenhum momento nossa API efetuou uma mesma chamadas mais de uma vez. Ainda não é o ideal, mas sem muito esforço, conseguimos melhorar muito a performance do serviço.
Neste artigo, tentei mostrar o básico de como construir e consultar uma API, mas o GraphQL também permite queries de tipos diferentes de uma vez só:
Legal, não é?! O GraphQL ainda possui muitas outras funcionalidades que podem ajudar tanto quem está desenvolvendo a API quanto quem vai consumi-la.
Até a próxima!
Todo o projeto pode ser encontrado no Github. Consulte a Iteris para saber como podemos ajudar a elevar a performance de suas aplicações!