Cloud-Datenbank FaunaDB 23.08.2019, 15:11 Uhr

Erstaunlicher Alleskönner

FaunaDB wird auf seiner Website als die weltbeste Serverless Datenbank beworben.
(Quelle: mb )
Sie steht in Konkurrenz zu Google Cloud Store, Amazon Aurora Serverless, Amazon DynamoDB und MongoDB Atlas. Die auf der Website präsentierten Features der FaunaDB stellen sie als erstaunlichen Alleskönner dar - dieser Artikel soll beleuchten wie sich das in der Praxis verhält.

NoSQL vs. SQL

SQL ist eine seit 45 Jahren etablierte domänenspezifische Sprache um Daten in einem Relationalen Datenbankmanagementsystem (RDBMS) zu verwalten. Über die eigentliche Sprache hinweg verbindet man in mit SQL heutzutage aber auch häufig das Prinzip relationaler Datenbanken nach Edgar F. Codds relationalem Modell.
Viele mit Big Data und Cloud Computing aufkommende nicht-relationalen Datenbankenmodelle werden deshalb als NoSQL bezeichnet. Sinnvoller wäre NoRelational gewesen, aber die Bezeichnung NoSQL zeigt auch wie fest in den Köpfen der Entwicklercommunities die Begriffe SQL und Relational miteinander verbunden sind.
Hinter der NoSQL-Idee steckt der Wunsch, durch eine Abkehr von den Konzepten des altbewährten relationalen Modells verschiedene Herausforderungen des Internet-Zeitalters zu lösen. Einfachere Modelle sollen dabei ermöglichen leichter horizontal zu skalieren. Statt großen und teuren Datenbank-Mainframes sammelt man in den Rechenzentren der Cloud-Computing-Ära Massen an einfachen Rechnern um kostengünstiger mit der riesigen Last von Big Data und Echtzeit-Webanwendungen umgehen zu können. Anbieter wie Amazon, Facebook, Google oder Twitter bilden dazu seit eh und je die Speerspitze dieser Bewegung.

Bessere Skalierung

Doch nicht nur eine bessere Skalierung wird den NoSQL-Datenbanken nachgesagt. Es gibt die unterschiedlichsten Modelle: Document-Stores, Key-Value-Stores, Object-Stores, Graphen-Datenbanken - je nachdem was sich für den jeweiligen Anwendungsfall besser eignet. Sie gelten deshalb oft auch als flexibler als das mitunter starre Datenmodell der klassischen RDBMS. Datenbanken wie CouchDB speichern Dokumente ohne ein festes Schema - man kann einfach beliebiges JSON ablegen und über Indices effizient abrufbar machen.
Doch es gibt auch einen Haken: Die meisten dieser NoSQL-Datenbanken unterstützen nicht denselben Grad an Transaktionssicherheit den man von RDBMS gewohnt ist. Bei SQL-Datenbanken werden Zugriffe über Transaktionen gesteuert, welche die sogenannten ACID-Eigenschaften erfüllen: A - Atomic, C - Consistent, I - Isolated und D - Durable.
Sobald man die Daten jedoch auf unterschiedliche Rechner verteilt, können Inkonsistenzen entstehen (die replizierten Daten weichen ungewollt voneinander ab). Bei einer Transaktion mit ACID-Eigenschaften müssen sich alle Teilnehmer auf irgendeine Weise untereinander abstimmen.
Bricht die Verbindung zwischen einzelnen Teilnehmern ab, ist eine Abstimmung zu diesem Zeitpunkt nicht mehr möglich. Da Ausfälle und Netzwerkprobleme in einem Cloud-Rechenzentrum der Normalfall sind, unterstützen die meisten NoSQL-Systeme keine ACID-Transaktionen. Vertreter wie MongoDB oder CouchDB nennt man deshalb auch oft Eventually Consistent. Damit ist gemeint, dass das System über bestimmte Zeitperioden hinweg auch Inkonsistenzen zulässt.

CAP-Theorem

Oft wird in diesem Kontext auch Eric Brewers CAP-Theorem erwähnt (Bild 1).
CAP-Theorem: Consistency, Availability & Partition Tolerance nach Brewer (Bild 1)
Quelle: Jochen Schmidt
Demnach soll es laut Brewer in einem verteilten System niemals möglich sein die drei Konzepte Consistency, Availability und Partition Tolerance gleichzeitig zu unterstützen. Der Entwickler kann stets nur zwischen Konsistenz oder Verfügbarkeit wählen.
Anders ausgedrückt: Wer Konsistenz unter allen Teilnehmern will, muss in Kauf nehmen, dass die Verfügbarkeit leidet. Wer Verfügbarkeit über alles stellt muss damit leben, dass es zumindest teilweise und temporär zu Inkonsistenzen kommen kann. Das CAP-Theorem wurde deshalb oft durch Vertreter von NoSQL mit Eventual Consistency als Hauptargument für das von High Availability getriebene Cloud-Umfeld benutzt.
FaunaDB wird dagegen explizit als Cloud-Datenbank beworben, die auch über Datenzentren hinweg verteilte ACID-Transaktionen unterstützt. Das klingt auf den ersten Blick so, als ob man sich hier über die Aussage des CAP-Theorems hinwegsetzen konnte. Dem ist natürlich nicht so.
FaunaDB gewährleistet Konsistenz zum Preis von Verfügbarkeit. Der Kunstgriff wieso die Datenbank dennoch für ein horizontal leicht skalierbares Cloud-Computing geeignet ist, liegt an der Implementierung des sogenannten Calvin-Protokolls. Durch dieses Protokoll ist es - einfach gesagt - möglich die Verfügbarkeit so hoch zu treiben, dass sie in der Praxis kein Problem mehr darstellt.
Demgegenüber steht die Tatsache, dass eigentlich selbst Systeme mit Eventual Consistency niemals eine hundertprozentige Verfügbarkeit bieten können. Das CAP-Theorem ist in seiner Aussage demnach eigentlich nicht so fatalistisch wie es von einigen Leuten vertreten wird.
Selbst Eric Brewer rät davon ab in diesem Fall das CAP-Theorem anzuwenden, da eine Verfügbarkeit von 99,99991 Prozent in der Praxis nahe genug an hochverfügbar herankommt um kein Problem zu sein und weil das CAP-Theorem sich lediglich auf Verfügbarkeit bei Network-Partitions bezieht, jedoch andere Ausfallursachen wahrscheinlicher sind. Ausfälle durch Network-Partitions würden dann im Rauschen untergehen.

Cloud-Datenbank mit ACID-Transaktionen

Tatsächlich ist FaunaDB nicht die erste Cloud-Datenbank mit ACID-Transaktionen. Google bietet mit Cloud Spanner ein Produkt an, das ähnliche Eigenschaften besitzt, dabei aber höchst abhängig von Googles TrueTime-Infrastruktur ist. Dabei sollen Atomuhren und präzise GPS-Empfänger dafür sorgen, dass die in den Transaktionen benutzten Zeitstempel überall in der Welt genau genug sind. Nach einer Transaktion wartet Spanner eine Zeitspanne die etwa dem maximal möglichen Zeitunterschied zwischen den Datenzentren entspricht. Wenn diese Unsicherheitszeitspanne vorbei ist, kann das System sicher sein, dass es zu keinem Konflikt gekommen ist.
Das in der FaunaDB eingesetzte Calvin-Protokoll wurde von Professor Daniel Abadi an der Yale Universität entwickelt und erfordert keine hochpräzisen Zeitstempel (inklusive entsprechender Hardware) und muss auch keine Unsicherheitszeitspanne abwarten. Stattdessen gibt es einen Vorverarbeitungsschritt bei welchem jede Transaktion in ein globales Transaktionslog eingetragen wird. Die Replikation dieses globalen Transaktionslogs ist insgesamt rechnerisch vergleichbar mit den bei Spanner notwendigen Schritten.
Der einzige Vorteil von Spanner liege - laut Abadi - bei rein lesenden Transaktionen, die nicht auf weit voneinander entfernten Datenpartitionen zugreifen.
Ein in der Praxis einer komplexen Anwendung schwer sicherzustellendes Szenario. In seinen Ausführungen betont Abadi allerdings auch, dass seine Betrachtung von einer perfekten Implementierung der Spanner und Calvin-Protokolle ausgeht. Ob sich dies im Realeinsatz bei Google Spanner oder der FaunaDB so bewahrheitet steht auf einem anderen Blatt. FaunaDB ist die erste Datenbank die dieses Protokoll kommerziell umsetzt und anbietet.
Wo die meisten Datenbanken sich auf ein bestimmtes Datenmodell (relational, dokumentbasiert, Graphenmodell, temporal) festlegen, unterstützt FaunaDB all diese Modelle. Auch die APIs sind flexibel: Neben der eigenen Datenbanksprache FQL (Fauna Query Language) wird auch GraphQL unterstützt. An der Möglichkeit eine FaunaDB per SQL anzusprechen wird momentan noch entwickelt.

FaunaDB Basics

FaunaDB ist sowohl als OnPremise-Variante zur Installation in eigenen Servern als auch als Cloud-Dienst verfügbar. In diesem Artikel wird ausschließlich das Cloud-Angebot der FaunaDB genutzt. Ein kostenloses Benutzerkonto kann auf der Website https://fauna.com angelegt werden.
Die Preisstaffelung teilt sich dabei auf den kostenlosen Plan Free, den rein durch Nutzung abgerechneten Utility und einen für monatlich 99 US-Dollar abgerechneten Plan Pro auf. Im Plan Free können keine weitere Kosten entstehen - er ist allerdings auf einen Speicherplatz von 5 GByte gedeckelt. Außerdem muss man sich pro Tag mit 100000 Leseoperationen, 50000 Schreiboperationen und einem Datentransfer von 50 MByte begnügen. Für kleine Projekte kann das jedoch bereits ausreichen. Wer diese Grenzen überschreitet kann per Utility-Plan eine feingranulare Abrechnung erhalten:
  • Speicherung: 0.18$ Pro GByte/Monat
  • Leseoperationen: 0,05$/100000 Operationen
  • Schreiboperationen: 0,2$/100000 Operationen
  • Datentransfer: 0,10$ Pro GByte/Tag
 
Eine Verdoppelung der freien Deckelung führt demnach auch nur Kosten im Bereich etwa eines Dollars - mit einer unschönen Kostenexplosion ist also nicht zu rechnen.
Die Datenbankübersicht im Dashboard (Bild 2)
Quelle: Jochen Schmidt
Über das in den Webauftritt integrierte Dashboard kann man Datenbanken anlegen und verwalten. Bild 2 zeigt die Datenbankübersicht mit den Verfügbaren Collections und Indices. Auch eine Shell für die Fauna Query Language (FQL) ist integriert (Bild 3).
Die FQL-Shell im Dashboard (Bild 3)
Quelle: Jochen Schmidt
Andere Möglichkeiten sind das Kommandozeilenwerkzeug fauna-shell oder eine passende Treiberbibliothek für eine der unterstützten Programmiersprachen. Für Node.js steht das npm-Paket faunadb bereit:
 
npm install --save faunadb
 
Die fauna-shell kann man ebenfalls per npm installieren:
 
npm install -g fauna-shell
 
Man kann die Shell auch ohne Installation mit npx aufrufen:
 
$ npx fauna-shell
npx: Installierte 346 in 18.757s
faunadb shell
VERSION
  fauna-shell/0.9.5 darwin-x64 node-v11.2.0
 
USAGE
  $ fauna [COMMAND]
 
COMMANDS
  add-endpoint
  autocomplete  display autocomplete installation instructions
  cloud-login
  create-database
  create-key
  default-endpoint
  delete-database
  delete-endpoint
  delete-key
  eval
  help  display help for fauna
  list-databases
  list-endpoints
  list-keys
  run-queries
  shell
 
Mit dem Befehl cloud-login kann man sich anmelden:
 
$ npx fauna-shell cloud-login
Email: name@host.com
Password: ***********
 
Man gibt dabei lediglich die Mailadresse und das Password an, das man bei der Registrierung auf der Website hinterlegt hat. Danach kann man eine erste Datenbank anlegen:
 
$ npx fauna-shell create-database tutorial
npx: Installierte 346 in 13.337s
creating database tutorial
  created database 'tutorial'
  To start a shell with your new database, run:
  fauna shell 'tutorial'
  Or, to create an application key for your
  database, run:
  fauna create-key 'tutorial'
 
Datenbanken können auch direkt mittels der Fauna Query Language verwaltet werden. Dazu kann man mit folgendem Kommando eine Shell öffnen:
 
$ npx fauna-shell shell
npx: Installierte 346 in 10.343s
Connected to https://db.fauna.com
Type Ctrl+D or .exit to exit the shell
> CreateDatabase({name: "tutorial"})
{ ref: Database("tutorial"),
  ts: 1564418847110000,
  name: 'tutorial' }
>
 
Über die Fauna-Shell lassen sich FQL-Anweisungen direkt eingeben: Der FQL-Funktion CreateDatabase wird ein Objekt übergeben. Das Attribut name wählt den Namen der neuen Datenbank. Das Ergebnis der Anweisung ist ein Objekt mit drei Attributen: ref, ts und name. ref ist eine Referenz auf das Datenbankobjekt, ts gibt die Transaktion an und name ist der Name der Datenbank.
In FaunaDB hat man oft mit sogenannten Refs zu tun. Das sind Referenzen auf Datenbankobjekte. Mit ihnen kann man ein Objekt eindeutig referenzieren ohne dass man die eigentlichen Daten des Objekts benötigt. Mit dem folgenden Befehl öffnet man eine Datenbank in der Fauna-Shell:
 
$ npx fauna-shell shell tutorial
 
Ähnlich wie bei MongoDB verwaltet FaunaDB Daten in Instanzen, die Dokumente genannt und in Collections gesammelt werden. In Dokumenten können - ohne festgelegtes Schema - Schlüssel/Wert-Paare abgelegt werden. So kann man im Prinzip alles abspeichern was auch mit JSON-Objekten möglich ist. FaunaDB unterscheidet sich hier nicht von anderen dokumentorientierten Datenbanken wie MongoDB oder CouchDB. Obwohl alle Werte mit einfachen JSON-Typen repräsentiert werden können, gibt es eine Reihe von speziellen Werten mit eigener Semantik. So können Datumswerte als formatierte Datumszeichenketten im ISO-Format gespeichert werden.
Instanzen von Dokumenten können nicht alleinstehend erzeugt werden. Sie gehören stets in eine Collection. Versteht man Dokumente als Zeilen einer relationalen Datenbank-Tabelle, dann entsprechen Collections den Tabellen eines RDBMS. Der Unterschied: Die einzelnen Dokumente einer Collection können völlig unterschiedliche Felder haben - es gibt keine feste Struktur wie bei den Spalten einer Tabelle.

Collections und Dokumente erzeugen

Die folgende Anweisung erzeugt eine Collection für Personen:
 
tutorial> CreateCollection({ name: "people"})
{ ref: Collection("people"),
  ts: 1564424287320000,
  history_days: 30,
  name: 'people' }
 
Die Funktion CreateCollection erhält ein Objekt als Parameter. Mit dem Feld name wird der Name der Collection angegeben. Wenn man diese Collection in FQL verwenden will, liefert die Funktion Collection anhand des Namens eine Referenz darauf:
 
tutorial> Collection("people")
Collection("people")
 
Die Shell zeigt die Ref auf eine solche Collection in der Ausgabe auch einfach als Collection(<name>) an. Mit der Funktion Create werden Instanzen in dieser Collection erzeugt:
 
> Create(Collection("people"), { data: {name:
"Madeleine"} })
{ ref: Ref(Collection("people"), "239248831469847048"),
  ts: 1564424315840000 }
 
Als ersten Parameter erwartet die Funktion die Ref einer Collection. Der zweite Parameter ist wieder ein Objekt. Das Feld data enthält die im Dokument zu speichernden Daten. Als Ergebnis erscheint hier eine neue Instanz mit der Ref Ref(Collection("people", "239248831469847048") die in der Transaktion (ts) 1564424315840000 erzeugt wurde. FaunaDB merkt sich die Historie aller gespeicherten Objekte. Immer wenn sich die Daten eines gespeicherten Dokuments ändern, wird durch eine neue Transaktion eine frische Instanz in der gleichen Collection mit der gleichen ID erzeugt. Man kann über FQL auch auf die Historie eines Objekts zugreifen. Daraus ergibt sich das temporale Datenmodell in FaunaDB und die Möglichkeit ein Dokument in dem Zustand abzufragen, den es zu einem bestimmten Punkt in der Vergangenheit besaß.

Mehr als nur Fremdschlüssel

Eine Ref enthält sowohl die eindeutige ID des Dokuments als auch eine Ref auf die Collection, zu der das Dokument gehört. Es handelt sich also nicht nur um eine ID, die als Primär- oder Fremdschlüssel verwendet werden kann. Mit der Funktion Ref lässt sich eine Referenz auf ein Dokument erzeugen:
 
tutorial> Ref(Collection("people"), "239248831469847048")
Ref(Collection("people"), "239248831469847048")
 
Die Daten des Dokuments werden dabei nicht abgerufen. Auf diese Weise wird sogar nicht verhindert, eine Ref zu einer nicht existierenden Collection und Instanz-ID zu erzeugen:
 
tutorial> Ref(Collection("foobar"), "4711")
Ref(Collection("foobar"), "4711")
 
Für die Datenbank ist das dennoch eine valide Referenz. Mit Get dereferenziert man eine Ref und erhält die Daten:
 
tutorial> Get(Ref(Collection("people"), "239248831469847048"))
{ ref: Ref(Collection("people"), "239248831469847048"),
  ts: 1564436560760000,
  data: { name: ‚Madeleine‘ } }
 
Spätestens beim Dereferenzieren fällt auch auf, ob eine Ref ungültig ist:
 
tutorial> Get(Ref(Collection("foobar"), "4711"))
Error: invalid ref
[ { position: [ 'get' ],
  code: 'invalid ref',
  description: "Ref refers to undefined collection
  'foobar'" } ]
 
Das System kennt keine Collection mit dem Namen foobar und liefert eine Fehlermeldung:
 
tutorial> Get(Ref(Collection("people"), "4711"))
Error: instance not found
[ { position: [],
  code: 'instance not found',
  description: 'document not found.'} ]
 
In diesem Fall ist zwar eine Collection people bekannt, aber es gibt keine Instanz mit der ID 4711.

Index: Dokumente finden

In Datenbank-Anwendungen ist es oft notwendig, Dokumente anhand beliebiger Datenfelder abzurufen. Dazu dient in FaunaDB ein Index. Das nachfolgende Listing zeigt die Vorgehensweise:
 
tutorial> CreateIndex({
  name: "people_by_name",
  source: Collection("people"),
  terms: [{ field: ["data", "name"] }],
  unique: true
  })
{ ref: Index("people_by_name"),
  ts: 1564424819210000,
  active: true,
  serialized: true,
  name: 'people_by_name',
  source: Collection("people"),
  terms: [ { field: [ 'data', 'name' ] } ],
  unique: true,
  partitions: 1 }
 
CreateIndex erzeugt hier für die Collection people einen Index mit dem Namen people_by_name und indexiert das Feld data.name im Dokument.

Index pflegen

Mit der Option unique wird der Index eindeutig gepflegt. Das bedeutet, es kann hier keine mehrfachen Einträge für data.name bei Dokumenten der Collection people geben:
 
tutorial> Create(Collection("people"), { data: {name: "Peter"} })
{ ref: Ref(Collection("people"), "239249990209241605"),
  ts: 1564425420900000,
  data: { name: 'Peter' } }
tutorial> Create(Collection("people"), { data: {name: "Peter"} })
Error: instance not unique
[ { position: [],
  code: 'instance not unique',
  description: 'document is not unique.' } ]
 
Diese Einschränkung gilt ab dem Zeitpunkt an dem der Index erzeugt wurde. Etwaige vorherige Instanzen werden nicht erfasst. Mit Match kann man Einträge in einem Index nachschlagen:
 
tutorial> Match(Index("people_by_name"), "Madeleine")
SetRef({ match: Index("people_by_name"),
terms: 'Madeleine' })
 
Zurückgegeben wird die Ergebnismenge als sogenannte SetRef. In einem Datenbankindex sind möglicherweise sehr viele Einträge. Deshalb liefert FaunaDB lediglich ein solches Beschreibungsobjekt auf die Ergebnismenge und nicht bereits die eigentlichen Daten.
Um an die Daten heranzukommen erzeugt man Pages. Das sind Ausschnitte der gesamten Ergebnismenge die man Stück für Stück abrufen kann. Eine SetRef kann mit der Funktion Paginate in eine Page umgewandelt werden:
 
tutorial> Paginate(Match(Index("people_by_name"),
"Madeleine"))
{ data: [ Ref(Collection("people"), "239249819464368645") ] }
 
Paginate liefert das Page-Objekt: das Feld data ist ein Array der in dieser Page abgerufenen Refs . Die Anzahl der im Array enthaltenen Refs ist abhängig von der gewünschten Page-Größe (Standard 64):
 
tutorial> Paginate(Match(Index("all_people"),
"Madeleine", { size: 100 }))
{ data: [ Ref(Collection("people"), "239249819464368645") ] }
 
In diesem Beispiel wird Page-Größe auf 100 eingestellt. Mit den Optionen after und before kann man eine Page mit den Dokumenten vor oder nach einer angegebenen Ref abrufen:
 
tutorial> Paginate(Match(Index("all_people"),
"Madeleine", {
   after: Ref(Collection("people"),
  "239249819464368645")
  }))
 
Man erhält die letzte Page auch mit before auf null und die erste mit after auf 0.
Index("people_by_name") ist eindeutig. Das bedeutet er liefert sowieso stets nur ein Ergebnis pro Term. Ein Paging wäre dann unnötig. Man kann das Ergebnis deshalb auch mittels Get ermitteln, denn dieses liefert stets das erste Ergebnis einer SetRef:
 
tutorial> Get(Match(Index("people_by_name"),
"Madeleine"))
{ ref: Ref(Collection("people"), "239249819464368645"),
  ts: 1564425258060000,
  data: { name: 'Madeleine' } }
 
Indexe können auch ohne Feldangabe über alle Dokumente einer Collection erzeugt werden:
 
tutorial> CreateIndex({name:"all_people", source: Collection("people")})
{ ref: Index("all_people"),
  ts: 1564425808240000,
  active: true,
  serialized: true,
  name: 'all_people',
  source: Collection("people"),
  partitions: 8 }
 
Als Multi-Modell Datenbank unterstützt FaunaDB unterschiedliche Möglichkeiten zur Modellierung - unter anderem auch ein graphenbasiertes Datenbankmodell.

Graphen-Modelle

Als Beispiel erzeuge ich dafür ein paar weitere Personen und eine neue Collection:
 
tutorial> Foreach(
  ["Birgit", "Manfred", "Alice",
  "Robert"],
  Lambda("name",
  Create(Collection("people"),
  { data: { name: Var("name") } }
  )
  )
)
 
Diese Anweisung zeigt wie man mit einem Array und der Funktion Foreach sehr einfach mehrere Instanzen auf einmal erzeugen kann. Der erste Parameter ist ein Array aus Zeichenketten und der zweite Parameter eine Lambda-Funktion mit einem Parameter. In der Lambda-Funktion wird anhand des Parameters zu jeder Zeichenkette eine neue Instanz in Collection("people") erzeugt. Die Anlehnung an funktionale Programmiertechniken ist FQL deutlich anzusehen. Nun die Collection:
 
tutorial> CreateCollection({name: "likes"})
{ ref: Collection("likes"),
  ts: 1564429558410000,
  history_days: 30,
  name: 'likes' }
Und ein passender Index dafür wird so erstellt:
tutorial> CreateIndex({
  name: "liked_by_liker",
  source: Collection("likes"),
  terms: [{ field: ["data", "liker"] }],
  values: [{ field: ["data", "liked"] }]
})
 
Der Index wird immer aktuell gehalten wenn Instanzen erzeugt, gelöscht oder modifiziert werden. Dazu erhält er die entsprechende Instanz. Er ermittelt daraus die terms und die values. Die terms dienen als Schlüssel des Index und values enthält die zum Schlüssel gespeicherten Daten. Der Index liked_by_liker löst Refs als Schlüssel auf und speichert dazu andere Refs. Dazu holt er aus den Dokumenten der Collection likes als Schlüssel die Ref im Feld liker und als Wert die Ref im Feld liked. Gemeint sind mit diesem Datenmodell Beziehungen zwischen zwei Dingen: A likes B. Listing 1 zeigt ein Beispiel.
Schon im Beispiel mit Foreach und Lambda erinnerte FQL sehr an eine einfache funktionale Programmiersprache. Mit dieser komplexen Anweisung wird das noch deutlicher. Mit Let kann man lokale Variablen binden, die man dann mit Var nutzen kann. In diesem Beispiel werden für die gewünschten sechs Personen Variablen gebunden.
Im Anschluss wird im zweiten Parameter von Let der Ausdruck für den Body definiert: Da nun mehrere Dokumentinstanzen erzeugt werden sollen, im zweiten Parameter des Let jedoch nur ein Ausdruck stehen darf, müssen die einzelnen Create noch mit einem Do umschlossen werden. Mit insgesamt fünf Create werden Beziehungen zwischen den Personen erzeugt (Bild 4).
Ein Graphenmodell mit Beziehungen zwischen Personen (Bild 4)
Quelle: Jochen Schmidt
Das Feld liker erhält immer die Ref der Person die mag und das Feld likedist die gemochte Person. Mit der folgenden Anweisung werden alle Namen der Personen abgerufen die Alice mag:
 
tutorial> Map(
  Paginate(
  Match(Index("liked_by_liker"),
  Select("ref",
  Get(Match(Index("people_by_name"), "Alice"))
  )
  )
   ),
Lambda("p",
   Select(["data", "name"], Get(Var("p")))
   )
)
{ data: [ 'Robert', 'Birgit' ] }
 
Map entspricht wieder der aus vielen Programmiersprachen gewohnten Funktion, welche die Werte eines Array mittels einer Funktion (hier das Lambda) auf andere Werte abbildet. Das Map holt auf diese Weise aus den Ergebnissen (Refs auf Personen) deren Feld data.name ab.
Auch der Zugriff auf den Index weist eine Besonderheit auf: Zuerst wird aus dem Index people_by_name die Ref zu Alice ermittelt. Mit Get dereferenziert man die SetRef des Match() und erhält die Dokument-Instanz. Mit Select("ref", ..) wird das Feld ref der Dokument-Instanz ermittelt. Diese dient nun als Schlüssel in den Index liked_by_liker. Das Ergebnis ist eine SetRef die alle Refs enthält wen oder was Alice mag. Mit Paginate löst man die SetRef in eine Page auf, über die man dann mit Map wandern und die Namen ermitteln kann.
Mit der Funktion Union kann man die SetRefs der Match-Aufrufe auch vereinigen:
 
tutorial> Map(
  Paginate(
  Union(
  Match(Index("liked_by_liker"),
  Select("ref", Get(Match(Index("people_by_name"),
  "Alice")) )
  ),
  Match(Index("liked_by_liker"), Select("ref",
  Get(Match(Index("people_by_name"), "Robert"))
  )
  )
  )
  ),
  Lambda("p",
  Select(["data", "name"], Get(Var("p")))
  )
)
{ data: [ 'Robert', 'Birgit', 'Alice' ] }
 
Das Ergebnis sind nun alle Namen der Leute die sowohl von Alice als auch Robert gemocht werden. Neben Union gibt es natürlich auch Intersection und Differenceum, um Schnitt- und Differenzmengen zu ermitteln.

Modellierung

Diese Modellierung erscheint auf den ersten Blick nicht so besonders zu sein: Auch bei relationaler Datenbankmodellierung kann man Beziehungen zwischen Tabellen mittels einer Zwischentabelle realisieren (Bild 5).
Beziehungen zwischen verschiedenen Dokumenten in unterschiedlichen Collections (Bild 5)
Quelle: Jochen Schmidt
Die Stärke des FaunaDB-Modells zeigt sich sobald weitere Collections hinzukommen (Listing 2). Nun können nicht nur andere Personen sondern auch Dinge wie Bücher, Filme oder Pflanzen geliked werden. Die exakt gleiche Anfrage von vorhin liefert auch die neuen Daten:
 
tutorial> Map(
  Paginate(
  Union(
  Match(Index("liked_by_liker"),
  Select("ref",
  Get(Match(Index("people_by_name"), "Alice"))
  )
  ),
  Match(Index("liked_by_liker"),
  Select("ref",
  Get(Match(Index("people_by_name"), "Robert"))
  )
  )
  )
  ),
  Lambda("p",
  Select(["data", "name"], Get(Var("p")))
  )
)
 
[
  "Birgit",
  "Robert",
  "Alice",
  "Bücher",
  "Filme"
]
 
Einige der bislang gezeigten Datenbankanweisungen sind lang und komplex. Es wäre schön wenn es dazu eine Möglichkeit gäbe diese Komplexität in neuen Abstraktionen zu vereinfachen. Das Mittel der Wahl ist bei FQL die Custom Function. Wenn man bestimmte Ausdrücke in FQL immer wieder benötigt, kann man sich mit CreateFunction eigene Funktionen definieren:
 
tutorial> CreateFunction({
  name: "GetLikesByName",
  body: Query(
  Lambda("name",
  Match(
  Index("liked_by_liker"),
  Select("ref",
  Get(
    Match(
    Index("people_by_name"),
    Var("name")
    )
  )
  )
  )
  )
   )
 }
)
 
Mit dieser Funktion kann der Query mit Union so ausgeführt werden:
 
tutorial> Map(
  Paginate(
  Union(
  Call("GetLikesByName", "Alice"),
  Call("GetLikesByName", "Robert")
  )
  ),
  Lambda("p",
  Select(["data", "name"], Get(Var("p")))
  )
)
 
[
  "Birgit",
  "Robert",
  "Alice",
  "Bücher",
  "Filme"
]
 
Das Beispiel zeigt, dass die Custom Functions nicht wie eingebaute Funktionen aufgerufen werden können. Stattdessen benötigt man die Funktion Call.

GraphQL in FaunaDB

FQL ist eine mächtige Sprache. Wer die volle Funktionalität einer FaunaDB nutzen möchte, kommt darum nicht herum. Je nach Anwendungsfall sind jedoch andere Anfragesprachen besser geeignet. Seit kurzem ist es möglich auch mit GraphQL auf FaunaDB-Datenbanken zuzugreifen. Doch nicht nur das: Man kann ein eigenes GraphQL-Schema in die Datenbank importieren und aus diesem automatisch das Datenbank-Schema erzeugen lassen.
Dieses Feature macht FaunaDB Cloud besonders interessant für Webentwickler. GraphQL ersetzt hierbei eine sonst oft auf REST basierende Web-API. Üblicherweise werden GraphQL-Schema und Queries von den Frontendentwickler an den konkreten Bedarf des Frontends entworfen. Backendentwickler realisieren dann die Web-API indem Resolver implementiert werden. In diesen Resolvern kann er auf andere Dienste oder auch auf Datenbanken zugreifen.
Mit dem GraphQL-Feature in FaunaDB könnten manche Webanwendungen sogar ohne spezifisches Backend auskommen. Wenn es tatsächlich nur darum geht über GraphQL Daten in einer Datenbank abzurufen und zu persistieren, dann wäre kein Backend-Code notwendig. Inwiefern das wirklich möglich ist soll ein Beispiel demonstrieren.

Custom Direktiven und Einschränkungen

Eines gleich vorweg: Faunas GraphQL-API besitzt zum aktuellen Zeitpunkt noch eine Reihe von Einschränkungen.
Momentan ist diese API nur im Fauna Cloud-Angebot des Anbieters möglich. Grundsätzlich soll später zwar auch die On Premise-Version der Datenbank entsprechend erweitert werden, wer aber schon heute damit experimentieren will ist auf das Cloud-Angebot angewiesen.
Weiterhin gibt es einige Einschränkungen bei den angebotenen GraphQL-Features die in importierten Schemas genutzt werden können:
  • Keine Custom Direktiven
  • Keine Custom Skalartypen
  • Keine Schnittstellen
  • Keine Union Types
  • Keine Subscriptions
 
Insbesondere das Fehlen von Schnittstellen und Union Types ist Schade - viele GraphQL-APIs profitieren von diesen Möglichkeiten enorm. Die Fauna-Entwickler sprechen zwar davon, dass dies in Zukunft möglich sein soll, aber bislang muss man darauf leider verzichten.
Eine weniger schmerzhafte Einschränkung: Die Namen der Felder und Typen dürfen nicht mit einem _ beginnen, da Fauna diesen Präfix für eigene Namen reserviert.

Ein GraphQL-Schema für Blogs

Als Beispiel definiere ich ein einfaches GraphQL-Schema für ein Blog mit Kommentaren:
 
type Post {
  title: String!
  slug: String! @unique
  @index(name: "slug")
  created: Time!
  author: String!
  content: String!
  comments: [Comment!] @relation
}
type Comment {
  author: String!
  created: Time!
  content: String!
  post: Post!
}
type Query {
  allPosts: [Post!]!
  postBySlug(slug: String) : Post!
  @resolver(name: "findPostBySlug")
}
 
Jedes Blog-Post wird als Typ Post beschrieben und besitzt einen Titel, einen Zeitstempel der Erstellung, Autor und Inhalt. Der slug ist der in der URL verwendete Name und kann beispielsweise aus dem Titel des Posts abgeleitet werden. Eine Liste aus Comment enthält die Kommentare, die zu diesem Blog-Post abgegeben wurden. Die Direktiven @unique, @index und @relation sind FaunaDB-spezifisch: Mit @unique kann ein Feld als eindeutig markiert werden.

One-to-many Relation

Ein erstellter Index in der FaunaDB wird dann ebenfalls als unique ausgezeichnet. Mit @index kann man den Namen des erstellten Index in FaunaDB beeinflussen. Die Direktive @relation kennzeichnet ein Feld vom Listen-Typ als bidirektionale One-to-many Relation zwischen Post und Comment. Lässt man die Direktive weg speichert FaunaDB eine Liste aus IDs statt einer echten Beziehung zwischen den Dokumenten.
Aus allen Feldern des Typs erstellt FaunaDB beim Import des Schemas eine Collection mit dem Namen Post und auch eine Reihe von Mutationen mit welchen man Post-Instanzen erzeugen, löschen und verändern kann (createPost, deletePost, updatePost).
All das trifft genauso auf den Typ Comment hinzu. Dieser kommt jedoch ohne besondere Direktiven aus.
Der Typ Query ist der Top-Level-Typ des GraphQL-Schemas. Das Beispielschema erlaubt mit dem Feld allPosts alle Posts abzurufen. Außerdem kann man mit postBySlug ein bestimmtes Post anhand seines eindeutigen Slug finden. Die Direktive @resolver ist wieder ein Spezifikum der FaunaDB: Man kann damit den Namen einer FQL-Funktion angeben mit der das Ergebnis berechnet werden soll. Man kann @resolver insbesondere auch für Mutationen einsetzen.
Zum Importieren logt man sich in das FaunaDB-Dashboard (Bild 6) ein und wählt in der Seitenleiste den Listenpunkt GraphqQL. Es erscheint der GraphQL-Playground mit dem es möglich ist Anfragen an die Datenbank direkt über das Webinterface einzugeben. Neben dem Titel gibt es die Schaltflächen Update Schema und Override Schema; damit kann man eine Schema-Datei hochladen. Update ergänzt ein eventuell bestehendes Schema und Override baut es vollständig neu auf.
Das Dashboard der FaunaDB Cloud (Bild 6)
Quelle: Jochen Schmidt
Ist der Import erfolgreich, dann kann man im Playground direkt damit arbeiten. Auf der rechten Seite befinden sich Tabs mit den Beschriftungen Docs und Schema. Ein Klick auf Schema zeigt das vollständige von FaunaDB generierte GraphQL-Schema. Man sieht, dass die vorher definierten Typen noch ergänzt wurden:
 
type Post {
  author: String!
  _id: ID!
  slug: String!
  content: String!
  title: String!
  created: Time!
  comments(
  _size: Int
  _cursor: String
  ): CommentPage!
  _ts: Long!
}
 
type Comment {
  post: Post!
  author: String!
  _id: ID!
  content: String!
  created: Time!
  _ts: Long!
}
 
Sowohl Post als auch Comment haben die zusätzlichen Felder _id und _ts erhalten. Damit wird die eindeutige ID des Dokuments und die Transaktion in der diese Instanz erzeugt wurde repräsentiert. Die Relation comments ist kein Listen-Typ mehr sondern wurde in einen Typ CommentPage umgeschrieben. Dieser sieht so aus:
 
type CommentPage {
  data: [Comment]!
  after: String
  before: String
}
 
FaunaDB führt also automatisch ein Paging ein. Statt dem bei der verbreiteten GraphQL-Bibliothek Relay gängigen Postfix-Edge wird hier Page verwendet. Ebenso sind die referenzierten Typen über data abrufbar statt node wie bei Relay. Eine Kompatibilität des generierten Schemas zu Relay ist zwar nicht notwendig, aber ein in der GraphQL-Community weit verbreitetes Muster für Paging. after und before sind Cursor-Repräsentationen die auf die nachfolgende oder vorhergehende Page verweisen. Diese Cursor kann man als Parameter an das Feld comments im Typ Post angeben.

Inhalte im Playground anlegen

Mit der folgenden Graphql-Mutation kann man ein Blog-Post anlegen:
 
mutation AddPost($title: String!, $slug: String!) {
  createPost(
  data: {
  title: $title,
  slug: $slug,
  created: "2019-07-31T14:32:11.000Z",
  author: "JS",
  content: "Lorem Ipsum..." }
  ) {
  _id
  _ts
  }
}
 
Bild 7 zeigt das Ergebnis der Ausführung im GraphQL-Playground.
Die GraphQL-Mutation createPost (Bild 7)
Quelle: Jochen Schmidt
Mit einem einfachen Query kann man alle Posts abrufen:
 
{
  allPosts {
  data {
  title
  }
  }
}
 
Bild 8 zeigt wieder das Ergebnis im Playground.
Die GraphQL-Query allPosts (Bild 8)
Quelle: Jochen Schmidt
Die GraphQL-Mutation deletePost (Bild 9)
Quelle: Jochen Schmidt
Um das Post wieder zu löschen genügt wieder eine Mutation (Bild 9):
 
mutation DeletePost ($id: ID!) {
  deletePost(id: $id) {
  _ts
  }
}
 
Man benötigt lediglich die _id des Posts. Möchte man ein bestehendes Post verändern, dann muss man neben der gewünschten ID auch den von FaunaDB erzeugten Input-Typ PostInput mit angeben (Bild 10):
Die GraphQL-Mutation updatePost (Bild 10)
Quelle: Jochen Schmidt
 
mutation UpdatePost ($id: ID!) {
  updatePost(id: $id, data: {
  title: "Foobar"
  slug: "ein-blogpost"
  created: "2019-07-31T15:00:01.000Z"
  content: "..."
  author: "JS"
  }) {
  _id
  title
  content
  }
}
 
Man kann das Dokument also nicht nur partiell ändern, denn der Input-Type enthält alle Felder außer _id und _ts:
 
input PostInput {
  title: String!
  slug: String!
  created: Time!
  author: String!
  content: String!
  comments: PostCommentsRelation
}
 
Der Typ PostCommentsRelation spiegelt die Relation zwischen Post und Comment wieder:
 
input PostCommentsRelation {
  create: [CommentInput]
  connect: [ID]
  disconnect: [ID]
}
 
Man kann damit bestehende Kommentare anhand ihrer IDs in Beziehung setzen (connect), Beziehungen auflösen (disconnect) oder sogar neue Kommentare direkt erzeugen (create). Diese Verwendung zeigt sehr deutlich, wie man komplexe Zusammenhänge schon mittels erstaunlich einfacher GraphQL-Typen modellieren kann.
Im Normalfall wird man jedoch kein Post modifizieren wollen nur um Kommentare hinzuzufügen. Sinnvoller ist es die Beziehung beim Erstellen eines Kommentars zu erzeugen:
 
mutation AddComment ($author: String!,
     $message: String!
     $post: CommentPostRelation!) {
  createComment(data: {
  author: $author,
  created: "2019-08-05T00:00:00.000Z",
  content: $message,
  post: $post
  }) {
  _id
  created
  post {
  slug
  }
  }
}
 
Mit dieser Mutation wird ein neuer Kommentar erzeugt. Die CommentPostRelation beschreibt die Beziehung zu einem Post:
 
input CommentPostRelation {
  create: PostInput
  connect: ID
}
 
Mit dem auch automatisch erstellten Feld create wäre es möglich, ein Post zu einem Kommentar neu zu erzeugen - ein in der Praxis nicht nützlicher Anwendungsfall. Die Variablen der Mutation werden deshalb folgendermaßen belegt:
 
{
  "author": "js@neonsqua.re",
  "message": "Another comment",
  "post": {"connect": "238594635270717955"}
}
 
Ein Kommentar-Autor hinterlässt demnach eine Nachricht zu einem Post, das mit connect verbunden wird.

GraphQL-Resolver in FQL implementieren

Damit das Schema vollständig implementiert ist, muss noch das Feld postBySlug des Query-Typs implementiert werden. Dazu definiert man in einer FQL-Shell folgende Funktion:
 
CreateFunction({
  name: "findPostBySlug",
  body: Query(
  Lambda("slug",
  Get(Match(Index("unique_Post_slug"),
    Var("slug")
    )
  )
  )
  )
})
 
Die Funktion erhält den Slug als einzigen Parameter. Über den Index unique_Post_slug (automatisch erzeugt durch die Schema-Definition) kann man anhand des Slug eine Ref der zugehörigen Posts finden. Mit Get wird die Ref aufgelöst.

FaunaDB in GatsbyJS einbinden

Dank dem importierten Schema und der FQL-Funktion findPostBySlug ist die GraphQL-API der neuen FaunaDB bereits vollwertig nutzbar. Es gibt viele Möglichkeiten, anhand dieser API ein Blog zu erstellen. Eine davon ist der statische Seitengenerator GatsbyJS (https://gatsbyjs.org). Man erzeugt eine neue Gatsby-Seite am einfachsten über das Gatsby-CLI:
 
> npx gatsby-cli new gatsby-fauna-blog
 
Danach hat man ein vollständiges Setup um mit Gatsby Seiten zu generieren.

Abhängigkeiten anpassen

Für das Blog werden einige zusätzliche Bibliotheken und Plugins benötigt. Am einfachsten ist es, die durch das Gatsby-CLI erzeugte Datei package.json einfach durch die in Listing 3 zu ersetzen. Danach reicht ein Aufruf von yarn oder npm install, um alle notwendigen Abhängigkeiten zu installieren. Den Entwicklungsserver kann man danach folgendermaßen starten:
 
> cd gatsby-fauna-blog
> yarn run develop
 
Danach ist unter http://localhost:8000 die Seite in ihrer aktuellen Fassung sichtbar. Ändert man Quelldateien, dann wird die Seite live angepasst. Unter http://localhost:8000/___graphql befindet sich ein GraphQL-Playground. Gatsby macht sich GraphQL zunutze, um beliebige Datenquellen anzubinden und daraus eine statische Website zu generieren, die man bei jedem Hoster veröffentlichen kann.

Fauna GraphQL als Datenquelle einbinden

Datenquellen werden bei GatsbyJS mit Gatsby-Source-Plugins in der Datei gatsby-config.js eingebunden. Für das Blog sieht das dann wie in Listing 4 aus. Gegenüber der Standardkonfiguration wurde diese um folgende Plugins ergänzt:
  • gatsby-source-graphql-universal: Ein Source-Plugin mit dem man eine externe GraphQL-API einbinden kann. Das Blog-Schema wird hier unter dem Typnamen (typeName) Fauna und dem Feld fauna eingebunden. Zum Zugriff benötigt man die URL des FaunaDB-Graphql-Endpunkts und einen Authorization-Header mit dem Secret Key.
  • gatsby-transformer-remark: Mit diesem Plugin kann man Markdown nach HTML transformieren.
  • gatsby-styled-components: Die React-Komponenten sollen mit Styled Components gestyled werden - dazu kann man dieses Plugin integrieren.
 
Startet man nun GatsbyJS und öffnet den GraphQL-Explorer unter http://localhost:8000/___8000, dann taucht in der Seitenleiste der neue Eintrag fauna auf (Bild 11)
Das GraphiQL-Interface in Gatsby mit eingebundenem Fauna-Schema (Bild 11)
Quelle: Jochen Schmidt
Man kann nun in GatsbyJS GraphQL-Queries ausführen, die dann an FaunaDB weitergeleitet werden.

Die Index-Seite

Auf der Index-Seite des Blogs soll eine Auflistung aller Posts mit Links zu den einzelnen Artikeln erzeugt werden. Dazu implementiert man src/pages/index.js wie folgt:
 
import React from "react"
import { graphql, Link } from "gatsby"
import { Layout, Title } from "../components"
const IndexPage = ({ data }) => (
  <Layout>
  <Title>Weblog</Title>
  {data.fauna.allPosts.data.map(p => (
  <h2>
  <Link to={p.slug}>{p.title}</Link>
  </h2>
  ))}
  </Layout>
)
export default IndexPage
export const query = graphql`
  query GetBlogIndex {
  fauna {
  allPosts {
  data {
  slug
  title
  }
  }
  }
  }
`
 
Die Komponente IndexPage erhält die GraphQL-Daten über eine Prop data. Diese wird durch den GraphQL-Query in der exportierten Variable query befüllt. Der Query ruft alle Posts ab und holt sich jeweils den slug und den title. Aus diesen Informationen rendert IndexPage eine Liste von Überschriften mit dem Titel als Text und dem slug als Linkziel. Layout ist eine Komponente in der das Basis-Layout der Website definiert wird. Sie liegt unter src/components/Layout.js (Listing 5) und wird über das Sammelmodul src/components/index.js (Listing 6) exportiert. Die Links verweisen jedoch noch ins Leere, da noch keine Seiten für die einzelnen Posts erzeugt werden.
GatsbyJS transformiert Dateien im Ordner src/pages/ zu HTML-Seiten. Man kann sie aber auch programmatisch erzeugen. Dazu muss man in der Datei gatsby-node.js die Funktion createPages implementieren (Listing 7).
In der Funktion wird ein GraphQL-Query ausgeführt, der die Slugs aller Posts abholt. Daraus wird mit createPage jeweils eine Seite erzeugt. Wichtig dabei: Unter context wird der Slug an die Komponente mit der die Seite gerendert wird weitergegeben. Da die Seiten-Komponenten ihren eigenen GraphQL-Query ausführen, benötigen sie irgendeine Information um das zugehörige Post ermitteln zu können.
Der Post-Content soll als Markdown erfasst werden können. Man könnte dazu eine der vielen Markdown-Konverter für JavaScript benutzen. Allerdings gibt es auch speziell für Gatsby bereits das Plugin gatsby-transform-remark. Dieses Plugin transformiert Knoten im Gatsby-Datenmodell nach Markdown. Typischerweise entstehen diese Knoten aus Dateien mit der Endung .md im Dateisystem. Da das Markdown hier jedoch aus der FaunaDB kommt, muss man die Gatsby-Knoten ebenfalls noch erzeugen. Dazu implementiert man die Funktion sourceNodes in gatsby-nodes.js (Listing 8).
Über einen GraphQL-Query werden wieder alle Posts abgerufen. Mit createNode wird ein Knoten mit einem mediaType text/markdown mit allen notwendigen Daten erzeugt. Diese Knoten entsprechen den sonst üblichen Dateien mit .md-Endung. Das gatsby-transform-remark-Plugin kann diese Knoten dann in MarkdownRemark-Knoten transformieren. Damit man die MarkdownRemark-Knoten später anhand des Slug wiederfinden kann, erzeugt man mit der Funktion onCreateNode noch ein zusätzliches Feld (Listing 9).

Die BlogPost-Seitenkomponente

Unter src/templates/BlogPost.js ist die Seiten-Komponente, die für das Rendern der BlogPosts zuständig ist (Listing 10). Der als query exportierte GraphQL-Query erhält die Variable $slug als Parameter. Das ist der Wert, der über die context-Option weitergegeben wurde. Der Slug wird hier in postBySlug genutzt, um das zu dieser Seite assoziierte Post abzurufen. Es werden _id, title, author, created und slug benötigt.
Zusätzlich wird noch das Feld markdownRemark abgerufen. Dieses Feld ruft MarkdownRemark-Knoten im Gatsby-Datenmodell ab. In diesem Fall den Knoten, in welchem es ein Feld mit einem passenden Wert für slug gibt. In MarkdownRemark-Knoten kann ein Feld html abgerufen werden. Dabei wird aus dem Markdown der HTML-Code erzeugt und als Ergebnis übermittelt. In React kann man rohes HTML mit einer einfachen Hilfskomponente ausgeben:
 
import React from "react"
export default function Html(props) {
  return (
  <div
  dangerouslySetInnerHTML={{
  __html: props.markup,
  }}
  ></div>
  )
}
 
Um einen Kommentar zu einem Post zu hinterlassen benötigt man auf jeder Post-Seite ein Webformular mit dem die notwendigen Daten erfasst werden können (Bild 12). Die entsprechende Komponente wurde als CommentForm bereits eingebunden und sieht so aus (Listing 11).
So präsentiert sich das Kommentar-Formular (Bild 12)
Quelle: Jochen Schmidt
Dabei handelt es sich um ein sehr einfaches Webformular mit drei Eingabefeldern für Name, E-Mail, URL und einem Textfeld für die eigentliche Nachricht. Beim Absenden werden die Daten des Formulars mit einem http-POST an den Endpunkt /api/comment gesendet.
Diesen Endpunkt kann man mit einer beliebigen Server-Sprache implementieren. Für diesen Artikel schreibe ich eine einfache Serverless-Lambda in TypeScript, um sie dann mit dem Cloud-Dienst Now (https://now.sh) zu deployen.
Dazu legt man lediglich im Toplevel-Ordner des Gatsby-Projekts einen Ordner api an und erstellt darin eine TypeScript-Datei mit Namen comment.ts (Listing 12).
In dieser Datei befindet sich die GraphQL-Mutation ADD_COMMENT mit der ein neuer Kommentar angelegt werden kann. Außerdem eine Funktion die einen NowRequest und eine NowResponse als Parameter erhält. Sie führt die Mutation anhand der Formulardaten aus und leitet danach auf den Slug der gerade kommentierten Seite um. Damit wird die Seite neu geladen. Zum Zugriff auf den FaunaDB-GraphQL-Endpunkt wird ein in src/fauna/index.js konfigurierter Apollo-Client eingebunden (Listing 13).
Die Kommentare sollen einfach als Liste von Boxen mit ihren Inhalten ausgegeben werden (Listing 14). Die CommentList-Komponente besitzt State, denn die Kommentare sollen nicht direkt ausgegeben werden. Stattdessen wird zuerst eine Schaltfläche mit der Aufschrift Show Comments angezeigt. Sobald der Besucher auf diese Schaltfläche klickt, wird die an die Komponente übergebene Funktion loadComments aufgerufen. Diese in BlogPost definierte Funktion führt erneut einen GraphQL-Query aus, um die Kommentare abzuholen. Doch warum nicht gleich die Kommentare anzeigen? Gatsby ist ein statischer Seitengenerator. Das gatsby-source-graphql-universal-Plugin führt die GraphQL-Anfragen eigentlich während des Erzeugens der Website aus. Im ausgelieferten HTML muss deshalb eigentlich kein GraphQL-Query und damit auch keine Datenbankabfrage mehr erfolgen. Für die Inhalte der BlogPosts ist das auch die sinnvollste Strategie. Die Gatsby-Seite muss eben stets neu generiert werden, wenn ein neuer Post geschrieben wurde. Wenn man den GraphQL-Query in src/templates/BlogPost.js gleich mit Kommentaren ausführt, dann könnte man die Kommentare allerdings ebenfalls direkt in das resultierende statische HTML rendern. Neue Kommentare würden dann jedoch erst sichtbar werden wenn die Seite mit Gatsby neu gebaut wird.
Insbesondere sehen Besucher der Seite, die einen Kommentar hinterlassen, ihren eigenen Kommentar erst, wenn die Seite das nächste Mal neu generiert wird. Falls man sowieso nur moderierte Kommentare zulassen möchte, ist das kein Problem. Die obige Implementierung sieht jedoch vor, dass die Besucher der Seite stets die aktuell in der Datenbank befindlichen Kommentare sehen. Deshalb muss innerhalb des Browsers des Besuchers eine Anfrage an den FaunaDB-Server erfolgen.
Damit jedoch nicht jeder Seitenbesuch automatisch zu einer dynamischen Datenbankanfrage führt, wird diese erst durchgeführt wenn der Besucher auf Show Comments klickt. Je nach konkretem Anwendungsfall kann man also wählen zwischen:
  • Moderierte Kommentare: Alles statisch in Gatsby erzeugen
  • Dynamische Kommentare nach Nutzerklick abrufen
  • Dynamische Kommentare direkt nach Besuch eines BlogPost abrufen.
 
Jeder Anwendungsfall kann der Entwickler somit einen unterschiedlichen Trade-Off aus kostensparender extrem schneller Website und nutzerfreundlicher Echtzeit-Darstellung von Kommentaren wählen. Für die Implementierung sind das nur minimale Unterschiede: Die folgende CommentList rendert die Kommentare einfach statisch direkt:
 
export default function CommentList(props) {
  return (
  <StyledCommentList>
  <h3>Comments</h3>
  {props.post.comments.data.map(c => (
  <Comment data={c} />
  ))}
  </StyledCommentList>
  )
}
 
In src/templates/BlogPost kann man dann einfach als query die Variante queryWithComments benutzen und erhält so bereits zum Zeitpunkt der Seitengenerierung alle Kommentare.
Eine weitere interessante Ergänzung wäre die Kommentare statisch zu rendern, aber nach jedem neuen Kommentar automatisch ein neues Deployment der Seite anzustoßen. Der Besucher sieht seinen Kommentar dann zwar nicht sofort, aber sie er-scheinen dennoch auch ohne Moderation nach kurzer Zeit auf der Seite.

Fazit

Es gibt immer mehr Angebote um Datenbanken in Serverless-Cloud-Anwendungen einzusetzen. FaunaDB zeichnet sich mit seiner Transaktionssicherheit als technologisch sehr interessante Alternative ab. Gleichzeitig hat der Anbieter mit seinem Pay-as-you-Go-Tarif ein sehr attraktives Preisschema. Bei den anderen Angeboten entstehen üblicherweise feste monatliche Pauschalen.
Die GraphQL-API ist noch sehr neu, aber ermöglich eine besonders leichte Integration in bestehende GraphQL-Anwendungen. Dies ist jedoch kein Alleinstellungsmerkmal der FaunaDB. Auch Angebote wie GraphCMS, Prisma, Hasura oder 8base bieten Entwicklern GraphQL-APIs für Datenbankdienste an. Man kann aber mit Fug und Recht behaupten, dass ein Cloud-Datenbankanbieter heutzutage einfach eine GraphQL-API anbieten muss um konkurrenzfähig zu bleiben.
Ganz neu ist auch das feingranulare Role-Based-Access-Control. Damit ist es möglich Rollen zu definieren die lediglich auf einzelne Collections Zugriff lesenden oder schreibenden Zugriff erhalten. So könnte man einen Secret-Key für eine Rolle definieren die ausschließlich neue Kommentare anlegen kann. Wenn der Formular-Endpunkt dann diesen Key benutzt, dann können über diese Verbindung keine unbefugten Zugriffe erfolgen.
Der Einstieg in die Datenbankentwicklung mit FaunaDB ist mit wenig Aufwand verbunden. Man muss definitiv kein Datenbankprofi sein. Durch die besondere Flexibilität im Deployment-, Abrechnungs-, API- und Datenmodell kombiniert mit dem Role-Based-Access-Control könnte sich FaunaDB durchaus als gut gegen seine Mitbewerber behaupten.

Listings zu Artikel

Listing 1: Beziehungen zwischen zwei Dingen

tutorial> Let(
  {
  peter: Select("ref",
   Get(Match(Index("people_by_name"), "Peter"))),
  madeleine: Select("ref",
   Get(Match(Index("people_by_name"), "Madeleine"))),
  birgit: Select("ref",
   Get(Match(Index("people_by_name"), "Birgit"))),
  manfred: Select("ref",
   Get(Match(Index("people_by_name"), "Manfred"))),
  alice: Select("ref",
   Get(Match(Index("people_by_name"), "Alice"))),
  robert: Select("ref",
   Get(Match(Index("people_by_name"), "Robert")))
  },
  Do(
  Create(Collection("likes"),
  { data: { liker: Var("peter"), liked: Var("madeleine") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("alice"), liked: Var("robert") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("robert"), liked: Var("alice") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("alice"), liked: Var("birgit") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("manfred"), liked: Var("peter") } }
  ),
  )
)
Listing 2: Weitere Collections

tutorial> CreateCollection({name: "things"})
tutorial> CreateIndex({
  name: "things_by_name",
  source: Collection("things"),
  terms: [{ field: ["data", "name"] }],
  unique: true
  })
tutorial> Do(
  Create(Collection("things"), { data: { name: "Bücher" } }),
  Create(Collection("things"), { data: { name: "Filme" } }),
  Create(Collection("things"), { data: { name: "Pflanzen" } })
)
tutorial> Let(
  {
  alice: Select("ref",
   Get(Match(Index("people_by_name"), "Alice"))),
  robert: Select("ref",
   Get(Match(Index("people_by_name"), "Robert"))),
  buecher: Select("ref",
   Get(Match(Index("things_by_name"), "Bücher"))),
  filme: Select("ref",
   Get(Match(Index("things_by_name"), "Filme")))
  },
  Do(
  Create(Collection("likes"),
  { data: { liker: Var("alice"), liked: Var("buecher") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("alice"), liked: Var("filme") } }
  ),
  Create(Collection("likes"),
  { data: { liker: Var("robert"), liked: Var("filme") } }
  )
  )
)
Listing 3: Ersatz für package.json

{
  "name": "gatsby-fauna-blog",
  "private": true,
  "description": "A simple starter to get up and developing quickly with Gatsby",
  "version": "0.1.0",
  "author": "Jochen H. Schmidt <jo@jschmidt.pro>",
  "dependencies": {
  "apollo-boost": "^0.4.3",
  "apollo-cache-inmemory": "^1.6.2",
  "apollo-link-http": "^1.5.15",
  "babel-plugin-styled-components": "^1.10.6",
  "gatsby": "2.13.1",
  "gatsby-image": "^2.2.6",
  "gatsby-plugin-manifest": "^2.2.3",
  "gatsby-plugin-offline": "^2.2.4",
  "gatsby-plugin-react-helmet": "^3.1.2",
  "gatsby-plugin-sharp": "^2.2.8",
  "gatsby-plugin-styled-components": "^3.1.2",
  "gatsby-source-filesystem": "^2.1.5",
  "gatsby-source-graphql-universal": "^3.1.4",
  "gatsby-transformer-remark": "^2.6.8",
  "gatsby-transformer-sharp": "^2.2.4",
  "node-fetch": "^2.6.0",
  "polished": "^3.4.1",
  "prop-types": "^15.7.2",
  "react": "^16.8.6",
  "react-dom": "^16.8.6",
  "react-helmet": "^5.2.1",
  "styled-components": "^4.3.2"
  },
  "devDependencies": {
  "@now/node": "^0.12.3",
  "prettier": "^1.18.2"
  },
  "keywords": [
  "gatsby"
  ],
  "license": "MIT",
  "scripts": {
  "build": "gatsby build",
  "develop": "gatsby develop",
  "format": "prettier --write src/**/*.{js,jsx}",
  "start": "npm run develop",
  "serve": "gatsby serve",
  "test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\""
  }
}
Listing 4: gatsby-config.js

const {
  FAUNA_SECRET,
} = require("./src/fauna")
module.exports = {
  siteMetadata: {
  title: `Fauna Weblog`,
  description: `A weblog hosted with FaunaDB`,
  author: `Jochen H. Schmidt`,
  },
  plugins: [
  `gatsby-plugin-react-helmet`,
  {
  resolve: `gatsby-source-filesystem`,
  options: {
  name: `images`,
  path: `${__dirname}/src/images`,
  },
  },
  {
  resolve:
  "gatsby-source-graphql-universal",
  options: {
  typeName: "Fauna",
  fieldName: "fauna",
  url:
  "https://graphql.fauna.com/graphql",
  headers: {
  Authorization:
  `Basic ${FAUNA_SECRET}`,
  },
  },
  },
  `gatsby-transformer-remark`,
  `gatsby-transformer-sharp`,
  `gatsby-plugin-sharp`,
  `gatsby-plugin-styled-components`,
  {
  resolve: `gatsby-plugin-manifest`,
  options: {
  name: `gatsby-fauna-blog`,
  short_name: `fauna-blog`,
  start_url: `/`,
  background_color: `#663399`,
  theme_color: `#663399`,
  icon: `src/images/gatsby-icon.png`,
  },
  },
  ],
}
Listing 5: src/components/Layout.js

import React from "react"
import GlobalStyles from "./GlobalStyles"
import styled from "styled-components"
const StyledLayout = styled.div`
  padding: 1.5em;
  max-width: 960px;
  margin: auto;
`
export default function Layout(props) {
  return (
  <StyledLayout>
  <GlobalStyles />
  {props.children}
  </StyledLayout>
  )
}
Listing 6: src/components/index.js

export { default as Layout } from "./Layout"
export { default as Title } from "./Title"
export { default as Body } from "./Body"
export { default as Html } from "./Html"
export {
  default as CommentForm,
} from "./CommentForm"
export {
  default as CommentList,
} from "./CommentList"
Listing 7: gatsby-node.js

const path = require("path")
exports.createPages = async ({
  graphql,
  actions,
}) => {
  const { createPage } = actions
  const blogPostTemplate = path.resolve(
  `src/templates/BlogPost.js`
  )
  const result = await graphql(
  `
  query loadPagesQuery {
  fauna {
  allPosts {
  data { 
  slug
  }
  }
  }
  }
  `,
  { limit: 1000 }
  )
  if (result.errors) {
  throw result.errors
  }
  result.data.fauna.allPosts.data.map(p => {
  createPage({
  path: `${p.slug}`,
  component: blogPostTemplate,
  context: {
  slug: p.slug,
  },
  })
  })
}
Listing 8: Funktion sourceNodes

const { client } = require("./src/fauna")
const gql = require("graphql-tag")
const crypto = require("crypto")
exports.sourceNodes = async ({
  actions,
}) => {
  const { createNode } = actions
  const response = await client.query({
  query: gql`
  query loadPagesQuery {
  allPosts {
  data {
  _id
  title
  content
  author
  slug
  }
  }
  }
  `,
  })
  response.data.allPosts.data.forEach(
  datum => createNode(processDatum(datum))
  )
  return
}
function processDatum(post) {
  return {
  slug: post.slug,
  postId: post._id,
  id: `FAUNA-${post._id}`,
  children: [],
  internal: {
  mediaType: "text/markdown",
  type: "FaunaPost",
  content: post.content,
  contentDigest: crypto
  .createHash("md5")
  .update(post.content)
  .digest("hex"),
  description: "Fauna Blog Post",
  },
  }
}
Listing 9: Funktion onCreateNode

exports.onCreateNode = ({
  node,
  actions,
  getNode,
}) => {
  const {
  createNode,
  createNodeField,
  } = actions
  if (
  node.internal.type === "MarkdownRemark"
  ) {
  let parent = getNode(node.parent)
  createNodeField({
  node,
  name: "slug",
  value: parent.slug,
  })
  }
}
Listing 10: src/templates/BlogPost.js

import React from "react"
import {
  Title,
  Html,
  CommentForm,
  CommentList,
  Layout,
} from "../components"
import { withGraphql } from "gatsby-source-graphql-universal"
import { graphql } from "gatsby"
export const query = graphql`
  query GetBlogPost($slug: String!) {
  fauna {
  postBySlug(slug: $slug) {
  _id
  title
  author
  created
  slug
  }
  }
  markdownRemark(
  fields: { slug: { eq: $slug } }
  ) {
  html
  }
  }
`
export default withGraphql(function BlogPost(
  props
) {
  const post = props.data.fauna.postBySlug
  function loadComments() {
  return props.graphql("fauna", {
  query: queryWithComments,
  variables: {
  slug: post.slug,
  },
  })
  }
  return (
  <Layout>
  <article>
  <Title>{post.title}</Title>
  <h4>{post.author}</h4>
  <Html
  markup={
  props.data.markdownRemark.html
  }
  />
  <CommentForm postId={post._id} />
  <CommentList
  post={post}
  loadComments={loadComments}
  />
  </article>
  </Layout>
  )
})
export const queryWithComments = graphql`
  query GetBlogPostWithComments(
  $slug: String!
  ) {
  fauna {
  postBySlug(slug: $slug) {
  _id
  title
  author
  created
  slug
  comments {
  data {
  _id
  author
  content
  created
  }
  }
  }
  }
  markdownRemark(
  fields: { slug: { eq: $slug } }
  ) {
  html
  }
  }
`
Listing 11: src/components/CommentForm.js

import React, { useState } from "react"
import styled from "styled-components"
const Form = styled.form`
  margin-top: 2em;
  border-top: 1px solid #efefef;
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  grid-gap: 10px;
  h3 {
  margin: 0;
  padding-top: 10px;
  grid-column: 1 / span 3;
  align-self: center;
  }
  textarea {
  border: 1px solid #e3e3e3;
  border-radius: 3px;
  background: #efefef;
  grid-column: 1 / span 3;
  min-height: 10em;
  padding: 5px;
  }
  input {
  border: 1px solid #e3e3e3;
  border-radius: 3px;
  background: #efefef;
  height: 1.5em;
  padding: 5px;
  }
  button {
  grid-column: 3;
  height: 2.5em;
  }
`
export default function CommentForm(props) {
  const [name, setName] = useState(false)
  const [message, setMessage] = useState(
  false
  )
  function setValue(setter) {
  return event =>
  setter(event.target.value)
  }
  const valid =
  name.length > 0 && message.length > 0
  return (
  <Form
  action="/api/comment"
  method="post"
  >
  <h3>Leave a Comment</h3>
  <input
  onChange={setValue(setName)}
  name="name"
  placeholder="Your name"
  />
  <input
  name="email"
  placeholder="Your email"
  />
  <input
  name="url"
  placeholder="Your website"
  />
  <textarea
  onChange={setValue(setMessage)}
  name="message"
  />
  <button
  disabled={!valid}
  type="submit"
  >
  Send Comment
  </button>
  <input
  type="hidden"
  name="postId"
  value={props.postId}
  />
  </Form>
  )
}
Listing 12: comment.ts

import {
  NowRequest,
  NowResponse,
} from "@now/node"
import gql from "graphql-tag"
import client from "../src/fauna/apollo"
const ADD_COMMENT = gql`
  mutation AddComment(
  $author: String!
  $created: Time!
  $message: String!
  $post: CommentPostRelation!
  ) {
  createComment(
  data: {
  author: $author
  created: $created
  content: $message
  post: $post
  }
  ) {
  _id
  created
  post {
  slug
  }
  }
  }
`
export default async (
  req: NowRequest,
  res: NowResponse
) => {
  const payload = req.body
  try {
  const response = await client.mutate({
  mutation: ADD_COMMENT,
  variables: {
  author: `${payload.name} <${payload.email}>`,
  created: new Date().toISOString(),
  message: payload.message,
  post: { connect: payload.postId },
  },
  })
  res.setHeader(
  "Location",
  `/${response.data.createComment.post.slug}`
  )
  res.status(301).end()
  } catch (err) {
  res.setHeader(
  "Content-Type",
  "text/html"
  )
  res
  .status(400)
  .send(err.message)
  }
}
Listing 13: src/fauna/index.js

const FAUNA_SECRET =
  "Zm5BRFUxb3R3N0FDQlZPTlUteWN2OV8zQlBxY19sX1R4dkFmWWRZQjo="

const {
  ApolloClient,
} = require("apollo-boost")
const {
  HttpLink,
} = require("apollo-link-http")
const {
  InMemoryCache,
} = require("apollo-cache-inmemory")
const fetch = require("node-fetch")
const config = {
  link: new HttpLink({
  uri: "https://graphql.fauna.com/graphql",
  fetch: fetch,
  headers: {
  Authorization: `Basic ${FAUNA_SECRET}`,
  },
  }),
  cache: new InMemoryCache(),
}
const client = new ApolloClient(config)
module.exports = { client, FAUNA_SECRET }
Listing 14: Kommentare ausgeben

import React, { useState } from "react"
import styled from "styled-components"
const Button = styled.button`
  background: transparent;
  border: 2px solid #555;
  border-radius: 5px;
  padding: 1rem;
`
const StyledCommentList = styled.div`
  border-top: 1px solid #efefef;
  padding-top: 2rem;
  margin-top: 5rem;
  display: grid;
  grid-gap: 1.5rem;
`
const StyledComment = styled.article`
  display: grid;
  min-height: 7rem;
  padding-bottom: 0;
  grid-template-rows: 1fr 1.5rem;
  border: 1px solid #ddd;
  h5 {
  line-height: 1.5rem;
  margin: 0;
  padding: 0;
  color: #555;
  background: #efefef;
  align-self: center;
  padding-left: 1rem;
  }
  p {
  margin: 0;
  padding: 1rem;
  }
`
function Comment({ data, className }) {
  return (
  <StyledComment>
  <p>{data.content}</p>
  <h5>{data.author}</h5>
  </StyledComment>
  )
}
export default function CommentList(props) {
  const [
  showComments,
  setShowComments,
  ] = useState(props.showComments)
  const [
  showCommentsLabel,
  setShowCommentsLabel,
  ] = useState("Show Comments")
  async function loadComments() {
  setShowCommentsLabel("Loading...")
  await props.loadComments()
  setShowComments(true)
  }
  return (
  <StyledCommentList>
  <h3>Comments</h3>
  {showComments ? (
  props.post.comments.data.map(c => (
  <Comment data={c} />
  ))
  ) : (
  <Button onClick={loadComments}>
  {showCommentsLabel}
  </Button>
  )}
  </StyledCommentList>
  )
}


Das könnte Sie auch interessieren