Recuperar datos eficientemente en GraphQL usando batching

Escrito por el , actualizado el .
java planeta-codigo programacion
Comentarios

GraphQL

Dada una consulta con los datos a recuperar GraphQL hace una llamada al correspondiente resolver o data fecher para obtener el valor de cada propiedad. Cuando se tratan de propiedades en un java bean esto no supone ningún problema en cuanto a rendimiento pero cuando obtener el valor de una propiedad es costoso la consulta resulta ineficiente.

Por ejemplo, siguiendo el ejemplo que he utilizado en anteriores artículos sobre GraphQL de una librería en la que hay un tipo para representar un libro con una propiedad con sus comentarios, con una consulta que permite recuperar los libros para cada uno de ellos se llama al resolver que recupera los comentarios. En este ejemplo no ya que están los datos en memoria y no se usa una base de datos pero si recuperar los comentarios de cada libro supusiera una consulta SQL en una base de datos relacional (o tráfico de red en una base de datos NoSQL u otro servicio) y la lista de libros devuelta fuese grande cada vez que se realizará esta consulta el número de sentencias SQL a ejecutar sería grande y el tiempo de respuesta pobre y con una carga mayor para el servidor de base de datos.

Para hacer eficientemente este caso en GraphQL existe la funcionalidad de batching con la que un resolver o data fecher puede recuperar los comentarios de todos los libros en una misma petición. Para esto el resolver en vez de recuperar la propiedad de cada libro individualmente se obtienen todas las propiedades en una única peticione de todos los libros para los cuales hay recuperar los comentarios.

Esta es la teoría ya que en el momento de escribir este artículo en la librería de utilidades que hace más sencillo usar GraphQL en Java se implementó una petición de mejora para añadir batching a los resolvers, en su momento se añadió la funcionalidad pero no de forma correcta como me di cuenta a escribir y probar el ejemplo de esta serie de artículos de modo que les creé esta petición para corregir el soporte de batching. Tres días depués de haber creado la petición en GitHub alguien envío un pull request pero no ha sido hasta después de casi seis meses que finalmente se ha aceptado, fusionado y publicado en la versión 5.1.0.

Se implementaba con la anotación @Batched en el método del resolver pero esta anotación ha quedado obsoleta. Como alternativa y mejor forma el resolver que recupera los libros, Query se encarga de recuperar la propiedad de todos los libros devueltos en la consulta y se proporciona al resolver de la propiedad del libro, BookResolver, a traves del contexto. A destacar que las propiedades batched solo son recuperadas si en la consulta de GraphQL se solicitan.

 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
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class Query implements GraphQLQueryResolver {

    private LibraryRepository libraryRepository;

    public Query(LibraryRepository libraryRepository) {
        this.libraryRepository = libraryRepository;
    }

    public List<Book> books(BookFilter filter, DataFetchingEnvironment environment) throws InterruptedException  {
        List<Book> books = libraryRepository.findBooks(filter);
        DefaultGraphQLContext context = environment.getContext();

        SelectedField batchedIsbn = getField(environment, "batchedIsbn");
        if (Objects.nonNull(batchedIsbn)) {
            System.out.printf("Getting %d ISBNs...", books.size());
            Thread.sleep(3000);
            System.out.printf("ok%n");
            Map<Long, String> isbns = books.stream().map(b -> b.getId()).collect(Collectors.toMap(
                Functions.identity(),
                v -> UUID.randomUUID().toString()
            ));;
            context.getData().put("batchedIsbn", isbns);
        }

        SelectedField batchedComments = getField(environment, "batchedComments");
        if (Objects.nonNull(batchedComments)) {
            Thread.sleep(3000);
            String after = (String) batchedComments.getArguments().get("after");
            Long limit = (Long) batchedComments.getArguments().get("limit");
            BookResolver resolver = new BookResolver(libraryRepository);
            Map<Long, CommentsConnection> commentsConnections = books.stream().collect(Collectors.toMap(
                k -> k.getId(),
                v -> resolver.getComments(v, after, limit)
            ));
            context.getData().put("batchedComments", commentsConnections);
        }
        return books;
    }

    ...
}
 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
package io.github.picodotdev.blogbitix.graphql.resolver;

...

public class BookResolver implements GraphQLResolver<Book> {

    private LibraryRepository libraryRespository;

    public BookResolver(LibraryRepository libraryRespository) {
        this.libraryRespository = libraryRespository;
    }

    public String getIsbn(Book book) throws InterruptedException {
        System.out.printf("Getting ISBN %d...", book.getId());
        Thread.sleep(3000);
        System.out.printf("ok%n");
        return UUID.randomUUID().toString();
    }

    public String getBatchedIsbn(Book book, DataFetchingEnvironment environment) throws InterruptedException {
        DefaultGraphQLContext context = environment.getContext();
        Map<Long, String> isbns = (Map<Long, String>) context.getData().get("batchedIsbn");
        return isbns.get(book.getId());
    }

    public CommentsConnection getComments(Book book, String after, Long limit) {
        ...
    }

    public CommentsConnection getBatchedComments(Book book, String after, Long limit, DataFetchingEnvironment environment) {
        DefaultGraphQLContext context = environment.getContext();
        Map<Long, CommentsConnection> batchedComments = (Map<Long, CommentsConnection>) context.getData().get("batchedComments");
        return batchedComments.get(book.getId());
    }
}

En la configuración de GraphQL se especifica el tipo de datos que actua como contexto, en este caso DefaultGraphQLContext.

 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
56
57
package io.github.picodotdev.blogbitix.graphql;

...

@SpringBootApplication
public class Main {

    public static final Logger logger = LoggerFactory.getLogger(Main.class);

    @Bean
    public LibraryRepository buildLibraryRepository() {
        return new LibraryRepository();
    }

    @Bean
    public GraphQLSchema graphQLSchema(LibraryRepository libraryRepository) throws IOException {
        return SchemaParser.newParser()
                .schemaString(IOUtils.resourceToString("/library.graphqls", Charset.forName("UTF-8")))
                .resolvers(new Query(libraryRepository), new Mutation(libraryRepository), new BookResolver(libraryRepository), new MagazineResolver(libraryRepository))
                .scalars(GraphQLScalarType.newScalar().name("LocalDate").description("LocalDate scalar").coercing(new LocalDateCoercing()).build())
                .dictionary(Magazine.class)
                .build()
                .makeExecutableSchema();
    }

    ...

    @Bean
    public GraphQLContextBuilder contextBuilder() {
        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);
                return context;
            }

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

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

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}
 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
package io.github.picodotdev.blogbitix.graphql.misc;

...

public class DefaultGraphQLContext extends GraphQLContext {

    private graphql.GraphQLContext data;

    public DefaultGraphQLContext(graphql.GraphQLContext data, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
        super(httpServletRequest, httpServletResponse, null, null, null);
        this.data = data;
    }

    public DefaultGraphQLContext(graphql.GraphQLContext data, Session session, HandshakeRequest handshakeRequest) {
        super(null, null, session, handshakeRequest, null);
        this.data = data;
    }

    public DefaultGraphQLContext(graphql.GraphQLContext data) {
        super(null, null, null, null, null);
        this.data = data;
    }

    public graphql.GraphQLContext getData() {
        return data;
    }
}

Con la lista completa de libros de la que hay que recuperar los comentarios ya sería posible lanzar una única consulta SQL a una base de datos relacional en vez de una por cada libro.

La consulta de GraphQL a realizar para recuperar los tres primeros comentarios de cada libro y los resultados que devuelve son los siguientes. La consulta parece un tanto compleja porque la propiedad de los comentarios implementa paginación pero básicamente se recupera de cada libro su título y los comentarios.

1
curl -XPOST -H "Content-Type: application/json" -d '{"query": "query Books{books{title batchedComments(limit:3){edges{node{text}cursor} pageInfo{startCursor endCursor hasNextPage}}}}"}' http://localhost:8080/graphql
  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
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
{
  "data": {
    "books": [
      {
        "title": "Ojo en el cielo",
        "batchedComments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      },
      {
        "title": "Muerte de la luz",
        "batchedComments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      },
      {
        "title": "El nombre de la rosa",
        "batchedComments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      },
      {
        "title": "Los tejedores de cabellos",
        "batchedComments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      },
      {
        "title": "Ready Player One",
        "batchedComments": {
          "edges": [
            {
              "node": {
                "text": "Comment 1"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox"
            },
            {
              "node": {
                "text": "Comment 2"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoy"
            },
            {
              "node": {
                "text": "Comment 3"
              },
              "cursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz"
            }
          ],
          "pageInfo": {
            "startCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDox",
            "endCursor": "aW8uZ2l0aHViLnBpY29kb3RkZXYuYmxvZ2JpdGl4LmdyYXBocWwuQ29tbWVudDoz",
            "hasNextPage": true
          }
        }
      }
    ]
  }
}

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 ./gradew run.