Aus Linux-Magazin 07/2020

BeerAdvisor-App mit GraphQL

© alicephoto, 123RF

Geht es um den Datenaustausch zwischen Client und Server, kommt häufig eine REST-API zum Einsatz. GraphQL bietet eine effiziente Alternative dazu. Dieser Artikel stellt die Sprache vor.

Mit der Sprache GraphQL kann ein Client flexibel Daten von einer GraphQL-API abfragen, ähnlich wie mit SQL aus einer Datenbank. So vorzugehen zahlt sich zum Beispiel dann besonders aus, wenn eine Anwendung ihre Clients nicht kennt (zum Beispiel, weil sie eine öffentliche API bereitstellt) oder wenn es sehr viele unterschiedliche Clients mit unterschiedlichen Anforderungen gibt. Einer der ersten prominenten Anbieter einer öffentlichen GraphQL-API war GitHub [1]. Aber auch GitLab sowie Atlassian-Produkte wie Jira [2] und Confluence [3] verfügen über eine GraphQL-API. Twitter und Facebook setzen ebenfalls auf diese Technologie, und auch viele andere Services bieten mittlerweile ein GraphQL-API an.

Die GraphQL-Spezifikation, die von der GraphQL Foundation [4] als Open-Source-Lösung weiterentwickelt wird, definiert, wie die Abfragen aussehen müssen und wie ein Server diese Anfragen beantworten muss. Die API allein ist damit noch keine ausführbare Software. Es gibt aber einige Produkte, die GraphQL-APIs für bestehende Datenbanken nutzbar machen, unter anderem mit PostGraphile für PostgreSQL [5]. Auch die Graph-Datenbank Neo4j bringt eine GraphQL-API mit [6], und für AWS gibt es mit AppSync einen Cloud-basierten GraphQL-Service [7].

Um Clients der eigenen Anwendung den Zugriff per GraphQL zu ermöglichen, muss die Applikation eine entsprechende Schnittstelle implementieren und zur Verfügung stellen. Das Ermitteln der abgefragten Daten ist dann Aufgabe der Anwendung. GraphQL interessiert es nicht, woher die Daten kommen: Sie können beispielsweise aus einer oder mehreren Datenbanken oder auch aus anderen (Micro-)Services stammen.

Für den Verwender der GraphQL-API bleibt das transparent, weswegen GraphQL-APIs häufig auch als eine Art Gateway vor eine Reihe von Microservices positioniert werden. Die Entwickler der GraphQL-Schnittstelle müssen jedoch dafür Sorge tragen, dass die angebotenen Daten bei einer entsprechenden Abfrage zügig an den Client zurückgegeben werden können.

Neben dem Lesen von Daten kann eine GraphQL-Schnittstelle auch das Schreiben und Verändern von Daten anbieten. Außerdem können sich Clients am Server registrieren und über Ereignisse informieren lassen – zum Beispiel für den Fall, dass ein Datensatz hinzugefügt oder verändert wurde. Es existieren Bibliotheken und Frameworks für diverse Programmiersprachen, die es ermöglichen, eine GraphQL-Schnittstelle für die eigene Anwendung bereitzustellen.

Für Java gibt es unter anderem das GraphQL-Java-Projekt [8], das wir später in diesem Artikel vorstellen. Für Scala steht das Projekt Sangria [9] zur Verfügung, für Javascript-Anwendungen das Apollo-Framework [10]. Eine Übersicht über die verfügbaren Bibliotheken findet sich auf der GraphQL-Homepage [11].

GraphQL soll ermöglichen, dass ein Client immer genau die Daten von einer Anwendung abfragen kann, die er für einen Use Case gerade benötigt, zum Beispiel für eine Ansicht im Frontend,. Dadurch lassen sich die Netzwerk-Requests und das übertragene Datenvolumen optimieren, da der Client niemals zu viele oder zu wenige Daten erhält.

Gleichzeitig soll der Einsatz von GraphQL auf dem Server auch eine flexible Weiterentwicklung der Anwendung erlauben, ohne dazu verschiedene Versionen der API zur Verfügung stellen zu müssen. GraphQL ermöglicht das Entkoppeln von Anbieter und Verwender der API. Der Server stellt einfach beliebige weitere Daten zur Verfügung, die der Client aber erst erhält, wenn er sie explizit abfragt. Der Server kann einem Client nicht versehentlich zusätzliche Daten schicken, auf die der gar nicht vorbereitet ist. Vielmehr pickt sich der Client genau die Teile der Daten heraus, die er für den jeweiligen Anwendungsfall benötigt.

Ein praktisches Beispiel

Eine kleine Beispielanwendung, der BeerAdvisor, soll im Folgenden die Konzepte von GraphQL praktisch verdeutlichen. Der Quellcode der Anwendung steht auf Github [12] zur Verfügung. Mit dieser Anwendung können Nutzer eine Reihe von Bieren bewerten und schauen, wo man sie erhält. Andere Nutzer können die abgegeben Bewertungen einsehen. Die App beinhaltet damit typische Anwendungsfälle einer Web-Anwendung.

Abbildung 1: Der BeerAdvisor – eine GraphQL-Beispielanwendung.

Abbildung 1: Der BeerAdvisor – eine GraphQL-Beispielanwendung.

Das Domain-Modell der Beispielanwendung besteht aus vier Entitäten: dem Bier (Beer), dessen Bewertungen (Rating), dem bewertenden Anwender (User) sowie Geschäften, in denen es das jeweilige Bier zu kaufen gibt (Shop). Der BeerAdvisor-Web-Client umfasst drei Ansichten, die jeweils ganz unterschiedliche Ausschnitte aus diesem Domain-Modell benötigen.

Die Übersichtsseite zeigt beispielsweise nur den Namen der Biere und deren durchschnittliche Bewertung an. Die Detailansicht eines Biers stellt neben diesen Informationen den Preis dar, darüber hinaus aber auch die Einzelbewertungen (aus den Entities Rating und User) sowie die Namen der Geschäfte, in denen es erhältlich ist. Die Detailseite eines Shops wiederum zeigt Informationen zu einem Geschäft (wie etwa dessen Adresse) sowie die dort erhältlichen Biere – hier aber nur jeweils den Namen und nicht den Preis, die Bewertungen und so weiter.

Mit GraphQL kann die Anwendung für jede der Ansichten genau die Informationen aus dem Modell abfragen, die sie dafür jeweils benötigt. Ändern sich die Anforderungen des Clients, kann dieser die GraphQL-Abfragen anpassen, um andere oder weniger Daten für einen Anwendungsfall zu lesen, ohne den Server dafür anpassen zu müssen.

Genauso wäre es möglich, dass ganz andere Clients hinzukommen, etwa eine mobile Variante der Anwendung. Diese Clients können dann ebenfalls Daten in der von ihnen benötigten Form abfragen, ohne dass der Server diese konkret für deren Anforderungen zur Verfügung stellen müsste.

Mit GraphQL Daten abfragen

Eine Abfrage in GraphQL besteht immer aus einer Operation. Davon gibt es drei Typen. Die Query dient dazu, Daten zu lesen. Mit der Mutation lassen sich Daten auf den Server schreiben beziehungsweise dort ändern. Die Subscription schließlich ermöglicht dem Client, sich für Events zu registrieren, die der Server veröffentlicht. So könnte der Server etwa einen solchen Event veröffentlichen, sobald eine neue Bewertung für ein Bier eingeht.

GraphQL-Abfragen werden in einer JSON-artigen Notation formuliert. Dabei wählt man Felder aus den von der GraphQL-API angebotenen Objekten aus. Das Beispiel aus Listing 1 zeigt die Query, die der Client ausführt, um die Übersichtsseite der Anwendung darstellen zu können. Dazu wählt er die Namen (Feld »name«) und die durchschnittliche Bewertung (Feld »averageStars« ) aller vorhanden Beer-Objekte aus.

Listing 1

Übersichts-Query

query OverviewPageQuery {
  beers {
    name
    averageStars
  }
}

In einer echten Anwendung hätte diese Abfrage mit Sicherheit noch einige Restriktionen. So könnte der Client gezwungen sein, die Ergebnismenge einzuschränken, etwa maximal zehn Biere abzufragen. Auch wäre es denkbar, dass der Client eine bestimmte Sortierung der Ergebnisse vorgeben möchte.

Im Gegensatz zu SQL gehören solche Features (Sortieren, Filtern, Limitieren) nicht zur GraphQL-Spezifikation. Anwendungen, die so etwas in ihrer API anbieten möchten, müssen diese Funktionen selbst implementieren. Dazu kann der Anwender für Felder einer API Argumente definieren, die die Anwendung beim Ausführen einer Query als Werte an die API übergibt.

In der Detailansicht eines Biers beispielsweise soll stets eine ganz bestimmte Sorte angezeigt werden – wir haben es hier also mit einer Art Filter zu tun. Das entsprechende Feld in der API (»beer« ) bietet dazu ein Argument (»beerId«) an, das der Client setzen muss. Listing 2 zeigt die entsprechende Query für das Bier B1. Außerdem zeigt diese Query, wie sich Felder aus einer Hierarchie von Objekten abfragen lassen (»ratings«).

Listing 2

Query für Bier B1

query BeerViewQuery {
  beer(beerId: "B1") {
    name
    ratings {
      comment
      author {
        name
      }
    }
    shops { id name address { street city } }
  }
}

Eine Abfrage in GraphQL besteht folglich aus einer hierarchischen Struktur von Feldern, die die Anwendung als Abfrage an einen GraphQL-fähigen Server schickt. Das erfolgt in der Regel per HTTP-POST-Request an einen zentralen Endpunkt. Andere HTTP-Verben wie GET oder PUT spielen in GraphQL keine Rolle, ebenso wenig die HTTP-Statuscodes. Sie kommen nur bei schwerwiegenden Fehlern zum Einsatz, etwa wenn der Server nicht gefunden wird. Alle anderen Informationen werden über das Query-Ergebnis ausgetauscht, zum Beispiel durch ein eigenes, anwendungsspezifisches Error-Feld.

Eine Versionierung der API zur Laufzeit gibt es nicht, da sich diese abwärtskompatibel erweitern lässt. Der zentrale Endpunkt nimmt also die Anfrage entgegen, verarbeitet sie und liefert das Ergebnis zurück. Das Kommando aus Listing 3 zeigt, wie man mit dem Kommandozeilenwerkzeug Curl eine GraphQL-Abfrage ausführt.

Listing 3

GraphQL-Abfrage via Curl

$ curl -X POST -H "Content-Type: application/json" -d '{"query":"{ beers { id name } }"}' http://localhost:9000/graphql

Als Ergebnis liefert die Abfrage ein JSON-Objekt, das auf Root-Ebene ein Data-Feld enthält. In diesem Feld finden sich die abgefragten Daten, deren Hierarchie jener der Abfrage gleicht. Der Client weiß daher durch die Formulierung seiner Abfrage, wie die Antwort vom Server strukturell aussieht. Eine exemplarische Antwort auf die oben gezeigte BeerViewQuery zeigt Abbildung 2; die Hierarchie unterhalb von »data« entspricht dabei genau der abgefragten Struktur in der Query.

Abbildung 2: Frage und Antwort einer GraphQL-Query.

Abbildung 2: Frage und Antwort einer GraphQL-Query.

Tritt beim Verarbeiten der Query ein Fehler auf, zum Beispiel aufgrund einer ungültigen Query, liefert der Server zusätzlich ein Feld »errors« mit einer Fehlerbeschreibung zurück.

Will der Client Daten auf dem Server verändern, benutzt er eine Mutation. Die Abfrage dazu ist mit einer Query fast identisch, nur dass sie als Operationstyp »mutation« angibt (Listing 4). Als Argumente übergibt der Client alle Informationen, die der Server benötigt, um die Aktion erfolgreich auszuführen. Aus dem spezifischen Ergebnis einer Mutation kann der Client wiederum, genau wie bei einer Query, einzelne Felder auswählen, die er vom Server als Antwort benötigt.

Listing 4

Mutation

mutation AddRatingMutation {
  addRating(beerId: "B1", userId: "U2", comment: "tasty", stars: 3) {
    id
  }
}

Im BeerAdvisor können Nutzer über eine Mutation eine neue Bewertung (Rating) für ein Bier abgeben. Dazu muss der Client Informationen wie den Kommentar und den Benutzer übergeben. Der Server liefert für die »addRating«-Mutation das neue, auf dem Server angelegte Rating-Objekt zurück.

Aus diesem Objekt pickt sich der Client die Felder heraus, die er benötigt. Der BeerAdvisor-Client wählt hier lediglich das Feld »id« aus: Alle anderen Informationen der Bewertung kennt der Client bereits, sie müssen somit nicht vom Server geholt werden. Das spart Datenvolumen ein.

Die API-Definition

Eine GraphQL-API muss zwingend mit einem Schema beschrieben werden, das definiert, welche Objekte und Felder es überhaupt gibt, welche Queries angeboten werden und welche Mutations mit welchen Parametern existieren. Gegen dieses Schema validiert der Server alle eingehenden Queries und nimmt nur gültige zur Verarbeitung an.

Als gängiger Weg zur Beschreibung des Schemas dient die Schema Definition Language (SDL), die ebenfalls zu den Bestandteilen der GraphQL-Spezifikation zählt und die auch viele serverseitige GraphQL-Bibliotheken unterstützen. Eine Alternative zur SDL bietet der sogenannte Code-first-Ansatz, der das Schema aus bestehendem Source-Code ableitet, der zum Beispiel mit Annotationen erweitert ist. Dieser Weg steht ebenfalls für viele Bibliotheken zur Verfügung, ist allerdings immer bibliotheksspezifisch.

Ein Schema beschreibt die Objekte (in GraphQL Object Types genannt) samt ihrer Felder, die Clients abfragen können. Da es sich bei GraphQL um eine typsichere Sprache handelt, muss man die Felder mit Typen versehen. Es gibt einige eingebaute Typen wie ID, String, Int, Boolean und außerdem Arrays beziehungsweise Listen. Die Definition eines eigenen Object Types könnte so aussehen, wie in Listing 5 gezeigt.

Listing 5

Eigener Object Type

type Rating {
  id: ID!
  beer: Beer!
  author: User!
  comment: String!
  stars: Int!
}

Das Listing definiert den Typ Rating, der aus fünf Feldern besteht. Jedes Feld erfordert neben dem Namen den jeweiligen Typ. Dazu gehört auch die Angabe, ob es sich bei dem Feld um ein Pflichtfeld (gekennzeichnet mit einem Ausrufezeichen) oder um ein Array handelt (dann wäre der Feldtyp mit eckigen Klammern umschlossen). Die Felder »beer« und »author« zeigen auf weitere Objekttypen.

Neben den Objekttypen umfasst ein Schema noch die Operationen, die sich über die API ausführen lassen, also die angesprochenen Queries, Mutations und Subscriptions. Die Operationen heißen Root Operation Types, werden syntaktisch genauso wie Object Types in der SDL beschrieben und bestehen ebenfalls aus einer Menge an Feldern (Listing 6).

Listing 6

Root Operation Types

type Query {
  beer(beerId: ID!): Beer
  beers: [Beer!]!
}
type Mutation {
  addRating(ratingInput: AddRatingInput): Rating!
}
type Subscription {
  onNewRating(beerId: ID!): Rating!
}

Im Typ »Query« ist ein Feld »beer« definiert, das die Beschreibung von Argumenten für Felder illustriert. Für die einzelnen Argumente muss man Name und Typ angeben, ähnlich wie bei Argumenten in Java-Methoden. Mutation und Subscription sind in einem Schema optional, die Definition des Query Type aber immer notwendig.

GraphQL-Abfragen beginnen stets bei einem Feld, das innerhalb eines der Root Operation Types definiert ist. Von dort aus lässt sich dann weiter transitiv über alle referenzierten Types wandern. Bemerkenswert ist, dass Clients die Felder auch nur in der vom Schema definierten Form abfragen können. Beliebige Kombinationen der Objekte, wie sie etwa in SQL mit Joins möglich wären, erlaubt GraphQL nicht.

Es kommt also beim Entwurf des Schemas darauf an, die nötige Flexibilität für die Clients zu berücksichtigen, indem beispielsweise der Query-Typ entsprechend viele Einstiegspunkte anbietet oder auch bidirektionale Referenzen (zum Beispiel vom Bier auf dessen Bewertungen und wieder zum Bier) zulässt. Da der Client die zusätzlichen Möglichkeiten nicht nutzen muss, wird er auch nicht mit Informationen und Daten überlastet, die er nicht braucht. Im Fall der Fälle hat er aber die Möglichkeit, die benötigten Daten flexibler auszuwählen.

Bei der Ausführung einer Query stellt die GraphQL-Laufzeitumgebung auf dem Server sicher, dass nur solche Queries verarbeitet werden, die mit dem Schema kompatibel sind. Ungültige Queries, die etwa nicht existierende Felder abfragen, weist der Server zurück. Ähnlich verhält es sich mit dem Ergebnis der Query: Der Client bekommt die Daten nur zurückgeschickt, wenn die Antwort dem Schema entspricht.

Daneben wird überprüft, ob Pflichtfelder wirklich befüllt sind und ob die zurückgegebenen Felder den erwarteten Typen entsprechen. Dadurch können sowohl Server als auch Client beim Verarbeiten der Queries und der Ergebnisse sicher sein, dass sie es mit syntaktisch korrekten Daten und den richtigen Datentypen zu tun haben.

Entwickler-Werkzeuge

Unabhängig davon, wie das Schema technisch definiert wurde, lässt es sich zur Laufzeit durch eine reguläre, in der Spezifikation beschriebene GraphQL-Query abfragen, ähnlich wie man in Programmiersprachen mittels Reflection-APIs zur Laufzeit Informationen über Objekte und Typen ermitteln kann. Diese Möglichkeit, das Schema zur Laufzeit abzufragen, hat eine Reihe von interessanten Werkzeugen für die Arbeit mit und die Entwicklung von GraphQL-Anwendungen hervorgebracht.

Ein prominentes Beispiel ist GraphiQL (Abbildung 3). Dabei handelt es sich um einen Editor, der im Webbrowser läuft und mit dem Nutzer GraphQL-Abfragen schreiben und ausführen können. GraphiQL fragt dazu das Schema der API ab und bietet auf dieser Basis Features wie Code-Completion und Syntax-Highlighting an, so wie man das auch von IDEs anderer Programmiersprachen kennt. Der Editor steht als Web-Anwendung zur Verfügung und lässt sich in die eigene GraphQL-Anwendung integrieren, um zum Beispiel zur Entwicklungszeit Queries zu testen.

Abbildung 3: Der GraphQL-API-Explorer GraphiQL.

Abbildung 3: Der GraphQL-API-Explorer GraphiQL.

Für die Github-GraphQL-API steht der Github Explorer [13] zur Verfügung. Für die Jetbrains-Produktfamilie (unter anderem IDEA und Webstorm) gibt es Plugins, mit denen sich direkt aus der IDE Queries gegen eine GraphQL-API absetzen lassen. Sie unterstützen den Anwender bei der Formulierung von Queries und des Schemas mithilfe der SDL (samt Code-Completion, Refactorings und so weiter).

Beim GraphQL-Codegenerator [14] handelt es sich um eine Sammlung von Code-Generatoren für eine Reihe von Programmiersprachen und Bibliotheken. Auf Basis eines GraphQL-Schemas lassen sich dazu passend etwa Klassen für Java oder Typescript erzeugen.

GraphQL Server in Java

Bis hierher ging es um die Konzepte und die Spezifikation von GraphQL. Nun nimmt der Artikel in den Blick, wie sich eine GraphQL-API für eine eigene Java-Anwendung implementieren lässt. Die dabei gezeigten Konzepte lassen sich auch auf GraphQL-Bibliotheken anderer Programmiersprachen übertragen.

Als Grundlage für die Java-Implementierung dient das Open-Source-Projekt GraphQL-Java. Es hat selbst keine weiteren Abhängigkeiten, etwa vom Spring Framework oder von Java EE, lässt sich aber sehr einfach in beiden Umgebungen einsetzen. Für die Integration in Spring und Spring Boot zum Beispiel stehen zwei weitere Projekte zur Verfügung: GraphQL-Java-Spring [15] und GraphQL-Spring-Boot [16].

Das Entwickeln einer GraphQL-Schnittstelle beginnt in der Regel mit der Definition des Schemas. Das geschieht entweder per SDL oder mit der GraphQL-Java-spezifischen programmatischen API. Dann implementiert der Entwickler DataFetcher, die festlegen, welche Daten für welches Feld der API zurückgeliefert werden. Die DataFetcher heißen in vielen anderen GraphQL-Bibliotheken übrigens DataResolver oder nur Resolver. Die DataFetcher stellen also die eigentliche Implementierung der API dar.

Ein DataFetcher ist ein einfaches Interface und verfügt nur über eine einzige zu implementierende Methode. Zur Laufzeit erhält diese Methode über den Parameter »environment« Informationen über die aktuelle Abfrage. Darüber lassen sich dann zum Beispiel die im Query angegebenen Argumente eines Felds auslesen.

Listing 7 zeigt exemplarisch zwei DataFetcher, den für das Feld »beers« (das eine Liste aller Biere zurückliefert) und jenen für das Feld »beer« (das ein einzelnes, über das Argument »beerId« angegebenes Bier zurückliefert).

Listing 7

DataFetcher-Beispiele

class BeerAdvisorDataFetchers {
  // gesetzt z.B. durch DI-Mechanismus
  private BeerRepository beerRepository;
  public DataFetcher beersDataFetcher() {
    return new DataFetcher<List<Beer>>() {
      public List<Beer> get(DataFetchingEnvironment env) {
        return beerRepository.findAll();
      }
    }
  }
  public DataFetcher beerDataFetcher() {
    return new DataFetcher<Beer>() {
      @Override
      public Beer get(DataFetchingEnvironment env) {
        // Argument 'beerId' aus der gerade ausgeführten
        // Query auslesen
        String beerId = env.getArgument("beerId");
        return beerRepository.getBeer(beerId);
      }
    }
  }
}

Solche DataFetcher muss der Programmierer zwingend für alle Felder anlegen, die ein Root Operation Type (also Query, Subscription oder Mutation) definiert. Für alle darunterliegenden beziehungsweise referenzierten Objekte sind die DataFetcher optional. Gibt es für ein Feld keinen DataFetcher, verwendet GraphQL in der Vorgabe den PropertyDataFetcher. Er versucht, ein Feld entweder über Reflexion und Namenskonventionen zu ermitteln, oder liest den Wert aus einer Map aus. Auch dieses Verhalten ähnelt den Konzepten aus GraphQL-Bibliotheken für andere Programmiersprachen.

Für die im Schema für den Beer-Typ definierten Felder »name«, »id« und »price« kann man auf einen DataFetcher verzichten. Das Root-Objekt (Beer) wird über einen der beiden oben gezeigten DataFetcher ermittelt, die jeweils ein Beer-Objekt (oder eine Liste davon) zurückgeben. Da es in der Java-Klasse Beer auch die Felder »name«, »id« und »price« gibt, kann ein expliziter DataFetcher dafür entfallen, da der PropertyDataFetcher diese Felder automatisch findet und abfragen kann. Dabei stellt GraphQL übrigens sicher, dass auch nur auf solche Felder zugegriffen wird, die das Schema definiert. Ein Feld, das zwar in einer Java-Klasse existiert, aber im Schema fehlt, lässt sich also nicht abfragen.

Anders sieht die Sache für das Feld »averageStars« im Beer-Objekt aus. Es ist in der Java-Klasse Beer nicht definiert, da der Wert dynamisch berechnet werden soll. Also gilt es, dafür einen eigenen DataFetcher zu implementieren. DataFetcher für (Unter-)Objekte sehen genauso aus wie die oben gezeigten DataFetcher für die Root Operation Types. Über die Methode »getSource« im übergebenen Environment lässt sich die Instanz des Objekts abfragen, das der übergeordnete Fetcher ermittelt hat. In diesem Beispiel geht es um ein Feld, das im API-Schema für das Beer-Objekt definiert ist; folglich liefert »getSource« eine Instanz der Java-Klasse Beer (Listing 8).

Listing 8

BeerDataFetchers

import nh.graphql.beeradvisor.domain.Beer;
public class BeerDataFetchers {
  public DataFetcher<Integer> averageStarsFetcher() {
    return environment -> {
      // Instanz ermitteln, von der das Feld 'averageStars'
      // abgefragt wurde
      Beer beer = environment.getSource();
      // Wert für das Feld berechnen und zurückliefern
      return (int)
        Math.round(beer.getRatings().stream()
          .mapToDouble(Rating::getStars)
          .average()
          .getAsDouble());
    };
  }
}

DataFetcher für Mutations werden technisch identisch implementiert. Eine Mutation etwa könnte ein Domain-Objekt verändern und über ein Repository in der Datenbank speichern. Per Definition dürfen Mutations Daten verändern, Queries aber nicht. Das Framework stellt allerdings nicht sicher, dass diese Bedingungen eingehalten werden.

Auch die DataFetcher für Subscriptions implementieren dasselbe Interface. Hier gilt es zu beachten, dass der vom DataFetcher zurückgelieferte Wert ein Reactive-Streams-Publisher-Objekt sein muss. Die Ereignisse sendet der Server dann in der Regel per Websocket an den Client. Beispielhafte Implementierungen dafür finden sich jeweils im BeerAdvisor-Sourcecode. Per Konfiguration werden die DataFetcher nun an die Felder des Schemas gebunden und über einen HTTP-Endpunkt, beispielsweise ein Servlet [17], den Clients zur Verfügung gestellt.

Eine etwas höhere Abstraktionsebene als GraphQL-Java bietet das darauf aufbauende Framework GraphQL-Java-Tools [18]. Es implementiert Resolver-Klassen statt DataFetcher. Im einfachsten Fall gibt es dabei für jeden Operation Type eine eigene Klasse, die Methoden definiert, die ebenso heißen wie die Felder der Typen. Argumente, die für ein Feld im Schema beschrieben sind, werden dabei als reguläre Java-Methodenparameter übergeben. Beim Starten der Anwendung überprüft das Framework, ob es für jedes Feld den erforderlichen Resolver gibt, und wirft andernfalls einen Fehler aus. Das folgende Listing 9 zeigt die beiden DataFetcher von oben in der Resolver-Implementierung.

Listing 9

Resolver-Implementierung

package nh.graphql.beeradvisor.rating.graphql;
import com.coxautodev.graphql.tools.GraphQLQueryResolver;
public class BeerAdvisorQueryResolver implements GraphQLQueryResolver {
  // gesetzt z.B. über DI-Mechanismus (CDI oder Spring)
  private BeerRepository beerRepository;
  public Beer beer(String beerId) {
    return beerRepository.getBeer(beerId);
  }
  public List<Beer> beers() {
    return beerRepository.findAll();
  }
}

Optimierte Implementierung

Die Implementierung der GraphQL-API für den BeerAdvisor fällt relativ trivial aus, weil das Datenmodell des BeerAdvisor sehr gut zur spezifizierten GraphQL-API passt.

Anders kann das bei bestehenden Anwendungen aussehen, für die im Nachhinein eine GraphQL-API bereitgestellt werden soll. Hier kommt es oft zu Schwierigkeiten, wenn das Datenmodell zum Beispiel nicht gut zur geplanten GraphQL-API passt oder die Daten für einen Query sogar aus mehreren Datenbanken oder Microservices geladen werden müssen. In solchen Fällen geraten die DataFetcher komplexer, und die Entwickler müssen sich Strategien etwa für das Caching einfallen lassen. Die unterschiedlichen Bibliotheken bieten für solche Fälle unterschiedliche Hilfen.

Ein häufig wiederkehrendes Problem ist das n+1-Problem. Der BeerAdvisor speichert die User, die Bewertungen abgeben, in einem eigenen Microservice. Wenn ein Client eine GraphQL-Abfrage absetzt, die ein Bier sowie dessen Bewertungen samt der User-Informationen ermitteln will, könnte es in einer naiven Implementierung dazu kommen, dass die Anwendung für jede Bewertung einen einzelnen Call zum User-Microservice ausführt. Dieses Problem kann man mit einem DataLoader verhindern oder zumindest begrenzen.

Ein DataLoader fasst Aufrufe an ein externes System (oder eine Datenbank) zusammen. In unserem Beispiel würde er die IDs aller Benutzer sammeln, die ein Rating abgegeben haben. Die spezifische Implementierung des DataLoaders für genau diesen Anwendungsfall würde dann alle IDs übergeben bekommen. Sie könnte dann im besten Fall einen einzigen Aufruf an den User Microservice absetzen oder ein optimiertes SQL-Statement erzeugen und ausführen. Das Konzept der DataLoader gibt es auch für andere GraphQL-Frameworks-Implementierungen.

Eine andere Möglichkeit der Optimierung besteht darin, im DataFetcher zu überprüfen, welche Felder eine konkrete Query abfragen will. Dazu lässt sich das DataFetchingEnvironment verwenden. Abhängig von den abgefragten Feldern kann es zum Beispiel ein optimiertes SQL-Statement generieren oder die Fetch-Policies dynamisch für den Query passend setzen. Es gibt also unterschiedliche Ebenen für die Optimierung der Queries.

Eine weitere Möglichkeit böte der Aufbau einer eigens für die GraphQL-API optimierten Datenbank. Die Microservices müssten in dem Fall Änderungen an ihrem Datenbestand publizieren, sodass ein zentraler Service die Datenbank aufbauen und aktuell halten kann. Das lässt sich zum Beispiel mit Debezium [19] realisieren, einem Change-Capture-Data-Tool (CDC), das Änderungen innerhalb einer Datenbank (Inserts, Updates, Delete) unter anderem über Apache Kafka anderen Anwendungen zur Verfügung stellt. Der GraphQL Prozess bekommt darüber Änderungen mit und kann seine optimierte Datenbank stets aktuell halten, ohne dass die einzelnen Microservices angepasst werden müssten.

Fazit

GraphQL bietet eine interessante Möglichkeit, Daten gezielt vom Server zu laden, ohne dass sich Client und Server sehr eng aneinander koppeln müssen. Von der Mächtigkeit her lässt sich GraphQL aber nicht mit SQL oder ähnlichen Sprachen vergleichen: Sortierungen, Aggregationen oder eine Suche etwa fehlen gänzlich. Wer solche Features benötigt, muss sie manuell programmieren, etwa über entsprechende Felder oder Argumente in Feldern.

Es fällt in der Regel leicht, eine GraphQL-API für die eigene Anwendung anzubieten, und es gibt – auch dank des typisierten Schemas – sehr gute Entwicklerwerkzeuge und Bibliotheken für diverse Programmiersprachen. Um ein Gefühl für die Sprache und deren Möglichkeiten zu bekommen, lohnt es sich, GraphQL einmal selbst auszuprobieren. Für komplexere Szenarien, in denen man Daten zum Beispiel aus unterschiedlichen Datenquellen laden muss, existieren unterschiedliche Lösungsansätze. Es lohnt sich also auf jeden Fall, GraphQL und dessen weitere Entwicklung im Auge zu behalten, auch wenn man es (noch) nicht im eigenen Projekt einsetzen will. (jcb/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben