post

GraphQL-Pouch: lokale Daten mit Schema

Ein Open-Source-Experiment aus dem Sommer 2016: eine GraphQL-Laufzeitumgebung, die aus einer Schema-Kurznotation eine komplette CRUD-API über PouchDB erzeugt – inklusive Synchronisation über das CouchDB-Replikationsprotokoll.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (13 Min.)

MP3 herunterladen
type Post implements Node {
  id: ID!
  rev: String
  title: String
  body: String
  published: Boolean
}

Fünf Felder, ein Interface, keine einzige Zeile Resolver-Code. Diese Datei ist das vollständige Backend eines kleinen Blogsystems – Datenhaltung, HTTP-Endpunkt und durchsuchbare API-Dokumentation inklusive. Man übergibt sie an graphql-pouch, und wenige Sekunden später antwortet unter http://127.0.0.1:3000/graphql/blog eine laufende GraphQL-API mit generierten Queries und Mutationen: post, allPosts, upsertPost, deletePost. Gespeichert wird nicht in einem entfernten Datenbankserver, sondern lokal in PouchDB – und auf Wunsch synchronisiert der Prozess seine Dokumente über das CouchDB-Replikationsprotokoll mit anderen Knoten.

Das ist die Kurzfassung von GraphQL-Pouch, einem Open-Source-Projekt, das ich in diesem Monat auf GitHub und npm veröffentlicht habe. Es ist ausdrücklich ein Experiment. Ich wollte GraphQL nicht nur aus Blogposts kennen, sondern von innen – und die beste Methode, eine Technologie von innen kennenzulernen, ist bei mir seit Jahren dieselbe: etwas Kleines, Echtes damit bauen und es so lange benutzen, bis die unbequemen Stellen sichtbar werden.

Ein Jahr GraphQL, viele offene Fragen

Zur Einordnung: Die GraphQL-Spezifikation ist gerade ein Jahr öffentlich. Facebook hat sie im Juli 2015 zusammen mit graphql-js als Referenzimplementierung freigegeben, intern läuft die Technik dort schon seit 2012. Drumherum gibt es bislang eine überschaubare Werkzeuglandschaft: Relay als Client-Framework, GraphiQL als interaktive Oberfläche zum Erkunden einer API, express-graphql als HTTP-Anbindung für Node.js. Wer heute einen GraphQL-Server baut, schreibt Typdefinitionen und Resolver von Hand gegen graphql-js – für jedes Feld eine Funktion, die sagt, woher die Daten kommen.

Beim dritten selbstgeschriebenen Resolver fiel mir auf, wie mechanisch diese Arbeit für einfache Fälle ist. Ein Typ Post mit fünf Feldern, dahinter eine Dokumentdatenbank: Lesen per Schlüssel, Liste aller Dokumente, Schreiben, Löschen. Das ist immer dasselbe Muster, und Muster, die immer gleich sind, kann eine Maschine erzeugen. Die eigentlich interessante Arbeit steckt im Schema – in der Frage, welche Typen, Felder und Beziehungen die Anwendung braucht. Daraus wurde die These des Experiments: Wenn das Schema die einzige Quelle ist, wie weit trägt eine Laufzeitumgebung, die den Rest generiert?

Der zweite Strang kam aus einer anderen Richtung. In mehreren Frontend-Projekten habe ich erlebt, wie die Backend-Entwicklung zum Engpass wird: Das Team wartet auf Endpunkte, auf Datenbankmigrationen, auf Deployments – und baut in der Zwischenzeit Attrappen, die später mühsam wieder ausgebaut werden. Ein Backend, das aus einer Schemadatei entsteht und ohne Infrastruktur lokal läuft, dreht diese Reihenfolge um. Das Frontend-Team definiert den Datenvertrag selbst, arbeitet sofort gegen eine echte API und verhandelt später, was davon ein „richtiges" Backend übernehmen muss.

Von der Kurznotation zur laufenden API

GraphQL-Pouch liest die Schema-Kurznotation – dieselbe knappe Schreibweise, in der die GraphQL-Dokumentation ihre Beispiele notiert – und baut daraus zur Laufzeit ein vollständiges, ausführbares Schema mit graphql-js. Für jeden Typ entstehen dabei generierte Wurzelfelder samt Resolvern gegen PouchDB. Der Parser dafür geht auf Matthew Muellers Projekt graph.ql zurück, dessen Vorarbeit mir viel Zeit gespart hat.

Standardmäßig sind die Relay-Konventionen aktiv: ein Node-Interface mit globalen IDs, Connections mit edges und node für Listen, clientMutationId in den Mutations-Payloads. Das war eine bewusste Entscheidung, denn Relay ist im Moment der anspruchsvollste GraphQL-Client, und wer dessen Konventionen erfüllt, erfüllt die einfacheren Fälle nebenbei. Wer sie nicht will, schaltet sie mit --no-relay ab und bekommt schlichte Listen.

Eine Abfrage gegen das generierte Blog-Schema sieht so aus:

query BlogFrontpage {
  allPosts {
    edges {
      node {
        id
        rev
        title
        published
      }
    }
  }
}

mutation PublishPost {
  upsertPost(input: {
    id: "hello-graphql",
    title: "GraphQL over local data",
    published: true,
    clientMutationId: "1"
  }) {
    post {
      id
      rev
    }
  }
}

Im Entwicklungsmodus (graphql-pouch --development) liefert der Server unter dem Endpunkt automatisch GraphiQL aus. Das klingt nach einem Nebenschauplatz, war im Alltag aber der größte Einzelgewinn: Die API ist ab der ersten Minute interaktiv erkundbar, mit Autovervollständigung und eingebauter Dokumentation aus den Schema-Kommentaren. Kein Postman, kein cURL-Gefummel – Query eintippen, Ergebnis sehen.

Darüber hinaus bringt die Laufzeitumgebung einige Dinge mit, die aus dem Experiment fast unbemerkt ein benutzbares Werkzeug gemacht haben:

  • mehrere Schemas parallel unter eigenen URL-Pfaden (/graphql/blog, /graphql/cms)
  • JWT-Authentifizierung per Startparameter, inklusive CLI-Kommando zum Signieren von Tokens
  • Ausliefern statischer Dateien, sodass eine React-Anwendung direkt daneben wohnen kann
  • Migration von Schemadateien, eigenen Funktionen und statischen Inhalten über die Kommandozeile

Warum PouchDB darunterliegt

Die Wahl der Datenbank war die eigentliche Architekturentscheidung des Projekts. PouchDB ist eine JavaScript-Implementierung der CouchDB-Ideen: dokumentorientiert, revisionsbasiert, und – das ist der entscheidende Punkt – mit dem CouchDB-Replikationsprotokoll als eingebautem Synchronisationsmechanismus. Im Node.js-Prozess speichert PouchDB über LevelDB direkt ins Dateisystem. Es gibt keinen Datenbankserver, keinen Verbindungsstring, keine Migrationen. npm install -g graphql-pouch, starten, fertig – der Zustand liegt als Dateien neben dem Prozess.

Für ein Werkzeug, das Frontend-Entwicklung vom Backend entkoppeln soll, ist diese Selbstgenügsamkeit kein Komfortmerkmal, sondern der Kern. Jede Infrastrukturabhängigkeit, die ein Entwickler erst installieren muss, ist eine Hürde vor der ersten Query.

Interessant wird es, sobald mehr als ein Knoten im Spiel ist. Mit graphql-pouch --remote http://user:pass@couch.example.com repliziert die lokale PouchDB gegen eine entfernte CouchDB – in beide Richtungen. Mehrere Instanzen hinter einem Load Balancer teilen sich so denselben Datenbestand, ohne dass eine davon der „Master" wäre. Der Preis ist bekannt und im CouchDB-Umfeld gut dokumentiert: Es gibt keine einzelne Quelle der Wahrheit, nur eventuelle Konsistenz. Jeder Knoten ist jederzeit verfügbar, aber nicht jeder Knoten sieht jederzeit denselben Stand.

flowchart LR
  SDL[Schema-Datei<br/>Kurznotation] --> RT[graphql-pouch<br/>Laufzeitumgebung]
  RT --> GEN[Generierte Felder<br/>allPosts, upsertPost, deletePost]
  UI[GraphiQL /<br/>Relay-Client] --> GEN
  GEN --> P[(PouchDB<br/>LevelDB im Dateisystem)]
  P <-->|CouchDB-<br/>Replikationsprotokoll| C[(CouchDB<br/>entfernter Knoten)]

Dass ich diese Eigenschaft nicht als Makel, sondern als Merkmal behandle, hat mit der Zielgruppe zu tun: Frontends. Eine Oberfläche, die auf Bestätigung eines entfernten Servers wartet, fühlt sich zäh an. Relay bringt mit seinen optimistischen Updates ohnehin ein Modell mit, das Änderungen sofort anzeigt und später mit der Serverantwort abgleicht. Eventuelle Konsistenz auf der Datenebene und optimistische Updates auf der Client-Ebene sind zwei Ausprägungen desselben Gedankens – die Architektur von GraphQL-Pouch nimmt diesen Gedanken ernst, statt ihn zu verstecken.

Die Revision steht im Schema – mit Absicht

Wer das Post-Beispiel oben aufmerksam liest, stolpert über das Feld rev. Das ist die Dokumentrevision aus der CouchDB-Welt, und man kann mit guten Gründen fragen, ob so ein Speicherdetail in einer API sichtbar sein darf. Ich habe mich dafür entschieden, und zwar nach einem konkreten Fehlversuch: In einer frühen Version habe ich die Revision intern behandelt und Schreibzugriffe einfach durchgereicht. Das funktioniert genau so lange, wie niemand gleichzeitig schreibt. Sobald zwei Clients dasselbe Dokument ändern – oder ein replizierter Knoten eine ältere Fassung zurückspielt –, überschreibt der Letzte stillschweigend den Ersten. Der verlorene Stand fällt erst auf, wenn ein Nutzer ihn vermisst, und dann ist er nicht mehr rekonstruierbar.

Mit sichtbarer Revision dreht sich das um. Ein upsertPost mit veralteter Revision scheitert kontrolliert, weil PouchDB den Konflikt erkennt:

sequenceDiagram
  participant A as Client A
  participant B as Client B
  participant API as graphql-pouch
  participant DB as PouchDB
  A->>API: upsertPost(id, rev: "1-abc", ...)
  API->>DB: put(document)
  DB-->>API: ok, rev: "2-def"
  B->>API: upsertPost(id, rev: "1-abc", ...)
  API->>DB: put(document)
  DB-->>API: 409 conflict
  API-->>B: error instead of silent overwrite

Der Client muss dann neu lesen und entscheiden, was mit den konkurrierenden Änderungen passiert. Das ist unbequemer als ein Schema ohne rev, aber die Unbequemlichkeit ist ehrlich: Sie zeigt ein Problem an dem Ort, an dem es entsteht, statt es in die Zukunft zu verschieben. Bei lokalen, replizierten Daten ist der Schreibkonflikt kein Randfall, den man wegabstrahieren kann – er ist der Normalfall, der nur selten eintritt.

Wenn generiertes CRUD nicht mehr reicht

Generierte Endpunkte tragen erstaunlich weit, aber nicht beliebig weit. Irgendwann braucht eine Anwendung eine Abfrage mit Fachlogik, eine Validierung vor dem Schreiben oder Daten aus einer fremden Quelle. Für diese Fälle hat GraphQL-Pouch eine Fluchttür: eigene Funktionen in JavaScript, die sich unter dem Namen eines Schema-Felds registrieren und den generierten Resolver ersetzen. Zusammen mit der Nutzung als npm-Modul lässt sich das direkt in eine bestehende Express-Anwendung einbetten – das Begleit-Repository graphql-pouch-as-library zeigt genau diesen Aufbau:

const express = require('express');
const expressGraphQL = require('express-graphql');
const graphqlPouch = require('graphql-pouch');

const schemaDefinition = `
type Post implements Node {
  id: ID!
  rev: String
  title: String
  body: String
  published: Boolean
}

type Query {
  postsByAuthor(author: String!): [Post]
}
`;

// custom resolver replaces the generated CRUD implementation
const customFunctions = {
  postsByAuthor: (ctx, input) => {
    legacyBlogService
      .fetchPostsByAuthor(input.author)
      .then(posts => ctx.success(posts));
  },
};

const mySchema = graphqlPouch.schema('blog', schemaDefinition, true, customFunctions);

const app = express();
app.use('/graphql', expressGraphQL({
  schema: mySchema.schema,
  graphiql: true,
}));
app.listen(3000);

Dieses Muster hat sich im Experiment als der wichtigste Befund herausgestellt. Die Generierung deckt den mechanischen Teil ab, und die eigenen Funktionen übernehmen den fachlichen – ohne dass man das Fundament wechseln muss. GraphQL zeigt hier seine Stärke als Fassade: Hinter demselben Schema können ein generierter PouchDB-Zugriff, ein Aufruf an einen bestehenden REST-Dienst und eine handgeschriebene Berechnung nebeneinander existieren, und kein Client merkt den Unterschied.

Was das Experiment nicht kann

Zur Ehrlichkeit eines Experiments gehört die Liste der offenen Enden, und die ist bei GraphQL-Pouch nicht kurz:

  • Ereignisse fehlen. PouchDB liefert einen Änderungs-Feed, aus dem sich Benachrichtigungen an Clients speisen ließen – das steht auf der Roadmap, ist aber nicht gebaut.
  • Ein Batching- und Caching-Mechanismus für Resolver fehlt ebenfalls; verschachtelte Abfragen erzeugen mehr Datenbankzugriffe als nötig.
  • Die JWT-Unterstützung authentifiziert, autorisiert aber nicht – Rollen oder Feldrechte gibt es nicht.
  • Generiertes CRUD bleibt generisch: upsertPost sagt nichts darüber, ob ein Beitrag veröffentlicht, zurückgezogen oder redigiert wird. Eine fachlich gute API braucht benannte Operationen, und die muss weiterhin ein Mensch entwerfen.

Der letzte Punkt ist der wichtigste. Die Generierung nimmt einem die Schreibarbeit ab, nicht die Entwurfsarbeit. Ein Schema, das nur aus Typen mit CRUD besteht, ist eine Datenbank mit hübscherem Protokoll – nützlich für Prototypen, Demos und die frühe Frontend-Phase, aber kein Ersatz für eine durchdachte Schnittstelle.

Was ich daraus mitnehme

Nach einigen Wochen mit dem eigenen Werkzeug bleiben drei Beobachtungen. Erstens: Der Ansatz, das Schema zuerst zu schreiben und alles Weitere daraus abzuleiten, verändert Gespräche. Über eine Schemadatei mit Kommentaren lässt sich mit Frontend-Kollegen konkret verhandeln, lange bevor irgendein Server steht – das Schema wird zum Vertrag, nicht zur Nachdokumentation. Zweitens: Lokale Daten mit Synchronisation verlagern die Komplexität, sie beseitigen sie nicht. Was an Infrastruktur wegfällt, kommt als Revisions- und Konfliktdenken im Datenmodell zurück, und es ist besser, wenn die API das offenlegt. Drittens: Ein Wochenend-Experiment, das man ernsthaft zu Ende baut – mit CLI, Tests, Dokumentation und Veröffentlichung auf npm –, lehrt mehr über eine Technologie als jede Einführung, weil es einen zwingt, auch die Ränder anzufassen: Fehlermeldungen, Authentifizierung, Betrieb mit mehreren Knoten.

Ob GraphQL sich durchsetzt, kann ich im Sommer 2016 nicht beurteilen. Aber die Idee, einen typisierten Datenvertrag zwischen Client und Server zu stellen und beide Seiten daran auszurichten, wirkt auf mich tragfähiger als vieles, was ich in zehn Jahren API-Entwicklung mit REST-Konventionen verhandelt habe. Das Experiment hat sich schon deshalb gelohnt.

Weiterführende Quellen

Kommentare