Comment faire en sorte que GraphQL et DynamoDB jouent bien ensemble

Online Coding Courses for Kids

Sans serveur, GraphQL et DynamoDB sont une combinaison puissante pour créer des sites Web. Les deux premiers sont très appréciés, mais DynamoDB est souvent mal compris ou activement évité. Il est souvent rejeté par des gens qui considèrent que cela ne vaut que l’effort «à grande échelle».

C’était aussi mon hypothèse et j’ai essayé de m’en tenir à une base de données SQL pour mes applications sans serveur. Mais après avoir appris et utilisé DynamoDB, j’en vois les avantages pour des projets de toute envergure.

Pour vous montrer ce que je veux dire, construisons une API du début à la fin – sans aucun lourd Object Relational Mapper (ORM) ou framework GraphQL pour cacher ce qui se passe réellement. Peut-être que lorsque nous aurons terminé, vous pourriez envisager de donner un second regard à DynamoDB. Je pense que cela en vaut la peine.

Les principales objections à DynamoDB et GraphQL

La principale objection à DynamoDB est qu’il est difficile à apprendre, mais peu de gens discutent de sa puissance. Je suis d’accord que la courbe d’apprentissage est très raide. Mais les bases de données SQL ne conviennent pas le mieux aux applications sans serveur. Où vous situez-vous cette base de données SQL? Comment gérez-vous les connexions avec celui-ci? Ces éléments ne correspondent tout simplement pas très bien au modèle sans serveur. DynamoDB est de par sa conception convivial sans serveur. Vous échangez la douleur initiale d’apprendre quelque chose de difficile pour vous sauver de la douleur future. Une douleur future qui ne grandira que si votre application se développe.

Le cas contre l’utilisation de GraphQL avec DynamoDB est un peu plus nuancé. GraphQL semble bien s’intégrer aux bases de données relationnelles en partie parce qu’il est supposé par une grande partie de la documentation, des didacticiels et des exemples. Alex Debrie est un expert DynamoDB qui a écrit Le livre DynamoDB ce qui est une excellente ressource pour l’apprendre en profondeur. Même il recommande contre l’utilisation des deux ensemble, principalement à cause de la façon dont les résolveurs GraphQL sont souvent écrits sous forme d’appels séquentiels de base de données indépendants qui peuvent entraîner des lectures excessives de la base de données.

Un autre problème potentiel est que DynamoDB fonctionne mieux lorsque vous connaissez au préalable vos modèles d’accès. L’une des forces de GraphQL est qu’il peut gérer les requêtes arbitraires plus facilement par conception que REST. Il s’agit davantage d’un problème avec une API publique où les utilisateurs peuvent écrire des requêtes arbitraires. En réalité, GraphQL est souvent utilisé pour les API privées où vous contrôlez à la fois le client et le serveur. Dans ce cas, vous connaissez et pouvez contrôler les requêtes que vous exécutez. Avec une API GraphQL, il est possible d’écrire des requêtes qui clobber tout base de données sans prendre de mesures pour les éviter.

Un modèle de données de base

Pour cet exemple d’API, nous modéliserons une organisation avec des équipes, des utilisateurs et des certifications. Le diagramme relationnel d’entité est illustré ci-dessous. Chaque équipe compte de nombreux utilisateurs et chaque utilisateur peut avoir de nombreuses certifications.

Modèle de base de données relationnelle

Notre objectif final est de modéliser ces données dans une table DynamoDB, mais si nous les modélisions dans une base de données SQL, cela ressemblerait au diagramme suivant:

Pour représenter la relation plusieurs-à-plusieurs entre les utilisateurs et les certifications, nous ajoutons un tableau intermédiaire appelé «Credential». Le seul attribut unique de cette table est la date d’expiration. Il y aurait d’autres attributs pour chacune des tables, mais nous les réduisons à un seul nom pour chacune pour plus de simplicité.

Modèles d’accès

La clé de la conception d’un modèle de données pour DynamoDB est de connaître vos modèles d’accès à l’avance. Dans une base de données relationnelle, vous commencez avec des données normalisées et effectuez des jointures entre les données pour y accéder. DynamoDB n’a pas de jointures, nous construisons donc un modèle de données qui correspond à la façon dont nous avons l’intention d’y accéder. C’est un processus itératif. L’objectif est d’identifier les schémas les plus fréquents pour démarrer. La plupart de ceux-ci seront directement mappés à une requête GraphQL, mais certains ne peuvent être utilisés qu’en interne pour le back-end pour authentifier ou vérifier les autorisations, etc. Un modèle d’accès rarement utilisé, comme une vérification effectuée une fois par semaine par un administrateur, pas besoin d’être conçu. Quelque chose de très inefficace (comme une analyse de table) peut gérer ces requêtes.

Le plus fréquemment consulté:

  • Utilisateur par ID ou nom
  • Équipe par ID ou nom
  • Certification par ID ou nom

Fréquemment consulté:

  • Tous les utilisateurs d’une équipe par ID d’équipe
  • Toutes les certifications pour un utilisateur donné
  • Toutes les équipes
  • Toutes les certifications

Rarement consulté

  • Toutes les certifications des utilisateurs d’une équipe
  • Tous les utilisateurs qui ont une certification
  • Tous les utilisateurs qui ont une certification dans une équipe

Conception de table simple DynamoDB

DynamoDB n’a pas de jointures et vous ne pouvez interroger qu’en fonction de la clé primaire ou d’index prédéfinis. Il n’y a pas de schéma d’ensemble pour les éléments imposés par la base de données, de sorte que de nombreux types d’éléments différents peuvent être stockés dans une seule table. En fait, la meilleure pratique recommandée pour votre schéma de données consiste à stocker tous les éléments dans une seule table afin que vous puissiez accéder aux éléments associés avec une seule requête. Vous trouverez ci-dessous un modèle de table unique représentant nos données. Pour concevoir ce schéma, prenez les modèles d’accès ci-dessus et choisissez les attributs des clés et des index qui correspondent.

La clé primaire ici est un composite de la clé de partition / hachage (pk) et de la clé de tri (sk). Pour récupérer un élément dans DynamoDB, vous devez spécifier exactement la clé de partition et une valeur unique ou une plage de valeurs pour la clé de tri. Cela vous permet de récupérer plus d’un élément s’ils partagent une clé de partition. Les index ici sont affichés sous la forme gsi1pk, gsi1sk, etc. Ces noms d’attributs génériques sont utilisés pour les index (c’est-à-dire gsi1pk) afin que le même index puisse être utilisé pour accéder à différents types d’éléments avec un modèle d’accès différent. Avec une clé composite, la clé de tri ne peut pas être vide, nous utilisons donc «#» comme espace réservé lorsque la clé de tri n’est pas nécessaire.

Modèle d’accèsConditions de requête
Équipe, utilisateur ou certification par IDClé primaire, pk = “T #” + ID, sk = “#”
Équipe, utilisateur ou certification par nomIndex GSI 1, gsi1pk = type, gsi1sk = nom
Toutes les équipes, utilisateurs ou certificationsIndex GSI 1, gsi1pk = type
Tous les utilisateurs d’une équipe par IDIndex GSI 2, gsi2pk = “T #” + teamID
Toutes les certifications pour un utilisateur par IDClé primaire, pk = “U #” + userID, sk = “C #” + certID
Tous les utilisateurs avec une certification par IDIndex GSI 1, gsi1pk = “C #” + certID, gsi1sk = “U #” + userID

Schéma de base de données

Nous appliquons le «schéma de base de données» dans l’application. L’API DynamoDB est puissante, mais aussi verbeuse et compliquée. De nombreuses personnes se lancent directement dans l’utilisation d’un ORM pour le simplifier. Ici, nous accèderons directement à la base de données en utilisant les fonctions d’assistance ci-dessous pour créer le schéma du Team article.

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));
    }
  },
};

Pour mettre un nouvel élément d’équipe dans la base de données que vous appelez:

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

Cela forme l’index et les valeurs de clé qui sont transmis à l’API de base de données. le parse La méthode prend un élément de la base de données et le traduit dans le modèle d’application.

Schéma 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]
}

Combler le fossé entre GraphQL et DynamoDB avec des résolveurs

Les résolveurs sont l’endroit où une requête GraphQL est exécutée. Vous pouvez faire un long chemin dans GraphQL sans jamais écrire de résolveur. Mais pour créer notre API, nous devrons en écrire. Pour chaque requête dans le schéma GraphQL ci-dessus, il y a un résolveur racine ci-dessous (seuls les résolveurs d’équipe sont affichés ici). Ce résolveur racine renvoie une promesse ou un objet avec une partie des résultats de la requête.

Si la requête renvoie un Team type comme résultat, puis l’exécution est transmise au Team résolveur de type. Ce résolveur a une fonction pour chacune des valeurs d’un Team. S’il n’y a pas de résolveur pour une valeur donnée (i.e. id), il cherchera à voir si le résolveur racine l’a déjà transmis.

Une requête prend quatre arguments. Le premier, appelé root ou parent, est un objet transmis par le résolveur ci-dessus avec des résultats partiels. Le second, appelé args, contient les arguments passés à la requête. Le troisième, appelé context, peut contenir tout ce dont l’application a besoin pour résoudre la requête. Dans ce cas, nous ajoutons une référence pour la base de données au context. Le dernier argument, appelé info, n’est pas utilisé ici. Il contient plus de détails sur la requête (comme un arbre syntaxique abstrait).

Dans les résolveurs ci-dessous, ctx.db.singletable est la référence à la table DynamoDB qui contient toutes les données. le get et query les méthodes s’exécutent directement sur la base de données et DB_MAP.TEAM.... traduit le schéma dans la base de données à l’aide des fonctions d’assistance que nous avons écrites précédemment. le parse La méthode convertit les données en retour nécessaires pour le schéma 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'));
    },
  },
};

Maintenant, suivons l’exécution de la requête ci-dessous. Premièrement le team le résolveur racine lit l’équipe en id et retourne id et name. Puis le Team le résolveur de type lit tous les membres de cette équipe. Puis le User le résolveur de type est appelé pour que chaque utilisateur obtienne toutes ses informations d’identification et certifications. S’il y a cinq membres dans l’équipe et que chaque membre dispose de cinq informations d’identification, cela se traduit par un total de sept lectures pour la base de données. Vous pourriez dire que c’est trop. Dans une base de données SQL, cela peut être réduit à quatre appels de base de données. Je dirais que les sept lectures DynamoDB seront moins chères et plus rapides que les quatre lectures SQL dans de nombreux cas. Mais cela vient avec une grande dose de «cela dépend» de nombreux facteurs.

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

La surextraction et le problème N + 1

Optimiser une API GraphQL implique d’équilibrer un grand nombre de compromis que nous n’entrerons pas ici. Mais deux qui pèsent lourd dans la décision de DynamoDB contre SQL sont la surextraction et le problème N + 1. À bien des égards, ce sont des faces opposées d’une même médaille. La surextraction se produit lorsqu’un résolveur demande plus de données à la base de données qu’il n’en a besoin pour répondre à la requête. Cela se produit souvent lorsque vous essayez d’effectuer un appel à la base de données dans le résolveur racine ou un résolveur de type (par exemple, les membres du Team type résolveur ci-dessus) pour obtenir autant de données que possible. Si la requête n’a pas demandé le name attribut, cela peut être considéré comme un effort inutile.

Le problème N + 1 est presque le contraire. Si toutes les lectures sont poussées vers le résolveur de niveau le plus bas, alors le team le résolveur racine et le résolveur de membres (pour Team type) ne ferait qu’une demande minimale ou nulle à la base de données. Ils transmettraient simplement les identifiants aux Team type et User résolveur de type. Dans ce cas, au lieu que les membres passent un seul appel pour obtenir les cinq membres, cela pousserait à User pour effectuer cinq lectures distinctes. Cela entraînerait potentiellement 36 lectures séparées ou plus pour la requête ci-dessus. En pratique, cela ne se produit pas car un serveur optimisé utiliserait quelque chose comme le DataLoader bibliothèque qui agit comme un middleware pour intercepter ces 36 appels et les regrouper probablement en seulement quatre appels à la base de données. Ces demandes de lecture atomique plus petites sont nécessaires pour que le DataLoader (ou un outil similaire) puisse les regrouper efficacement en moins de lectures.

Ainsi, pour optimiser une API GraphQL avec SQL, il est généralement préférable d’avoir de petits résolveurs aux niveaux les plus bas et d’utiliser quelque chose comme DataLoader pour les optimiser. Mais pour une API DynamoDB, il est préférable d’avoir des résolveurs «plus intelligents» plus haut qui correspondent mieux aux modèles d’accès pour votre base de données de table unique. La surexploitation qui en résulte dans ce cas est généralement le moindre des deux maux.

Déployez cet exemple en 60 secondes

C’est là que vous réalisez le plein bénéfice de l’utilisation de DynamoDB avec GraphQL sans serveur. J’ai construit cet exemple avec Architecte. Il s’agit d’un outil open source pour créer des applications sans serveur sur AWS sans la plupart des maux de tête liés à l’utilisation directe d’AWS. Une fois que vous avez cloné le dépôt et exécuté npm install, vous pouvez lancer l’application pour le développement local (y compris une version locale intégrée de la base de données) avec une seule commande. Non seulement cela, vous pouvez également le déployer directement sur l’infrastructure de production (y compris DynamoDB) sur AWS avec une seule commande lorsque vous êtes prêt.

Close Menu