Arquitectura
ArquitecturaPipeline de directivas

Pipeline de directivas

Las directivas se colocan en un pipeline y se ejecutan en orden. Su diseño inicial es sencillo, así:

Pipeline de directivas

En esta arquitectura:

  • La entrada del pipeline es el valor del campo proporcionado por el resolver de campo
  • Cada directiva ejecuta su lógica y pasa el resultado a la siguiente directiva del pipeline
  • La salida del pipeline será el valor del campo resuelto, tras haber sido procesado por todas las directivas

Sin embargo, esta arquitectura no aprovecha al máximo GraphQL. A continuación se describen todas las etapas del pipeline de directivas real, hasta llegar al diseño realmente implementado en Gato GraphQL.

Las directivas como bloques de construcción de la resolución de la consulta

Inicialmente podríamos plantearnos que el servidor GraphQL resolviera el campo mediante algún mecanismo y, después, pasara este valor como entrada al pipeline de directivas.

Sin embargo, es mucho más sencillo disponer de un único mecanismo que gestione todo: invocar a los resolvers de campo (tanto para validar campos como para resolverlos) puede hacerse ya a través del pipeline de directivas. En este caso, el pipeline de directivas es el único mecanismo utilizado para resolver la consulta.

Por esta razón, el servidor Gato GraphQL incorpora dos directivas especiales:

  • @validate llama al resolver de campo para validar que el campo se puede resolver (por ejemplo: que la sintaxis es correcta, que el campo existe, etc.)
  • Si la validación tiene éxito, @resolveValueAndMerge llama entonces al resolver de campo para resolver el campo, y fusiona el valor en el objeto de respuesta

Estas dos son del tipo especial de directivas "de sistema": están reservadas exclusivamente al motor de GraphQL y son implícitas en cada campo. (Por el contrario, las directivas estándar son explícitas: las añade el usuario a la consulta.)

Utilizando estas dos directivas, esta consulta:

query {
  field1
  field2 @directiveA
}

...se resolverá como esta otra:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge @directiveA
}

El pipeline tiene ahora este aspecto (ten en cuenta que el pipeline recibe el campo como entrada, no su valor resuelto inicial):

Pipeline de directivas con @validate y @resolveValueAndMerge

Slots del pipeline

Las directivas normalmente se ejecutan después de @resolveValueAndMerge, ya que lo más probable es que impliquen actualizar el valor del campo resuelto. Sin embargo, hay otras directivas que deben ejecutarse antes de @validate, o entre @validate y @resolveValueAndMerge.

Por ejemplo:

  • Para medir el tiempo que se tarda en resolver un campo, la directiva @traceExecutionTime puede obtener la hora actual antes y después de resolver el campo, colocando las subdirectivas @startTracingExecutionTime al principio y @endTracingExecutionTime al final del pipeline
  • Una directiva @cache debe comprobar si un campo solicitado está en caché y devolver ya esa respuesta, antes de ejecutar @resolveValueAndMerge

El pipeline ofrecerá entonces cinco slots distintos a través de la clase PipelinePositions, y la directiva indicará en cuál de ellos debe ejecutarse:

  • El slot "beginning": al principio de todo
  • El slot "before-validate": antes de que tenga lugar la validación
  • El slot "middle": tras la validación y antes de la resolución del campo
  • El slot "after-resolve": tras la resolución del campo
  • El slot "end": al final de todo

El pipeline de directivas tiene ahora este aspecto (considerando solo 3 etapas, por simplificar):

Pipeline de directivas con slots

Fíjate en cómo las directivas @skip e @include pueden satisfacerse tan fácilmente con esta arquitectura: situadas en el slot "middle", pueden informar a la directiva @resolveValueAndMerge (junto con todas las directivas en etapas posteriores del pipeline) para que no se ejecuten estableciendo el flag skipExecution a true.

Directiva @skip en el pipeline

Ejecutar la directiva sobre varios campos en una sola llamada

Hasta ahora hemos considerado un único campo como entrada del pipeline de directivas. Sin embargo, en una consulta GraphQL típica recibiremos varios campos sobre los que ejecutar directivas.

Por ejemplo, en la consulta de abajo, la directiva @upperCase se ejecuta sobre los campos "field1" y "field2":

query {
  field1 @upperCase
  field2 @upperCase
  field3
}

Además, dado que el motor de GraphQL añade las directivas de sistema @validate y @resolveValueAndMerge a cada campo de la consulta, de modo que esta consulta:

query {
  field1
  field2
  field3
}

...se resuelve como esta otra:

query {
  field1 @validate @resolveValueAndMerge
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

Entonces las directivas de sistema siempre recibirán todos los campos como entradas.

Como consecuencia, el pipeline de directivas está diseñado para recibir varios campos como entrada, y no solo uno cada vez:

Recibiendo varios campos como entrada en el pipeline de directivas

Esta arquitectura es más eficiente, porque ejecutar una directiva una sola vez para todos los campos es más rápido que ejecutarla una vez por cada campo, y producirá los mismos resultados.

Por ejemplo, al validar si el usuario ha iniciado sesión para concederle acceso al esquema, la operación puede ejecutarse una única vez. Ejecutar el siguiente código:

if (isUserLoggedIn()) {
  resolveFields([$field1, $field2, $field3]);
}

es más eficiente que ejecutar este otro código:

if (isUserLoggedIn()) {
  resolveField($field1);
}
if (isUserLoggedIn()) {
  resolveField($field2);
}
if (isUserLoggedIn()) {
  resolveField($field3);
}

Esto puede no parecer gran cosa al llamar a una función local como isUserLoggedIn, pero puede marcar una gran diferencia al interactuar con servicios externos, como al resolver endpoints REST a través de GraphQL. En estos casos, ejecutar una función una sola vez en lugar de varias podría marcar la diferencia entre poder ofrecer una determinada funcionalidad o no.

Veamos un ejemplo. Al interactuar con Google Translate a través de una directiva @translate, la API GraphQL debe establecer una conexión por la red. Ejecutar este código será todo lo rápido que se pueda:

googleTranslateFields([$field1, $field2, $field3]);

Por el contrario, ejecutar la función por separado, varias veces, producirá una latencia mayor que se traducirá en un tiempo de respuesta más alto, degradando el rendimiento de la API. Posiblemente esto no sea una gran diferencia para traducir 3 cadenas (donde el campo es la cadena a traducir), pero para 100 o más cadenas sin duda tendrá un impacto:

googleTranslateField($field1);
googleTranslateField($field2);
googleTranslateField($field3);

Además, ejecutar una función una sola vez con todas las entradas puede producir una respuesta mejor que ejecutar la función sobre cada campo de forma independiente. Volviendo al ejemplo de Google Translate, la traducción será más precisa cuantos más datos proporcionemos al servicio.

Por ejemplo, al ejecutar el código de abajo:

googleTranslate("fork");
googleTranslate("road");
googleTranslate("sign");

En la primera ejecución independiente, Google no conoce el contexto de "fork", por lo que podría responder con "fork" como utensilio para comer, como bifurcación de una carretera, o con otro significado distinto. Sin embargo, si en su lugar ejecutamos:

googleTranslate(["fork", "road", "sign"]);

A partir de esta cantidad mayor de información, Google puede deducir que "fork" se refiere a la bifurcación de la carretera, y devolver una traducción precisa.

Es por estas razones por las que las directivas del pipeline reciben los campos de entrada todos juntos, y luego cada directiva puede decidir la mejor manera de ejecutar su lógica sobre esas entradas (una ejecución por cada entrada, una única ejecución que abarque todas las entradas, o cualquier opción intermedia).

El pipeline tiene ahora este aspecto:

Recibiendo varios campos como entrada en el pipeline de directivas

Ejecutar un único pipeline de directivas para toda la consulta

Hace un momento hemos visto que tiene sentido ejecutar varios campos por directiva; sin embargo, esto funciona bien siempre y cuando todos los campos tengan las mismas directivas aplicadas. Cuando las directivas son distintas, puede dar lugar a una mayor complejidad que dificulte su implementación y reduzca algunas de las ventajas obtenidas.

Veamos cómo ocurre esto. Consideremos la siguiente consulta:

query {
  field1 @directiveA
  field2
  field3
}

Esta directiva es equivalente a esta otra:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge
  field3 @validate @resolveValueAndMerge
}

En este escenario, los campos field2 y field3 tienen el mismo conjunto de directivas, y field1 tiene uno diferente; tendríamos entonces que generar 2 pipelines distintos para resolver la consulta:

La consulta requiere 2 pipelines de directivas para resolverse

Y cuando todos los campos tienen un conjunto único de directivas, el efecto es aún más pronunciado. Consideremos esta consulta:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

Que es equivalente a esta:

query {
  field1 @validate @resolveValueAndMerge @directiveA
  field2 @validate @resolveValueAndMerge @directiveB @directiveC
  field3 @validate @resolveValueAndMerge @directiveC
}

En esta situación tendremos 3 pipelines para gestionar 3 campos, así:

La consulta requiere 3 pipelines de directivas para resolverse

En este caso, aunque las directivas @validate y @resolveValueAndMerge se aplican a los 3 campos, como se ejecutan a través de 3 pipelines de directivas distintos, lo harán de forma independiente unas de otras, lo que nos lleva de nuevo a tener una directiva ejecutándose sobre un único elemento cada vez.

La solución a este problema es evitar producir varios pipelines y trabajar con un único pipeline para todos los campos. Como consecuencia, el motor ya no pasa los campos como entrada al pipeline, ya que no todas las directivas de un único pipeline interactuarán con el mismo conjunto de campos; en su lugar, cada directiva debe recibir su propia lista de campos como entrada propia.

Entonces, para esta consulta:

query {
  field1 @directiveA
  field2
  field3
}

...las directivas @validate y @resolveValueAndMerge recibirán los 3 campos como entradas, y directiveA solo recibirá "field1":

Pipeline único de directivas para resolver todos los campos

Y para esta consulta:

query {
  field1 @directiveA
  field2 @directiveB @directiveC
  field3 @directiveC
}

...las directivas @validate y @resolveValueAndMerge recibirán los 3 campos como entradas, directiveA solo recibirá "field1", directiveB solo recibirá "field2", y directiveC recibirá "field2" y "field3":

Pipeline único de directivas para resolver todos los campos

Controlando la ejecución de la directiva ID por ID

Hasta ahora, una directiva en alguna etapa podía influir en la ejecución de las directivas en etapas posteriores mediante algún flag skipExecution. Sin embargo, este flag no es lo suficientemente granular para todos los casos.

Por ejemplo, consideremos una directiva @cache, situada en el slot "end" para almacenar el valor del campo, de modo que la próxima vez que se consulte el campo, su valor pueda recuperarse de la caché mediante una directiva @getCache situada en el slot "middle":

Pipeline con las directivas @getCache y @cache

Al ejecutar esta consulta:

{
  posts(pagination: { limit: 2 }) {
    title @translate @cache
  }
}

El servidor recuperará y almacenará en caché 2 registros. Después, ejecutamos la misma consulta, pero aplicada a 4 registros:

{
  posts(pagination: { limit: 4 }) {
    title @translate @cache
  }
}

Al ejecutar esta 2ª consulta, los 2 registros de la 1ª consulta ya estaban en caché, pero los otros 2 no. Sin embargo, necesitaríamos que los 4 registros estuvieran ya en caché para poder usar el flag skipExecution. Sería mejor si pudiéramos recuperar los 2 primeros registros de la caché y resolver únicamente los otros 2 registros.

Por eso actualizamos de nuevo el diseño del pipeline. Descartamos el flag skipExecution y, en su lugar, pasamos a cada directiva la lista de IDs de objetos por campo sobre los que debe aplicarse la directiva, mediante un objeto de entrada fieldIDs:

{
  field1: [ID11, ID12, ...],
  field2: [ID21, ID22, ...],
  ...
  fieldN: [IDN1, IDN2, ...],
}

La variable fieldIDs es única para cada directiva, y cada directiva puede modificar la instancia de fieldIDs para todas las directivas en etapas posteriores. Así, skipExecution puede hacerse de forma granular, ID por ID, simplemente eliminando el ID de fieldIDs para todas las directivas posteriores de la pila.

El pipeline tiene ahora este aspecto:

Pasando los IDs por campo a cada directiva

Aplicado al ejemplo anterior, al ejecutar la primera consulta traduciendo 2 registros, el pipeline tiene este aspecto:

Pasando los IDs por campo a cada directiva para la 1ª consulta

Al ejecutar la segunda consulta traduciendo 4 registros, la directiva @getCache recibe los IDs de los 4 registros, pero tanto @resolveValueAndMerge como @cache recibirán solo los IDs de los 2 últimos registros (que no están en caché):

Pasando los IDs por campo a cada directiva para la 2ª consulta

Atando todos los cabos

Este es el diseño final del pipeline de directivas:

Diseño final del pipeline de directivas

En resumen, estas son sus características:

  • Los resolvers de campo se invocan desde el propio pipeline de directivas, a través de las directivas @validate y @resolveValueAndMerge
  • Las directivas pueden colocarse en cualquiera de los 5 slots: "beginning", "before-validate", "middle", "after-validate" y "end"
  • Las directivas resuelven varios campos en una única llamada
  • Un único pipeline contiene todas las directivas involucradas en la consulta
  • Cada directiva recibe su propio conjunto de IDs por campo a resolver mediante la variable fieldIDs
  • Las directivas pueden modificar la variable fieldIDs para todas las directivas en una etapa posterior del pipeline