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.
| Zugriffsmuster | Abfragebedingungen |
|---|---|
| Team, Benutzer oder Zertifizierung nach ID | Primärschlüssel, pk=„T#“+ID, sk=„#“ |
| Team, Benutzer oder Zertifizierung nach Name | Index GSI 1, gsi1pk=Typ, gsi1sk=Name |
| Alle Teams, Benutzer oder Zertifizierungen | Index GSI 1, gsi1pk=Typ |
| Alle Benutzer in einem Team nach ID | Index GSI 2, gsi2pk=„T#“+teamID |
| Alle Zertifizierungen für einen Benutzer nach ID | Primärschlüssel, pk=„U#“+userID, sk=„C#“+certID |
| Alle Benutzer mit einer Zertifizierung nach ID | Index 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.
Ich bin sehr an dem Single-Table-Design-Ansatz interessiert, aber mit AWS AppSync scheint es keine Möglichkeit zu geben, dies mit dem eingebauten Codegen zu erreichen. Ich sehe, wie das außerhalb von Amplify und AppSync oder bei Verwendung eines direkten Lambda-Resolvers mit benutzerdefinierten Datenmodellen sinnvoll ist, aber wir können all diese komplexen Datenzugriffsmuster direkt in unserem GraphQL-Schema mit AppSync erreichen. Oder übersehe ich etwas?
Tolle Zusammenfassung von DynamoDB. Ich bin noch Anfänger damit, daher war das sehr hilfreich.
AppSync kümmert sich um einen Großteil der Interaktion mit DynamoDB für Sie. Dies ist geschrieben, um zu zeigen, wie man ein Modell von Grund auf neu erstellen könnte, ohne die Hilfe von Tools wie Amplify oder AppSync. Wenn Sie in AppSync sind, macht es wahrscheinlich mehr Sinn, es zu verwenden, bis Sie auf ein Problem stoßen, das es nicht löst. Dann könnten Sie auf dieses Muster zurückgreifen.
Warum wählen Sie nicht MongoDB? Es deckt alle Funktionalitäten von NoSQL-Datenbanken ab.
Abgesehen von selbst gehosteten auf EC2 oder DocumentDB, liest und schreibt DynamoDB über HTTPS-Verbindungen, was es perfekt für eine Serverless-Architektur macht, bei der die Verwaltung von Netzwerkverbindungen nicht effizient ist!
Als Alternative zu DataLoader: https://github.com/calebmer/graphql-resolve-batch
Stehen Sie immer noch hinter den obigen Argumenten, jetzt wo RDS Serverless PostgreSQL anbietet?
Was ist, wenn ich eine Rückwärts-Suche durchführen möchte, z. B. alle Teammitglieder mit einer bestimmten Zertifizierung finden? Ist das immer noch ein guter Ansatz? Ich habe das Schema durchgesehen, aber ich sehe keine Abfrage dafür.
Könnten Sie mir zeigen, wie eine Tabelle mit Daten aussehen würde, in einem Excel-Tabellenblatt? Danke für Ihren Artikel.