Recuperar datos eficientemente en GraphQL usando batching con data loaders

Escrito por el .
java planeta-codigo programacion
Comentarios

Al diferencia de una API REST donde cada recurso posee un endpoint propio en GraphQL los recursos están relacionados y forman un grafo. Por otro lado las propiedades devueltas en una consulta de GraphQL son las que se indiquen en la consulta en vez de prefijadas como en una API REST. Hay que tener en cuenta que GraphQL para recuperar las propiedades de las entidades usa un resolver y las recupera una a una, si se devuelve una lista de elementos y de cada uno de esos elementos otra propiedad para la que hay que generar una consulta adicional a la base de datos el rendimiento no será bueno. Los data loaders permiten recuperar las propiedades relacionadas de una colección de entidades eficientemente evitando el problema 1+N.

GraphQL
Java

Una de las dificultades a resolver en GraphQL es evitar los problemas de generar 1+N consultas dado que en algunas peticiones se recupera una lista de elementos para recuperar alguna otra propiedad de esos elementos para la que se realiza otra consulta. Suele ocurrir al navegar las relaciones de las entidades, por ejemplo al solicitar una lista de libros y de cada libro obtener su autor, para obtener los libros se necesita una consulta y hay que evitar que para recuperar el autor de cada libro generar otra consulta, si el número de libros recuperados es grande el número de consultas será grande y la consulta será poco eficiente, lenta y generará una carga a evitar en el servidor de base de datos.

En el artículo Recuperar datos eficientemente en GraphQL usando batching comentaba una estrategia para evitar este problema que consistía en dados una serie de elementos recuperados y si la propiedad estaba presente en la consulta se obtenían los identificativos de esos elementos y se recuperaba la propiedad para todos los elementos en una única consulta.

Sin embargo, GraphQL posee otra estrategia para resolver el problema de los 1+N, mediante Data Loaders. Para usar un data loader en una propiedad de un tipo hay que crear una clase que implemente la interfaz MappedBatchLoader o MappedBatchLoaderWithContext de java-loader. El método a implementar es load(Set<K>) que recibe un conjunto de instancias de las que se quiere recuperar la propiedad y devuelve un Map<K,V> cuya clave es la instancia de la colección y el valor de la propiedad recuperada.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package io.github.picodotdev.blogbitix.graphql.dataloader;

import io.github.picodotdev.blogbitix.graphql.type.Book;
import org.dataloader.BatchLoaderEnvironment;
import org.dataloader.MappedBatchLoaderWithContext;
import org.springframework.stereotype.Component;

...

@Component
public class IsbnDataLoader implements MappedBatchLoaderWithContext<Book, String> {

   public IsbnDataLoader() {
   }

   @Override
   public CompletionStage<Map<Book, String>> load(Set<Book> books, BatchLoaderEnvironment environment) {
       Map<Book, String> isbns = books.stream().collect(Collectors.toMap(
           Function.identity(),
           Book::getIsbn
       ));
       return CompletableFuture.supplyAsync(() -> isbns);
   }
}

Una vez definidos los data loaders hay que incluirlos en un registro e indicarlos en la clase del contexto de GraphQL. El método contextBuilder recibe todas las instancias de data loaders, el método dataLoaderRegistry crea el registro y finalmente se asigna el registro al contexto. Los data loaders cachean los datos de modo que si los datos no se deben compartir entre peticiones hay que construir los data loaders en cada petición.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package io.github.picodotdev.blogbitix.graphql;

...

@SpringBootApplication
public class Main {

    ...

    @Bean
    public GraphQLContextBuilder contextBuilder(List<MappedBatchLoaderWithContext<?, ?>> mappedBatchLoaders) {
        return new GraphQLContextBuilder() {
            @Override
            public GraphQLContext build(HttpServletRequest request, HttpServletResponse response) {
                graphql.GraphQLContext data = graphql.GraphQLContext.newContext().build();
                GraphQLContext context = new DefaultGraphQLContext(data, request, response);
                context.setDataLoaderRegistry(buildDataLoaderRegistry(mappedBatchLoaders, context));
                return context;
            }

            @Override
            public GraphQLContext build(Session session, HandshakeRequest request) {
                graphql.GraphQLContext data = graphql.GraphQLContext.newContext().build();
                GraphQLContext context = new DefaultGraphQLContext(data, session, request);
                context.setDataLoaderRegistry(buildDataLoaderRegistry(mappedBatchLoaders, context));
                return context;
            }

            @Override
            public GraphQLContext build() {
                graphql.GraphQLContext data = graphql.GraphQLContext.newContext().build();
                GraphQLContext context = new DefaultGraphQLContext(data);
                context.setDataLoaderRegistry(buildDataLoaderRegistry(mappedBatchLoaders, context));
                return context;
            }
        };
    }

    private DataLoaderRegistry buildDataLoaderRegistry(List<MappedBatchLoaderWithContext<?, ?>> mappedBatchLoaders, GraphQLContext context) {
        DataLoaderRegistry registry = new DataLoaderRegistry();
        for (MappedBatchLoaderWithContext<?, ?> loader : mappedBatchLoaders) {
            registry.register(loader.getClass().getSimpleName(),
                DataLoader.newMappedDataLoader(
                    loader,
                    DataLoaderOptions.newOptions().setBatchLoaderContextProvider(() -> context)
                )
            );
        }
        return registry;
    }

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

Una vez creados los data loaders hay que usarlos en los resolver de las propiedades de una entidad en la que se desee que se cargue de forma batched. El método de la propiedad del resolver debe devolver un CompletableFuture, el método recibe la instancia de la que se quiere recuperar una propiedad y una referencia de DataFetchingEnvironment de la librería graphql-java, se recupera el data loader de esa propiedad y se le indica que acumule el conjunto de instancias de las que se quiere recuperar. GraphQL en algún momento llamará al método load(Set) que recibe un conjunto de instancias para realizar la carga de todas en una única consulta.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class BookResolver implements GraphQLResolver<Book> {

    ...

    public CompletableFuture<String> getDataLoaderIsbn(Book book, DataFetchingEnvironment environment) throws InterruptedException {
        DataLoader<Book, String> dataLoader = environment.getDataLoader(IsbnDataLoader.class.getSimpleName());
        return dataLoader.load(book);
    }

    ...
}

Al obtener los datos del conjunto de libros que utilizan un batch loader se produce la siguiente salida.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ curl -XPOST -H 'Content-Type: application/json' -d '{"query":"query{books{id title dataLoaderIsbn}}"}' http://localhost:8080/graphql
{
  "data": {
    "books": [
      {
        "id": 7,
        "title": "Ojo en el cielo",
        "dataLoaderIsbn": "3a8441b1-fd9a-40d4-8765-2ccb0900a223"
      },
      {
        "id": 8,
        "title": "Muerte de la luz",
        "dataLoaderIsbn": "06f217f7-5be0-4334-b946-1b29e93387a1"
      },
      {
        "id": 9,
        "title": "El nombre de la rosa",
        "dataLoaderIsbn": "ffc704a3-b34a-4a06-80e1-506e33d20aab"
      },
      {
        "id": 10,
        "title": "Los tejedores de cabellos",
        "dataLoaderIsbn": "21b70880-e245-405e-bffc-ab6c5975d837"
      },
      {
        "id": 11,
        "title": "Ready Player One",
        "dataLoaderIsbn": "18cd56d3-8050-4467-9fa8-fb9aeb0d1ea5"
      }
    ]
  }
}

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando el comando ./gradlew run.