Trabajar con datos al construir una API basada en GraphQL

Preámbulo


En primer lugar, este artículo está diseñado para aquellos lectores que ya están familiarizados con GraphQL y más sobre las complejidades y los matices de trabajar con él. Sin embargo, espero que sea útil para los principiantes.


GraphQL es una gran herramienta. Creo que muchas personas ya conocen y entienden sus ventajas. Sin embargo, hay algunos matices a tener en cuenta cuando crea sus API basadas en GraphQL.


Por ejemplo, GraphQL le permite regresar al consumidor (usuario o programa) solicitando los datos solo de la parte de la cual este consumidor está interesado. Sin embargo, al construir un servidor, es bastante fácil cometer un error, lo que lleva al hecho de que dentro del servidor (que puede ser, entre otras cosas, distribuido), los datos se ejecutarán en paquetes completos. Esto se debe principalmente al hecho de que GraphQL no proporciona herramientas convenientes para analizar una consulta entrante, y las interfaces que se encuentran en ella no están bien documentadas.


Fuente del problema


Veamos un ejemplo típico de una implementación no óptima (abra la imagen en una ventana separada si no se lee bien):


imagen


Supongamos que nuestro consumidor es una determinada aplicación o componente de la "guía telefónica" que solicita a nuestra API solo el identificador, el nombre y el número de teléfono de los usuarios almacenados por nosotros. Al mismo tiempo, nuestra API es mucho más extensa, permitirá el acceso a otros datos, como la dirección física de la residencia y la dirección de correo electrónico de los usuarios.


En el punto de intercambio de datos entre el consumidor y la API, GraphQL hace a la perfección todo el trabajo que necesitamos: solo se enviarán los datos solicitados en respuesta a la solicitud. El problema en este caso está en el punto de muestreo de datos de la base de datos, es decir en la implementación interna de nuestro servidor, y consiste en el hecho de que para cada solicitud entrante seleccionamos todos los datos de usuario de la base de datos, a pesar de que no necesitamos algunos de ellos. Esto genera una carga excesiva en la base de datos y conduce a la circulación de tráfico excesivo dentro del sistema. Con un número significativo de consultas, puede obtener una optimización significativa cambiando el enfoque del muestreo de datos y seleccionando solo los campos que se solicitaron. Al mismo tiempo, no importa en absoluto cuál es nuestra fuente de datos: una base de datos relacional, tecnología NoSQL u otro servicio (interno o externo). Cualquier comportamiento no óptimo puede verse afectado por cualquier implementación. MySQL en este caso se selecciona simplemente como un ejemplo.


Solución


Es posible optimizar este comportamiento del servidor si analizamos los argumentos que vienen a la función resolve() :


 async resolve(source, args, context, info) { // ... } 

Es el último argumento, la info , que es de particular interés para nosotros, en este caso. Pasamos a la documentación y analizamos en detalle en qué consiste la función resolve() y el argumento que nos interesa:


 type GraphQLFieldResolveFn = ( source?: any, args?: {[argName: string]: any}, context?: any, info?: GraphQLResolveInfo ) => any type GraphQLResolveInfo = { fieldName: string, fieldNodes: Array<Field>, returnType: GraphQLOutputType, parentType: GraphQLCompositeType, schema: GraphQLSchema, fragments: { [fragmentName: string]: FragmentDefinition }, rootValue: any, operation: OperationDefinition, variableValues: { [variableName: string]: any }, } 

Por lo tanto, los primeros tres argumentos pasados ​​al solucionador son source : los datos pasados ​​desde el nodo principal en el árbol GraphQL del esquema, args , los argumentos de solicitud (que provienen de la consulta) y el context , el objeto de contexto de ejecución definido por el desarrollador, a menudo llamado para transmitir algunos datos globales en los "resolvers". Y finalmente, el cuarto argumento es la metainformación sobre la solicitud.


¿Qué podemos extraer de GraphQLResolveInfo para resolver nuestro problema?


Sus partes más interesantes son:


  • fieldName es el nombre de campo actual de su esquema GraphQL. Es decir corresponde al nombre del campo que se especifica en el esquema para este solucionador. Si detectamos el objeto de info en el campo de users , como en nuestro ejemplo anterior, entonces serán "usuarios" los que estarán contenidos como el valor de fieldName
  • fieldNodes : colección (matriz) de nodos que se SOLICITARON en la consulta. Justo lo que se requiere!
  • fragments : una colección de fragmentos de la solicitud (en caso de que la solicitud haya sido fragmentada). También información importante para recuperar los campos de datos finales.

Entonces, como solución, necesitamos analizar la herramienta de info y seleccionar la lista de campos que nos llegó de la consulta, y luego pasarlos a la consulta SQL. Desafortunadamente, el paquete GraphQL de Facebook "listo para usar" no nos da nada para simplificar esta tarea. En general, como lo ha demostrado la práctica, esta tarea no es tan trivial, dado que las solicitudes pueden fragmentarse. Y además, dicho análisis tiene una solución universal, que posteriormente simplemente se copia de un proyecto a otro.


Así que decidí escribirlo como una biblioteca de código abierto bajo la licencia ISC . Con su ayuda, la solución para analizar los campos de consulta entrantes se resuelve de manera bastante simple, por ejemplo, en nuestro caso de esta manera:


 const { fieldsList } = require('graphql-fields-list'); // ... async resolve(source, args, context, info) { const requestedFields = fieldsList(info); return await database.query(`SELECT ${requestedFields.join(',')} FROM users`) } 

fieldsList(info) en este caso hace todo el trabajo por nosotros y devuelve una matriz "plana" de campos secundarios para esta resolución, es decir nuestra consulta SQL final se verá así:


 SELECT id, name, phone FROM users; 

Si cambiamos la solicitud entrante a:


 query UserListQuery { users { id name phone email } } 

entonces la consulta SQL se convertirá en:


 SELECT id, name, phone, email FROM users; 

Sin embargo, no siempre es posible hacer frente a un desafío tan simple. A menudo, las aplicaciones reales son mucho más complejas en estructura. En algunas implementaciones, es posible que necesitemos describir el resolutor en el nivel superior con respecto a los datos en el esquema GraphQL final. Por ejemplo, si decidimos usar la biblioteca Relay , nos gustaría usar un mecanismo listo para dividir colecciones de objetos de datos en páginas, lo que lleva al hecho de que nuestro esquema GraphQL se construirá de acuerdo con ciertas reglas. Por ejemplo, reelaboramos nuestro esquema de esta manera (TypeScript):


 import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql'; import { connectionDefinitions, connectionArgs, nodeDefinitions, fromGlobalId, globalIdField, connectionFromArray, GraphQLResolveInfo, } from 'graphql-relay'; import { fieldsList } from 'graphql-fields-list'; export const { nodeInterface, nodeField } = nodeDefinitions(async (globalId: string) => { const { type, id } = fromGlobalId(globalId); let node: any = null; if (type === 'User') { node = await database.select(`SELECT id FROM user WHERE id="${id}"`); } return node; }); const User = new GraphQLObjectType({ name: 'User', interfaces: [nodeInterface], fields: { id: globalIdField('User', (user: any) => user.id), name: { type: GraphQLString }, email: { type: GraphQLString }, phone: { type: GraphQLString }, address: { type: GraphQLString }, } }); export const { connectionType: userConnection } = connectionDefinitions({ nodeType: User }); const Query = new GraphQLObjectType({ name: 'Query', fields: { node: nodeField, users: { type: userConnection, args: { ...connectionArgs }, async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { // TODO: implement }, }, }); export const schema = new GraphQLSchema({ query: Query }); 

En este caso, connectionDefinition from Relay agregará edges , node , pageInfo y nodos de cursor al esquema, es decir ahora necesitaremos reconstruir nuestras consultas de manera diferente (no nos detendremos en la paginación ahora):


 query UserListQuery { users { edges { node { id name phone email } } } } 

Por lo tanto, resolve() función implementada en el nodo de users ahora tendrá que determinar qué campos se solicitan no para sí mismo, sino para su nodo de node hijo anidado, que, como vemos, es relativo a los users largo de la ruta edges.node .


fieldsList de la graphql-fields-list le ayudará a resolver este problema, para esto debe pasarle la opción de path correspondiente. Por ejemplo, aquí está la implementación en nuestro caso:


 async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { const fields = fieldsList(info, { path: 'edges.node' }); return connectionFromArray( await database.query(`SELECT ${fields.join(',')} FROM users`), args ); } 

También en el mundo real puede ser que en el esquema GraphQL hemos definido solo un nombre de campo, y en el esquema de la base de datos otros nombres de campo corresponden a ellos. Por ejemplo, supongamos que la tabla de usuario en la base de datos se definió de manera diferente:


 CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, fullName VARCHAR(255), email VARCHAR(255), phoneNumber VARCHAR(15), address VARCHAR(255) ); 

En este caso, los campos de la consulta GraphQL deben renombrarse antes de incrustarse en la consulta SQL. fieldsList ayudará en esto si le pasa un mapa de traducción de nombres en la opción de transform correspondiente:


 async resolve( source: any, args: {[argName: string]: any}, context: any, info: GraphQLResolveInfo, ) { const fields = fieldsList(info, { path: 'edges.node', transform: { phone: 'phoneNumber', name: 'fullName' }, }); return connectionFromArray( await database.query(`SELECT ${fields.join(',')} FROM users`), args ); } 

Y, sin embargo, a veces, la conversión a una matriz plana de campos no es suficiente (por ejemplo, si la fuente de datos devuelve una estructura compleja con anidamiento). En este caso, la función fieldsMap de la graphql-fields-list vendrá al rescate, que devuelve todo el árbol de campos solicitados como un objeto:


 const { fieldsMap } = require(`graphql-fields-list`); // ... some resolver implementation on `users`: resolve(arc, args, ctx, info) { const map = fieldsMap(info); /* RESULT: { edges: { node: { id: false, name: false, phone: false, } } } */ } 

Si suponemos que el usuario es descrito por una estructura compleja, lo obtendremos todo. Este método también puede tomar el argumento de path opcional, que le permite obtener un mapa de solo la rama necesaria de todo el árbol, por ejemplo:


 const { fieldsMap } = require(`graphql-fields-list`); // ... some resolver implementation on `users`: resolve(arc, args, ctx, info) { const map = fieldsMap(info, 'edges.node'); /* RESULT: { id: false, name: false, phone: false, } */ } 

La transformación de los nombres en las tarjetas actualmente no es compatible y queda a merced del desarrollador.


Solicitar fragmentación


GraphQL admite la fragmentación de consultas, por ejemplo, podemos esperar que el consumidor nos envíe tal solicitud (aquí nos referimos al esquema original, un poco exagerado, pero sintácticamente correcto):


 query UsersFragmentedQuery { users { id ...NamesFramgment ...ContactsFragment } } fragment NamesFragment on User { name } fragment AddressFragment on User { address } fragment ContactsFragment on User { phone email ...AddressFragment } 

No debe preocuparse en este caso, y fieldsList(info) y fieldsMap(info) en este caso devolverán el resultado esperado, ya que tienen en cuenta la posibilidad de fragmentar las solicitudes. Entonces, fieldsList(info) devolverá ['id', 'name', 'phone', 'email', 'address'] , y fieldsMap(info) , respectivamente, devolverá:


 { id: false, name: false, phone: false, email: false, address: false } 

PS


Espero que este artículo haya ayudado a arrojar luz sobre algunos de los matices de trabajar con GraphQL en el servidor, y la biblioteca graphql-fields-list puede ayudarlo a crear soluciones óptimas en el futuro.


UPD 1


Se ha lanzado la versión 1.1.0 de la biblioteca: se ha @skip compatibilidad con las @include @skip y @include en las solicitudes. Por defecto, la opción está habilitada, si es necesario, deshabilítela de esta manera:


 fieldsList(info, { withDirectives: false }) fieldsMap(info, null, false); 

Source: https://habr.com/ru/post/es427399/


All Articles