Wie man GraphQL und DynamoDB gut zusammenarbeiten lässt

Avatar of Ryan Bethel
Ryan Bethel am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Serverless, GraphQL und DynamoDB sind eine leistungsstarke Kombination für den Aufbau von Websites. Die ersten beiden sind sehr beliebt, aber DynamoDB wird oft missverstanden oder aktiv vermieden. Es wird oft von Leuten abgetan, die der Meinung sind, dass es sich nur „im großen Maßstab“ lohnt.

Das war auch meine Annahme, und ich habe versucht, bei meinen Serverless-Anwendungen bei einer SQL-Datenbank zu bleiben. Aber nachdem ich DynamoDB gelernt und verwendet habe, sehe ich dessen Vorteile für Projekte jeder Größenordnung.

Um Ihnen zu zeigen, was ich meine, bauen wir eine API von Anfang bis Ende auf – ohne schwere Object-Relational Mapper (ORM) oder GraphQL-Frameworks, die verstecken, was wirklich vor sich geht. Vielleicht überlegen Sie sich nach Abschluss, ob Sie DynamoDB noch einmal eine Chance geben möchten. Ich denke, es ist die Mühe wert.

Die Hauptkritikpunkte an DynamoDB und GraphQL

Der Hauptkritikpunkt an DynamoDB ist, dass es schwer zu lernen ist, aber wenige Leute bestreiten dessen Leistungsfähigkeit. Ich stimme zu, dass die Lernkurve sehr steil erscheint. Aber SQL-Datenbanken passen nicht gut zu Serverless-Anwendungen. Wo stellen Sie diese SQL-Datenbank auf? Wie verwalten Sie die Verbindungen dazu? Diese Dinge passen nicht gut zum Serverless-Modell. DynamoDB ist von Natur aus Serverless-freundlich. Sie tauschen den anfänglichen Schmerz des Erlernens von etwas Schwierigem ein, um sich zukünftigen Schmerzen zu ersparen. Zukünftiger Schmerz, der mit wachsender Anwendung wächst.

Der Einwand gegen die Verwendung von GraphQL mit DynamoDB ist etwas nuancierter. GraphQL scheint gut zu relationalen Datenbanken zu passen, teilweise weil dies von vielen Dokumentationen, Tutorials und Beispielen angenommen wird. Alex Debrie ist ein DynamoDB-Experte, der The DynamoDB Book geschrieben hat, eine großartige Ressource, um es tiefgehend zu lernen. Selbst er empfiehlt, diese beiden nicht zusammen zu verwenden, hauptsächlich wegen der Art und Weise, wie GraphQL-Resolver oft als sequentielle unabhängige Datenbankaufrufe geschrieben werden, die zu übermäßigen Datenbanklesevorgängen führen können.

Ein weiteres potenzielles Problem ist, dass DynamoDB am besten funktioniert, wenn Sie Ihre Zugriffsmuster im Voraus kennen. Eine der Stärken von GraphQL ist, dass es beliebige Abfragen leichter als REST handhaben kann. Das ist eher ein Problem bei einer öffentlichen API, bei der Benutzer beliebige Abfragen schreiben können. In Wirklichkeit wird GraphQL oft für private APIs verwendet, bei denen Sie sowohl den Client als auch den Server kontrollieren. In diesem Fall kennen und können Sie die von Ihnen ausgeführten Abfragen kontrollieren. Mit einer GraphQL-API ist es möglich, Abfragen zu schreiben, die *jede* Datenbank belasten, ohne Schritte zu unternehmen, um sie zu vermeiden.

Ein grundlegendes Datenmodell

Für diese Beispiel-API modellieren wir eine Organisation mit Teams, Benutzern und Zertifizierungen. Das relationale Entitätsdiagramm ist unten gezeigt. Jedes Team hat viele Benutzer und jeder Benutzer kann viele Zertifizierungen haben.

Relationales Datenbankmodell

Unser Endziel ist es, diese Daten in einer DynamoDB-Tabelle zu modellieren, aber wenn wir sie in einer SQL-Datenbank modellieren würden, sähe sie wie im folgenden Diagramm aus.

Um die Many-to-Many-Beziehung von Benutzern zu Zertifizierungen darzustellen, fügen wir eine Zwischen-Tabelle namens „Credential“ hinzu. Das einzige eindeutige Attribut dieser Tabelle ist das Ablaufdatum. Es gäbe andere Attribute für jede der Tabellen, aber wir reduzieren sie der Einfachheit halber nur auf einen Namen.

Zugriffsmuster

Der Schlüssel zur Gestaltung eines Datenmodells für DynamoDB ist, Ihre Zugriffsmuster im Voraus zu kennen. In einer relationalen Datenbank beginnen Sie mit normalisierten Daten und führen Joins über die Daten durch, um darauf zuzugreifen. DynamoDB hat keine Joins, daher bauen wir ein Datenmodell, das der Art und Weise entspricht, wie wir darauf zugreifen wollen. Dies ist ein iterativer Prozess. Das Ziel ist es, zunächst die häufigsten Muster zu identifizieren. Die meisten davon werden direkt einer GraphQL-Abfrage zugeordnet, aber einige werden möglicherweise nur intern im Backend verwendet, um zu authentifizieren oder Berechtigungen zu überprüfen usw. Ein selten genutztes Zugriffsmuster, wie ein einmal pro Woche von einem Administrator durchgeführter Check, muss nicht gestaltet werden. Etwas sehr Ineffizientes (wie ein Tabellenscan) kann diese Abfragen bearbeiten.

Am häufigsten zugegriffen

  • Benutzer nach ID oder Name
  • Team nach ID oder Name
  • Zertifizierung nach ID oder Name

Häufig zugegriffen

  • Alle Benutzer in einem Team nach Team-ID
  • Alle Zertifizierungen für einen bestimmten Benutzer
  • Alle Teams
  • Alle Zertifizierungen

Selten zugegriffen

  • Alle Zertifizierungen von Benutzern in einem Team
  • Alle Benutzer, die eine Zertifizierung haben
  • Alle Benutzer, die eine Zertifizierung in einem Team haben

DynamoDB Single-Table-Design

DynamoDB hat keine Joins und Sie können nur basierend auf dem Primärschlüssel oder vordefinierten Indizes abfragen. Es gibt kein festgelegtes Schema für Items, das von der Datenbank erzwungen wird, sodass viele verschiedene Arten von Items in einer einzigen Tabelle gespeichert werden können. Tatsächlich ist die empfohlene Best Practice für Ihr Datenschema, alle Items in einer einzigen Tabelle zu speichern, damit Sie verwandte Items mit einer einzigen Abfrage abrufen können. Unten ist ein Single-Table-Modell, das unsere Daten repräsentiert. Um dieses Schema zu entwerfen, nehmen Sie die obigen Zugriffsmuster und wählen Attribute für die Schlüssel und Indizes, die übereinstimmen.

Der Primärschlüssel ist hier eine Kombination aus Partition/Hash-Schlüssel (pk) und Sortierschlüssel (sk). Um ein Item in DynamoDB abzurufen, müssen Sie den Partitionsschlüssel genau und entweder einen einzelnen Wert oder einen Wertebereich für den Sortierschlüssel angeben. Dies ermöglicht es Ihnen, mehr als ein Item abzurufen, wenn sie einen Partitionsschlüssel teilen. Die Indizes sind hier als gsi1pk, gsi1sk usw. aufgeführt. Diese generischen Attributnamen werden für die Indizes (d. h. gsi1pk) verwendet, damit derselbe Index verwendet werden kann, um verschiedene Arten von Items mit unterschiedlichen Zugriffsmustern abzurufen. Bei einem zusammengesetzten Schlüssel kann der Sortierschlüssel nicht leer sein, daher verwenden wir „#“ als Platzhalter, wenn der Sortierschlüssel nicht benötigt wird.

ZugriffsmusterAbfragebedingungen
Team, Benutzer oder Zertifizierung nach IDPrimärschlüssel, pk=„T#“+ID, sk=„#“
Team, Benutzer oder Zertifizierung nach NameIndex GSI 1, gsi1pk=Typ, gsi1sk=Name
Alle Teams, Benutzer oder ZertifizierungenIndex GSI 1, gsi1pk=Typ
Alle Benutzer in einem Team nach IDIndex GSI 2, gsi2pk=„T#“+teamID
Alle Zertifizierungen für einen Benutzer nach IDPrimärschlüssel, pk=„U#“+userID, sk=„C#“+certID
Alle Benutzer mit einer Zertifizierung nach IDIndex GSI 1, gsi1pk=„C#“+certID, gsi1sk=„U#“+userID

Datenbankschema

Wir erzwingen das „Datenbankschema“ in der Anwendung. Die DynamoDB-API ist leistungsstark, aber auch wortreich und kompliziert. Viele Leute greifen direkt zu einem ORM, um es zu vereinfachen. Hier werden wir direkt auf die Datenbank zugreifen, indem wir die unten stehenden Hilfsfunktionen verwenden, um das Schema für das Team-Item zu erstellen.

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

Um ein neues Team-Item in die Datenbank einzufügen, rufen Sie auf

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

Dies bildet die Index- und Schlüsselwerte, die an die Datenbank-API übergeben werden. Die parse-Methode nimmt ein Item aus der Datenbank und übersetzt es zurück in das Anwendungsmodell.

GraphQL-Schema

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]
}

Überbrückung der Lücke zwischen GraphQL und DynamoDB mit Resolvern

Resolver sind der Ort, an dem eine GraphQL-Abfrage ausgeführt wird. Man kann in GraphQL weit kommen, ohne jemals einen Resolver schreiben zu müssen. Aber um unsere API zu erstellen, werden wir einige schreiben müssen. Für jede Abfrage im obigen GraphQL-Schema gibt es einen Root-Resolver unten (nur die Team-Resolver sind hier gezeigt). Dieser Root-Resolver gibt entweder ein Promise oder ein Objekt mit Teilen der Abfrageergebnisse zurück.

Wenn die Abfrage einen Team-Typ als Ergebnis zurückgibt, wird die Ausführung an den Team-Typ-Resolver weitergegeben. Dieser Resolver hat eine Funktion für jeden der Werte in einem Team. Wenn es keinen Resolver für einen bestimmten Wert gibt (z. B. id), wird geprüft, ob der Root-Resolver ihn bereits übergeben hat.

Eine Abfrage nimmt vier Argumente entgegen. Das erste, genannt root oder parent, ist ein Objekt, das vom Resolver darüber mit teilweisen Ergebnissen übergeben wird. Das zweite, genannt args, enthält die an die Abfrage übergebenen Argumente. Das dritte, genannt context, kann alles enthalten, was die Anwendung zur Auflösung der Abfrage benötigt. In diesem Fall fügen wir eine Referenz zur Datenbank zum context hinzu. Das letzte Argument, genannt info, wird hier nicht verwendet. Es enthält weitere Details zur Abfrage (wie einen Abstract Syntax Tree).

In den untenstehenden Resolvern ist ctx.db.singletable die Referenz auf die DynamoDB-Tabelle, die alle Daten enthält. Die Methoden get und query werden direkt gegen die Datenbank ausgeführt, und DB_MAP.TEAM.... übersetzt das Schema mit den zuvor geschriebenen Hilfsfunktionen in die Datenbank. Die Methode parse übersetzt die Daten zurück in das für das GraphQL-Schema benötigte Format.

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

Betrachten wir nun die Ausführung der folgenden Abfrage. Zuerst liest der Root-Resolver team das Team anhand der id und gibt id und name zurück. Dann liest der Resolver vom Typ Team alle Mitglieder dieses Teams. Anschließend wird der Resolver vom Typ User für jeden Benutzer aufgerufen, um alle seine Anmeldeinformationen und Zertifizierungen abzurufen. Wenn fünf Mitglieder im Team sind und jedes Mitglied fünf Anmeldeinformationen hat, führt dies zu insgesamt sieben Lesevorgängen für die Datenbank. Man könnte argumentieren, dass das zu viele sind. In einer SQL-Datenbank könnte dies auf vier Datenbankaufrufe reduziert werden. Ich würde argumentieren, dass die sieben DynamoDB-Lesevorgänge in vielen Fällen günstiger und schneller sind als die vier SQL-Lesevorgänge. Aber das ist mit einer großen Portion „es kommt darauf an“ von vielen Faktoren verbunden.

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

Over-Fetching und das N+1-Problem

Die Optimierung einer GraphQL-API beinhaltet die Abwägung einer ganzen Reihe von Kompromissen, auf die wir hier nicht eingehen werden. Aber zwei, die bei der Entscheidung zwischen DynamoDB und SQL stark ins Gewicht fallen, sind Over-Fetching und das N+1-Problem. In vielerlei Hinsicht sind dies die beiden Seiten derselben Medaille. Over-Fetching tritt auf, wenn ein Resolver mehr Daten aus der Datenbank abruft, als er zum Antworten auf die Abfrage benötigt. Dies geschieht oft, wenn Sie versuchen, einen einzigen Aufruf an die Datenbank im Root-Resolver oder einem Typ-Resolver (z. B. Mitglieder im Team-Typ-Resolver oben) zu tätigen, um so viele Daten wie möglich abzurufen. Wenn die Abfrage nicht das name-Attribut angefordert hat, kann dies als verschwendete Mühe angesehen werden.

Das N+1-Problem ist fast das Gegenteil. Wenn alle Lesevorgänge bis zum niedrigsten Resolver weitergegeben werden, dann würden der team-Root-Resolver und der Mitglieder-Resolver (für den Team-Typ) nur eine minimale oder keine Anfrage an die Datenbank stellen. Sie würden nur die IDs an den Team-Typ- und User-Typ-Resolver weitergeben. In diesem Fall würde anstatt eines Aufrufs, um alle fünf Mitglieder abzurufen, das Mitglied an den User weitergegeben, um fünf separate Lesevorgänge durchzuführen. Dies würde zu potenziell 36 oder mehr separaten Lesevorgängen für die obige Abfrage führen. In der Praxis geschieht dies nicht, da ein optimierter Server etwas wie die DataLoader-Bibliothek verwenden würde, die als Middleware fungiert, um diese 36 Aufrufe abzufangen und sie in wahrscheinlich nur vier Aufrufe an die Datenbank zu bündeln. Diese kleineren atomaren Leseanforderungen sind notwendig, damit der DataLoader (oder ein ähnliches Tool) sie effizient zu weniger Lesevorgängen bündeln kann.

Um eine GraphQL-API mit SQL zu optimieren, ist es daher normalerweise am besten, kleine Resolver auf den niedrigsten Ebenen zu haben und etwas wie DataLoader zur Optimierung zu verwenden. Aber für eine DynamoDB-API ist es besser, „intelligentere“ Resolver höher oben zu haben, die besser zu den Zugriffsmustern passen, für die Ihre Single-Table-Datenbank geschrieben ist. Das Over-Fetching, das in diesem Fall auftritt, ist in der Regel das kleinere Übel.

Dieses Beispiel in 60 Sekunden bereitstellen

Hier erkennen Sie den vollen Nutzen der Verwendung von DynamoDB zusammen mit Serverless GraphQL. Ich habe dieses Beispiel mit Architect erstellt. Es ist ein Open-Source-Tool zum Erstellen von Serverless-Apps auf AWS ohne die meisten Kopfschmerzen bei der direkten Verwendung von AWS. Sobald Sie das Repository geklont und npm install ausgeführt haben, können Sie die App für die lokale Entwicklung (einschließlich einer integrierten lokalen Version der Datenbank) mit einem einzigen Befehl starten. Nicht nur das, Sie können sie auch mit einem einzigen Befehl direkt in die Produktionsinfrastruktur (einschließlich DynamoDB) auf AWS deployen, wenn Sie bereit sind.