post

GraphQL als Frage an das System

Ein halbes Jahr nach der Spezifikation sortiert dieser Beitrag, was an GraphQL wirklich neu ist – und welche Fragen Anfang 2016 noch niemand beantwortet hat.

≈ 9 Min. Lesezeit

Diesen Beitrag anhören (13 Min.)

MP3 herunterladen

„Eine Query Language für APIs" – so beschreibt Facebook GraphQL in der Spezifikation, die seit Juli 2015 als Working Draft öffentlich ist. Der Begriff führt schnell in die Irre. Wer „Query Language" hört, denkt an SQL und vermutet eine Abfragesprache für Datenbanken, irgendwie ins Web verlängert: der Client schickt ein SELECT, der Server reicht es durch. Das trifft es nicht, und das Missverständnis ist gefährlich, weil es GraphQL entweder zu mächtig erscheinen lässt (beliebige Abfragen gegen meine Daten!) oder zu banal (noch ein ORM übers Netz).

GraphQL fragt keine Tabellen ab, kennt keine Joins und hat keine Meinung dazu, wo Daten liegen. Abgefragt wird etwas anderes: ein Typsystem, das der Server veröffentlicht. Der Client formuliert seine Frage in den Begriffen dieses Typsystems, der Server beantwortet genau diese Frage – und woher die Daten kommen, ob aus einer Datenbank, aus drei internen Diensten oder aus einer Datei auf der Platte, bleibt vollständig Sache des Servers. Ein halbes Jahr nach der Veröffentlichung lohnt sich ein sortierender Blick: Was davon ist wirklich neu, und was ist nur neue Syntax für alte Ideen?

Wer stellt hier eigentlich die Frage?

Der Kern lässt sich in einem Satz fassen: REST beantwortet Fragen, die sich der Server gestellt hat – GraphQL lässt den Client die Frage stellen.

Das klingt polemischer, als es gemeint ist. Ein durchdachtes REST-Design ist eine Sammlung von Antworten: GET /posts/42 liefert das, was der Entwickler des Endpunkts für einen Artikel für relevant hielt. Als der Endpunkt entworfen wurde, hat jemand geraten, welche Felder ein Client wohl brauchen wird – und im Zweifel lieber ein paar mehr hineingepackt. Diese Antwort ist eingefroren. Kommt später eine Ansicht dazu, die nur Titel und Autorennamen der letzten zehn Artikel braucht, hat sie zwei Möglichkeiten: den fetten Endpunkt aufrufen und das meiste wegwerfen, oder einen neuen, maßgeschneiderten Endpunkt beantragen. Beides passiert in echten Projekten ständig, und beides erzeugt Kosten – einmal im Netz, einmal in der Abstimmung zwischen Teams.

GraphQL dreht die Richtung um. Der Server sagt nicht mehr „das sind meine Antworten", sondern „das sind die Begriffe, in denen du fragen darfst". Die konkrete Frage – welche Felder, wie tief verschachtelt, in welcher Kombination – formuliert der Client selbst, pro Ansicht, pro Release, ohne Rückfrage beim Server-Team. Das ist die eigentliche Verschiebung, und sie ist organisatorisch mindestens so interessant wie technisch.

Das Schema als Vertrag und Fachsprache

Damit der Client fragen kann, muss der Server sein Vokabular offenlegen. Das geschieht im Schema, und hier liegt für mich die unterschätzte Qualität von GraphQL: Das Schema ist nicht nur ein Vertrag über Datenformen, es ist eine Fachsprache. Typen benennen Dinge der Domäne, nicht Zeilen einer Tabelle. In der kompakten Schema-Notation, die sich in Beispielen und Vorträgen eingebürgert hat, sieht das etwa so aus:

type Author {
  id: ID!
  name: String!
  posts: [Post]
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: Author!
  comments: [Comment]
}

type Comment {
  id: ID!
  text: String!
  author: Author!
}

type Query {
  post(id: ID!): Post
  latestPosts(count: Int): [Post]
}

Man kann dieses Schema einem Redakteur zeigen und darüber reden, ob ein Kommentar wirklich immer einen Autor hat. Das Ausrufezeichen ist keine Formalie, sondern eine fachliche Aussage: author: Author! behauptet, dass es keine verwaisten Kommentare gibt. Ob das stimmt, weiß nicht der Client-Entwickler, sondern derjenige, der die Altdaten kennt. Solche Diskussionen habe ich an Datenbankschemata selten geführt – am GraphQL-Schema entstehen sie fast von selbst, weil es genau an der Grenze zwischen den Teams liegt und beide Seiten es lesen können.

Der Typ Query fällt aus der Reihe: Er beschreibt keine Sache, sondern die Einstiegspunkte – die Fragen, die das System überhaupt zulässt. Auch das ist eine Design-Entscheidung des Servers. GraphQL öffnet nicht wahllos alle Daten; es öffnet exakt die Wurzeln, die im Schema stehen, und von dort aus die Kanten, die die Typen definieren.

Die Query: die Form der Antwort ist die Form der Frage

Die zweite Eigenschaft, die im täglichen Arbeiten sofort auffällt: Eine GraphQL-Query ist deklarativ, und die Antwort hat exakt die Gestalt der Frage.

{
  post(id: "42") {
    title
    author {
      name
    }
    comments {
      text
      author {
        name
      }
    }
  }
}

Die Antwort dazu:

{
  "data": {
    "post": {
      "title": "GraphQL as a question",
      "author": { "name": "Ada" },
      "comments": [
        { "text": "Nice read!", "author": { "name": "Grace" } }
      ]
    }
  }
}

Kein Feld zu viel, keines zu wenig, und die Verschachtelung der Antwort spiegelt die Verschachtelung der Frage. Das wirkt zunächst wie eine Annehmlichkeit, hat aber praktische Tiefe: Der Client-Code, der die Antwort verarbeitet, steht direkt neben der Query, die sie beschreibt. Wer die Ansicht ändert, ändert die Query, und die Datenform folgt. Facebooks Relay, seit August 2015 als Open Source verfügbar, treibt genau diesen Gedanken auf die Spitze und lässt jede Komponente ihre Datenanforderungen selbst deklarieren. Man muss Relay nicht einsetzen, um von der Idee zu profitieren – die Query allein erzwingt schon eine Klarheit, die bei „wir nehmen halt, was der Endpunkt liefert" nie entsteht.

Resolver: der Ort der Wahrheit

Bleibt die Frage, wie aus einer deklarativen Frage eine konkrete Antwort wird. Die Referenzimplementierung graphql-js beantwortet sie mit einem einfachen Prinzip: Jedes Feld hat eine Resolver-Funktion. Sie bekommt das Elternobjekt und die Argumente und liefert den Wert des Feldes – synchron oder als Promise.

import {
  GraphQLObjectType,
  GraphQLID,
  GraphQLString,
  GraphQLList,
  GraphQLNonNull
} from 'graphql';

const postType = new GraphQLObjectType({
  name: 'Post',
  fields: () => ({
    id: { type: new GraphQLNonNull(GraphQLID) },
    title: { type: new GraphQLNonNull(GraphQLString) },
    author: {
      type: authorType,
      resolve: (post) => db.findAuthorById(post.authorId)
    },
    comments: {
      type: new GraphQLList(commentType),
      resolve: (post) => db.findCommentsByPostId(post.id)
    }
  })
});

const queryType = new GraphQLObjectType({
  name: 'Query',
  fields: {
    post: {
      type: postType,
      args: { id: { type: new GraphQLNonNull(GraphQLID) } },
      resolve: (root, args) => db.findPostById(args.id)
    }
  }
});

In graphql-js baut man das Schema derzeit programmatisch aus solchen Typobjekten zusammen; die kompakte Schema-Notation von oben dient vor allem der Kommunikation. Wichtiger als die API-Details ist der Ort, an dem hier Wahrheit entsteht: im Resolver. Ob author aus derselben Datenbank kommt wie der Post, aus einem anderen Dienst oder aus einem Cache – das entscheidet diese eine Funktion, und nur sie. Das Schema verspricht die Form, der Resolver liefert den Inhalt. Diese Trennung macht GraphQL zur Fassade: Man kann dahinter aufräumen, Dienste zusammenlegen oder auseinanderschneiden, ohne dass ein Client es merkt, solange das Schema hält.

In einem Projekt Ende letzten Jahres habe ich einen kleinen Prototyp mit graphql-js vor zwei bestehende interne Dienste gesetzt – nichts Produktives, ein Experiment über die Feiertage. Die überraschende Erfahrung war nicht die Query-Sprache, sondern wie schnell die Diskussion im Team von Endpunkten zu Begriffen wechselte. Statt „welcher Endpunkt liefert das Feld" hieß die Frage plötzlich „gehört das Feld an diesen Typ". Das ist eine bessere Frage.

Overfetching und Underfetching: das eigentlich gelöste Problem

Wenn man nüchtern fragt, welches konkrete Problem GraphQL löst, lande ich immer wieder bei zweien, die dieselbe Wurzel haben. Overfetching: Der Endpunkt liefert mehr, als die Ansicht braucht, und mobile Clients bezahlen das mit Datenvolumen und Akkulaufzeit. Underfetching: Der Endpunkt liefert weniger, als die Ansicht braucht, und der Client klappert nacheinander weitere Endpunkte ab – jede Runde eine volle Netzwerk-Latenz, im Mobilfunk gern dreistellige Millisekunden.

flowchart LR
  subgraph REST["REST – drei Runden für eine Ansicht"]
    C1[Client] --> E1["GET /posts/42"]
    C1 --> E2["GET /posts/42/comments"]
    C1 --> E3["GET /authors/7"]
  end
  subgraph GQL["GraphQL – eine Frage"]
    C2[Client] --> Q["POST /graphql<br/>eine Query, ein Roundtrip"]
    Q --> R["Resolver"]
    R --> S1[("Datenbank")]
    R --> S2["interner Dienst"]
  end

Beide Probleme verschwinden, weil die Frage vollständig beim Client liegt: Er fragt alles, was er braucht, und nichts darüber hinaus, in einem einzigen Roundtrip. Die Runden zwischen den Datenquellen finden weiterhin statt – aber serverseitig, im Rechenzentrum, wo Latenz billig ist. Genau deshalb hat Facebook GraphQL für seine mobilen Anwendungen gebaut, und genau deshalb ist der Nutzen dort am größten, wo Clients über schlechte Netze sprechen. Wer eine interne API zwischen zwei Servern im selben Rack baut, löst mit GraphQL ein Problem, das er womöglich gar nicht hat.

GraphiQL: das Fenster ins System

Ein Aspekt wird in den Diskussionen um Sprache und Typsystem oft unterschlagen: GraphQL-Server sind introspektierbar. Das Schema ist zur Laufzeit abfragbar – mit einer GraphQL-Query über das Schema selbst. Darauf baut GraphiQL auf, der browserbasierte Explorer, den Facebook zusammen mit der Referenzimplementierung veröffentlicht hat. Man richtet ihn auf einen Endpunkt und bekommt:

  • Autovervollständigung für Felder und Argumente, direkt aus dem Schema
  • eingeblendete Dokumentation für jeden Typ und jedes Feld
  • Validierung der Query beim Tippen, vor dem ersten Request
  • ein Fenster zum Ausprobieren, ohne eine Zeile Client-Code

Das verändert, wie man eine fremde API kennenlernt. Statt Dokumentations-Wikis zu lesen, die seit drei Versionen niemand gepflegt hat, exploriert man das System selbst – und die Auskunft kann nicht veralten, weil sie aus dem laufenden Server kommt. Mir ist beim Herumprobieren aufgefallen, dass GraphiQL nebenbei die Schema-Qualität diszipliniert: Wenn jeder Feldname sofort sichtbar und autovervollständigbar ist, tut schlechte Benennung sichtbar weh.

Was Anfang 2016 offen ist

So weit das Lob. Ehrlicherweise gehört in eine Bestandsaufnahme auch, was die Spezifikation und die junge Community noch nicht beantwortet haben. Vier Punkte beschäftigen mich derzeit:

  • Caching ohne URLs
  • das N+1-Problem in Resolvern
  • Berechtigungen pro Feld
  • Versionierung – das Versprechen der „versionless API"

Beim Caching verliert GraphQL zunächst etwas, das REST geschenkt bekommt. HTTP-Caching lebt von der URL als Cache-Schlüssel: GET /posts/42 können Browser, Proxy und CDN cachen, ohne den Inhalt zu verstehen. Ein GraphQL-Request ist ein POST gegen einen einzigen Endpunkt – für die HTTP-Infrastruktur sehen alle Anfragen gleich aus. Relay beantwortet das clientseitig mit einem normalisierten Cache über Objekt-IDs, was elegant ist, aber eben nur im Client hilft. Für die Zwischenschichten gibt es Stand heute keine überzeugende Antwort.

Das N+1-Problem entsteht direkt aus dem Resolver-Modell. Die Query latestPosts(count: 10) mit dem Feld author ruft den Author-Resolver zehnmal auf – im naiven Fall zehn einzelne Datenbankabfragen nach einer Liste, die eine einzige Abfrage liefern könnte:

flowchart TD
  Q["latestPosts(count: 10)<br/>mit Feld author"] --> P["Resolver latestPosts<br/>1 Abfrage"]
  P --> A1["Resolver author<br/>Post 1"]
  P --> A2["Resolver author<br/>Post 2"]
  P --> An["Resolver author<br/>Post 10"]
  A1 --> DB[("10 einzelne Abfragen<br/>statt einer")]
  A2 --> DB
  An --> DB

Facebook hat dafür im Herbst DataLoader veröffentlicht, ein kleines, frisches Projekt, das Ladevorgänge pro Request sammelt, dedupliziert und gebündelt ausführt. Der Ansatz wirkt tragfähig, ist aber noch kein etabliertes Muster – man muss ihn kennen und bewusst einbauen, sonst produziert das eleganteste Schema miserable Datenbanklast.

Berechtigungen sind die Frage, die mir am meisten Respekt einflößt. Bei REST autorisiert man pro Endpunkt, und das Modell ist gut verstanden. In einem Graphen ist potenziell jeder Typ über Kanten von jedem anderen aus erreichbar: Wenn Comment ein Feld author hat und Author ein Feld posts, kommt man vom harmlosesten Kommentar zur kompletten Werkliste. Die Konsequenz ist unbequem: Autorisierung muss pro Feld gedacht werden, sinnvollerweise in den Resolvern – und ein verbreitetes, geprüftes Muster dafür existiert schlicht noch nicht.

Bleibt die Versionierung. Facebook bewirbt GraphQL als „versionless API": Statt /v2/ zu bauen, erweitert man das Schema, markiert alte Felder als veraltet – die Spezifikation sieht Deprecation ausdrücklich vor – und beobachtet, welche Clients sie noch abfragen. Weil jede Query ihre Felder explizit nennt, weiß der Server erstmals genau, was tatsächlich benutzt wird. Auf dem Papier überzeugt mich das; ob es die Disziplin über Jahre trägt, kann Anfang 2016 niemand belegen. Ich notiere es als Versprechen, nicht als Eigenschaft.

Unterm Strich

Was an GraphQL wirklich neu ist, steckt nicht in der Syntax und nicht im Wort „Graph". Neu ist die Verteilung der Verantwortung: Der Server definiert die Fachsprache und verantwortet die Wahrheit in den Resolvern, der Client formuliert seine Frage selbst – vollständig, typgeprüft, in einem Roundtrip beantwortbar. Overfetching und Underfetching, die beiden chronischen Reibungspunkte zwischen API-Teams und Client-Teams, verschwinden dabei fast als Nebeneffekt.

Die offenen Fragen sind real: Caching, N+1, Feld-Berechtigungen, das unbewiesene Versionierungs-Versprechen. Keine davon wirkt unlösbar, aber wer GraphQL heute produktiv einsetzt, beantwortet sie selbst und trägt das Risiko dafür. Für mich heißt das: weiter prototypisch arbeiten, das Schema als Gesprächsgrundlage mit Fachleuten ernst nehmen und die Server-Seite klein halten. Die Idee, dass ein Client dem System eine Frage stellt statt vorgefertigte Antworten abzuholen, halte ich unabhängig vom Werkzeug für richtig – und falls GraphQL sich nicht durchsetzt, wird die nächste Technik genau diese Idee wieder aufgreifen müssen.

Weiterführende Quellen

Kommentare