Cómo hacer que GraphQL y DynamoDB funcionen bien juntos

Serverless, GraphQL y DynamoDB son una combinación poderosa para crear sitios web. Los dos primeros son muy queridos, pero DynamoDB a menudo se malinterpreta o se evita activamente. A menudo lo descartan personas que consideran que sólo vale la pena el esfuerzo “a escala”.

Esa también era mi suposición, e intenté seguir con una base de datos SQL para mis aplicaciones sin servidor. Pero después de aprender y usar DynamoDB, veo sus beneficios para proyectos de cualquier escala.

Para mostrarle lo que quiero decir, creemos una API de principio a fin, sin ningún mapeador relacional de objetos (ORM) o marco GraphQL pesado para ocultar lo que realmente está sucediendo. Quizás cuando hayamos terminado podrías considerar darle una segunda mirada a DynamoDB. Creo que vale la pena el esfuerzo.

Las principales objeciones a DynamoDB y GraphQL

La principal objeción a DynamoDB es que es difícil de aprender, pero pocas personas discuten sobre su poder. Estoy de acuerdo en que la curva de aprendizaje parece muy pronunciada. Pero las bases de datos SQL no son la mejor opción para las aplicaciones sin servidor. ¿Dónde se coloca esa base de datos SQL? ¿Cómo gestionas las conexiones con él? Estas cosas simplemente no encajan muy bien con el modelo sin servidor. DynamoDB está diseñado para ser compatible con servidores sin servidor. Estás intercambiando el dolor inicial de aprender algo difícil para salvarte de dolores futuros. Dolor futuro que sólo crece si tu aplicación crece.

El argumento en contra del uso de GraphQL con DynamoDB tiene un poco más de matices. GraphQL parece encajar bien con las bases de datos relacionales en parte porque se asume en gran parte de la documentación, tutoriales y ejemplos. Alex Debrie es un experto en DynamoDB que escribió The DynamoDB Book, que es un gran recurso para aprenderlo en profundidad. Incluso él recomienda no usar los dos juntos, principalmente debido a la forma en que los solucionadores GraphQL a menudo se escriben como llamadas secuenciales a bases de datos independientes que pueden resultar en lecturas excesivas de bases de datos.

Otro problema potencial es que DynamoDB funciona mejor cuando conoce sus patrones de acceso de antemano. Uno de los puntos fuertes de GraphQL es que puede manejar consultas arbitrarias más fácilmente por diseño que REST. Esto es más un problema con una API pública donde los usuarios pueden escribir consultas arbitrarias. En realidad, GraphQL se usa a menudo para API privadas donde controlas tanto el cliente como el servidor. En este caso, usted conoce y puede controlar las consultas que ejecuta. Con una API GraphQL es posible escribir consultas que afecten cualquier base de datos sin tomar medidas para evitarlas.

Un modelo de datos básico

Para esta API de ejemplo, modelaremos una organización con equipos, usuarios y certificaciones. El diagrama relacional de entidades se muestra a continuación. Cada equipo tiene muchos usuarios y cada usuario puede tener muchas certificaciones.

Modelo de base de datos relacional

Nuestro objetivo final es modelar estos datos en una tabla de DynamoDB, pero si los modeláremos en una base de datos SQL, se vería como el siguiente diagrama:

Para representar la relación de muchos a muchos de los usuarios con las certificaciones, agregamos una tabla intermedia llamada “Credencial”. El único atributo exclusivo en esta tabla es la fecha de vencimiento. Habría otros atributos para cada una de las tablas, pero lo reducimos a solo un nombre para cada una por simplicidad.

Patrones de acceso

La clave para diseñar un modelo de datos para DynamoDB es conocer sus patrones de acceso desde el principio. En una base de datos relacional, se comienza con datos normalizados y se realizan uniones entre los datos para acceder a ellos. DynamoDB no tiene uniones, por lo que creamos un modelo de datos que coincide con la forma en que pretendemos acceder a él. Este es un proceso iterativo. El objetivo es identificar los patrones más frecuentes para empezar. La mayoría de estos se asignarán directamente a una consulta GraphQL, pero algunos solo se pueden usar internamente en el back-end para autenticar o verificar permisos, etc. Un patrón de acceso que rara vez se usa, como una verificación ejecutada una vez a la semana por un administrador, no es necesario diseñarlo. Algo muy ineficiente (como un escaneo de tabla) puede manejar estas consultas.

Acceso más frecuente:

  • Usuario por ID o nombre
  • Equipo por ID o nombre
  • Certificación por DNI o nombre

Acceso frecuente:

  • Todos los usuarios de un equipo por ID de equipo
  • Todas las certificaciones para un usuario determinado.
  • Todos los equipos
  • Todas las certificaciones

Raramente accedido

  • Todas las certificaciones de usuarios de un equipo.
  • Todos los Usuarios que tengan una Certificación
  • Todos los Usuarios que tengan una Certificación en un Equipo

Diseño de mesa única de DynamoDB

DynamoDB no tiene combinaciones y solo puede realizar consultas según la clave principal o los índices predefinidos. No existe un esquema establecido para los elementos impuestos por la base de datos, por lo que se pueden almacenar muchos tipos diferentes de elementos en una sola tabla. De hecho, la mejor práctica recomendada para su esquema de datos es almacenar todos los elementos en una sola tabla para que pueda acceder a los elementos relacionados junto con una única consulta. A continuación se muestra un modelo de tabla único que representa nuestros datos. Para diseñar este esquema, tome los patrones de acceso anteriores y elija atributos para las claves e índices que coincidan.

La clave principal aquí es una combinación de la clave de partición/hash (pk) y la clave de clasificación (sk). Para recuperar un elemento en DynamoDB, debe especificar exactamente la clave de partición y un valor único o un rango de valores para la clave de clasificación. Esto le permite recuperar más de un elemento si comparte una clave de partición. Los índices aquí se muestran como gsi1pk, gsi1sk, etc. Estos nombres de atributos genéricos se usan para los índices (es decir, gsi1pk) de modo que se pueda usar el mismo índice para acceder a diferentes tipos de elementos con diferentes patrones de acceso. Con una clave compuesta, la clave de clasificación no puede estar vacía, por lo que usamos “#” como marcador de posición cuando la clave de clasificación no es necesaria.

Patrón de acceso Condiciones de consulta
Equipo, Usuario o Certificación por ID Clave principal, pk=”T#”+ID, sk=”#”
Equipo, Usuario o Certificación por nombre Índice GSI 1, gsi1pk=tipo, gsi1sk=nombre
Todos los equipos, usuarios o certificaciones Índice GSI 1, gsi1pk=tipo
Todos los usuarios de un equipo por ID Índice GSI 2, gsi2pk=”T#”+teamID
Todas las certificaciones para un usuario por ID. Clave principal, pk=”U#”+ID de usuario, sk=”C#”+ID de certificado
Todos los Usuarios con Certificación por DNI Índice GSI 1, gsi1pk=”C#”+certID, gsi1sk=”U#”+userID

Esquema de base de datos

Aplicamos el “esquema de base de datos” en la aplicación. La API de DynamoDB es potente, pero también detallada y complicada. Mucha gente pasa directamente a utilizar un ORM para simplificarlo. Aquí, accederemos directamente a la base de datos utilizando las funciones auxiliares a continuación para crear el esquema para el Teamelemento.

const DB_MAP = {  TEAM: {    get: ({ teamId }) = ({      pk: 'T#'+teamId,      sk: '#',    }),    put: ({ teamId, teamName }) = ({      pk: 'T#'+teamId,      sk: '#',      gsi1pk: 'Team',      gsi1sk: teamName,      _tp: 'Team',      tn: teamName,    }),    parse: ({ pk, tn, _tp }) = {      if (_tp === 'Team') {        return {          id: pk.slice(2),          name: tn,          };        } else return null;        },    queryByName: ({ teamName }) = ({      IndexName: 'gsi1pk-gsi1sk-index',      ExpressionAttributeNames: { '#p': 'gsi1pk', '#s': 'gsi1sk' },      KeyConditionExpression: '#p = :p AND #s = :s',      ExpressionAttributeValues: { ':p': 'Team', ':s': teamName },      ScanIndexForward: true,    }),    queryAll: {      IndexName: 'gsi1pk-gsi1sk-index',      ExpressionAttributeNames: { '#p': 'gsi1pk' },      KeyConditionExpression: '#p = :p ',      ExpressionAttributeValues: { ':p': 'Team' },      ScanIndexForward: true,    },  },  parseList: (list, type) = {    if (Array.isArray(list)) {      return list.map(i = DB_MAP[type].parse(i));    }    if (Array.isArray(list.Items)) {      return list.Items.map(i = DB_MAP[type].parse(i));    }  },};

Para poner un nuevo elemento de equipo en la base de datos, llame a:

DB_MAP.TEAM.put({teamId:"t_01",teamName:"North Team"})

Esto forma el índice y los valores clave que se pasan a la API de la base de datos. El parsemétodo toma un elemento de la base de datos y lo traduce nuevamente al modelo de aplicación.

Esquema GraphQL

type Team {  id: ID!  name: String  members: [User]}type User {  id: ID!  name: String  team: Team  credentials: [Credential]}type Certification {  id: ID!  name: String}type Credential {  id: ID!  user: User  certification: Certification  expiration: String}type Query {  team(id: ID!): Team  teamByName(name: String!): [Team]  user(id: ID!): User  userByName(name: String!): [User]  certification(id: ID!): Certification  certificationByName(name: String!): [Certification]  allTeams: [Team]  allCertifications: [Certification]  allUsers: [User]}

Cerrando la brecha entre GraphQL y DynamoDB con resolutores

Los solucionadores son donde se ejecuta una consulta GraphQL. Puede avanzar mucho en GraphQL sin tener que escribir un solucionador. Pero para construir nuestra API, necesitaremos escribir algunas. Para cada consulta en el esquema GraphQL anterior, hay un solucionador raíz a continuación (aquí solo se muestran los solucionadores del equipo). Este solucionador de raíz devuelve una promesa o un objeto con parte de los resultados de la consulta.

Si la consulta devuelve un Teamtipo como resultado, la ejecución se transmite al Teamsolucionador de tipos. Ese solucionador tiene una función para cada uno de los valores en un archivo Team. Si no hay un solucionador para un valor determinado (es decir id), buscará si el solucionador raíz ya lo transmitió.

Una consulta requiere cuatro argumentos. El primero, llamado rooto parent, es un objeto transmitido desde el solucionador anterior con resultados parciales. El segundo, llamado args, contiene los argumentos pasados ​​a la consulta. El tercero, llamado context, puede contener cualquier cosa que la aplicación necesite para resolver la consulta. En este caso, agregamos una referencia para la base de datos al archivo context. El argumento final, llamado info, no se utiliza aquí. Contiene más detalles sobre la consulta (como un árbol de sintaxis abstracta).

En los solucionadores siguientes, ctx.db.singletablese encuentra la referencia a la tabla de DynamoDB que contiene todos los datos. Los métodos gety queryse ejecutan directamente en la base de datos y DB_MAP.TEAM....traducen el esquema a la base de datos utilizando las funciones auxiliares que escribimos anteriormente. El parsemétodo traduce los datos al formato necesario para el esquema GraphQL.

const resolverMap = {  Query: {    team: (root, args, ctx, info) = {      return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: args.id }))        .then(data = DB_MAP.TEAM.parse(data));    },    teamByName: (root, args, ctx, info) =; {      return ctx.db.singletable        .query(DB_MAP.TEAM.queryByName({ teamName: args.name }))        .then(data = DB_MAP.parseList(data, 'TEAM'));    },    allTeams: (root, args, ctx, info) = {      return ctx.db.singletable.query(DB_MAP.TEAM.queryAll)        .then(data = DB_MAP.parseList(data, 'TEAM'));    },  },  Team: {    name: (root, _, ctx) = {      if (root.name) {        return root.name;      } else {        return ctx.db.singletable.get(DB_MAP.TEAM.get({ teamId: root.id }))          .then(data = DB_MAP.TEAM.parse(data).name);      }    },    members: (root, _, ctx) = {      return ctx.db.singletable        .query(DB_MAP.USER.queryByTeamId({ teamId: root.id }))        .then(data = DB_MAP.parseList(data, 'USER'));    },  },  User: {    name: (root, _, ctx) = {      if (root.name) {        return root.name;      } else {        return ctx.db.singletable.get(DB_MAP.USER.get({ userId: root.id }))          .then(data = DB_MAP.USER.parse(data).name);      }    },    credentials: (root, _, ctx) = {      return ctx.db.singletable        .query(DB_MAP.CREDENTIAL.queryByUserId({ userId: root.id }))        .then(data =DB_MAP.parseList(data, 'CREDENTIAL'));    },  },};

Ahora sigamos la ejecución de la consulta a continuación. Primero, el teamsolucionador raíz lee el equipo by idy devuelve idy name. Luego, el Teamsolucionador de tipos lee todos los miembros de ese equipo. Luego, Userse llama al solucionador de tipos para que cada usuario obtenga todas sus credenciales y certificaciones. Si hay cinco miembros en el equipo y cada miembro tiene cinco credenciales, eso da como resultado un total de siete lecturas para la base de datos. Se podría argumentar que son demasiados. En una base de datos SQL, esto podría reducirse a cuatro llamadas a la base de datos. Yo diría que las siete lecturas de DynamoDB serán más baratas y más rápidas que las cuatro lecturas de SQL en muchos casos. Pero esto viene con una gran dosis de “dependencia” de muchos factores.

query { team( id:"t_01" ){  id  name  members{    id    name    credentials{      id      certification{        id        name      }    }  }}}

La sobrecaptación y el problema N+1

La optimización de una API GraphQL implica equilibrar una gran cantidad de compensaciones en las que no entraremos aquí. Pero dos que pesan mucho en la decisión de DynamoDB versus SQL son el exceso y el problema N+1. En muchos sentidos, son caras opuestas de la misma moneda. La recuperación excesiva se produce cuando un solucionador solicita más datos de la base de datos de los que necesita para responder a la consulta. Esto sucede a menudo cuando intenta realizar una llamada a la base de datos en el solucionador raíz o un solucionador de tipos (por ejemplo, miembros en el Teamsolucionador de tipos anterior) para obtener la mayor cantidad de datos posible. Si la consulta no solicita el nameatributo, puede considerarse un esfuerzo inútil.

El problema N+1 es casi lo contrario. Si todas las lecturas se envían al solucionador de nivel más bajo, entonces el teamsolucionador raíz y el solucionador de miembros (para Teamel tipo) realizarían solo una solicitud mínima o ninguna a la base de datos. Simplemente pasarían las identificaciones al Teamtipo y Useral solucionador de tipos. En este caso, en lugar de que los miembros realicen una llamada para obtener los cinco miembros, se presionará hacia abajo para Userrealizar cinco lecturas separadas. Esto daría como resultado potencial 36 o más lecturas independientes para la consulta anterior. En la práctica, esto no sucede porque un servidor optimizado usaría algo como la biblioteca DataLoader que actúa como middleware para interceptar esas 36 llamadas y agruparlas probablemente en solo cuatro llamadas a la base de datos. Estas aplicaciones de lectura atómica más pequeñas son necesarias para que DataLoader (o una herramienta similar) pueda agruparlas de manera eficiente en menos lecturas.

Entonces, para optimizar una API GraphQL con SQL, generalmente es mejor tener pequeños solucionadores en los niveles más bajos y usar algo como DataLoader para optimizarlos. Pero para una API de DynamoDB es mejor tener solucionadores “más inteligentes” en un nivel superior que coinciden mejor con los patrones de acceso para los que escribió la base de datos de una sola tabla. La sobrevaloración que resulta en este caso suele ser el menor de los dos machos.

Implemente este ejemplo en 60 segundos

Repositorio de GitHub

Aquí es donde se da cuenta de todos los beneficios de utilizar DynamoDB junto con GraphQL sin servidor. Construí este ejemplo con Architect. Es una herramienta de código abierto para crear aplicaciones sin servidor en AWS sin la mayoría de los dolores de cabeza que supone el uso directo de AWS. Una vez que clone el repositorio y lo ejecute npm install, puede iniciar la aplicación para el desarrollo local (incluida una versión local integrada de la base de datos) con un solo comando. No solo eso, también puede implementarlo directamente en la infraestructura de producción (incluido DynamoDB) en AWS con un solo comando cuando esté listo.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Subir