2. GraphQL 18.04.2024

2.1. Beschreibung

GraphQL ist ein fortschrittlicher API-Standard, der eine effiziente, leistungsstarke und flexible Alternative zu REST bietet. Ursprünglich von Facebook entwickelt und open-source, wird es nun von einer globalen Gemeinschaft gepflegt. Im Gegensatz zu traditionellen REST-APIs mit mehreren festen Endpunkten bietet GraphQL einen einzigen Endpunkt und liefert genau die Daten, die der Client anfragt.

Im Kern ermöglicht GraphQL eine deklarative Datenabfrage, bei der Clients genau angeben können, welche Daten sie benötigen. Diese Abfragesprache arbeitet mit einem serverseitigen Laufzeitumgebung zusammen, die Anfragen basierend auf einem anpassbaren Typsystem für die Daten ausführt. GraphQL ist nicht an eine bestimmte Datenbank oder Speicherengine gebunden.

2.2. Entwicklungs-Setup

2.2.1. Vorbedingungen

2.2.1.1. Backend

2.2.1.1.1. Docker

Installation per apt Package Manager (Advanced Packaging-Tool)

  1. alte Pakete deinstallieren
    for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done

  2. Docker apt Repository registrieren

    # Add Docker's official GPG key:
    sudo apt-get update
    sudo apt-get install ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc
    
    # Add the repository to Apt sources:
    echo \
    "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
    $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
    sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt-get update
    
  3. Docker installieren
    sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

  4. (optional) Installation prüfen durch Ausführen des Hello-World images
    sudo docker run hello-world

2.2.1.2. Frontend

2.2.1.2.1. yarn
  1. Node.js installieren (per apt Package Manager)
    (getestet unter Ubuntu, siehe hier für andere Linux Distributionen)

    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
    sudo apt-get install -y nodejs
    
  2. Yarn Feature Flag aktivieren:

    corepack enable
    
  3. Prüfen, ob die Installation erfolgreich war:

    1. yarn --version ausführen => Version sollte 1.x.x sein

    2. zu <repository root>/todo-app/packages/todo-app/ navigieren

    3. yarn --version ausführen => Version sollte 4.x.x sein

2.2.2. Projekt initialisieren und Abhängigkeiten installieren

  1. zu <repository root>/todo-app/ navigieren

  2. yarn
    

    ausführen

2.2.3. Starten der Anwendung

Der Stand der Svelte Anwendung mit GraphQL befindet sich auf dem Git branch svelte-graphql-dotnet!

  1. zu <repository root>/todo-app/ navigieren

  2. yarn be:docker:create-image
    

    ausführen

  3. yarn be:docker:run
    

    ausführen

  4. yarn fe:build
    

    ausführen

  5. yarn fe:run
    

    ausführen

  6. Anwendung öffnen http://localhost:4173/

Das .NET Backend kann mit yarn be:docker:stop wieder gestoppt werden.

2.2.4. Erläuterungen

  • yarn (alias für yarn install) installiert alle npm Pakete, die als Abhängigkeiten der Svelte Anwendung gelistet sind. Außerdem wird das Projekt initialisiert, sodass yarn scripte ausgeführt werden können.

  • yarn be:docker:create-image baut die .NET API in einem Docker Container und erstellt ein lokales Docker Image dva/todo-api

  • yarn be:docker:run verwendet Docker compose, um

    • einen todo-api Container zu starten

    • einen postgres Container zu starten

  • yarn fe:build baut die Svelte Anwendung

  • yarn fe:run startet die Svelte Anwendung

2.3. Vorteile

  • stark typisiertes Schema: klar definierte Operationen, einschließlich Eingabeargumenten und mögliche Antworten

  • kein Underfetching und Overfetching: Client diktiert die Form der Abfragen und entscheidet welche expliziten Felder abgefragt werden sollen

  • ermöglicht Frontend-getriebene Entwicklung: die Views können unabhängig von etwaigen Endpunkten entwickelt werden und umgekehrt

  • API Kompositionen möglich: eine GraphQL API kann aus mehreren anderen GraphQL APIs zusammengesetzt werden

  • zahlreiche Open-Source-Ressourcen und große Community

2.4. REST vs. GraphQL

Ausgangssituation: In einer Blogging-Anwendung muss eine App die Titel der Beiträge eines bestimmten Benutzers anzeigen. Auf demselben Bildschirm werden auch die Namen der letzten 3 Follower dieses Benutzers angezeigt.

Mit REST müssen mehrere Queries an verschiedene Endpunkte getätigt werden, um die benötigten Daten abzurufen. Blogging Anwendung mit REST

Mit GraphQL kann der Client in einer Query explizit die benötigten Daten angeben. Blogging Anwendung mit GraphQL

2.5. Schema Definition Language (SDL)

Das Schema beschreibt, wie der Client Daten abfragen kann und definiert somit die Möglichkeiten bei der Verwendung der API. Das Schema wird grundlegend aus einer Zusammenstellung von Typen implementiert.

type Post {
  title: String!
  author: Person!
}

Der Typ im oberen Beispiel definiert einen Post in einer Blogging-Anwendung. Ein Post wird durch einen Titel title und einem Autor author definiert. Die beiden Felder werden durch das ! als „required“ markiert.

type Person {
  name: String!
  age: Int!
  posts: [Post!]!
}

Der Typ Person wird mit den Feldern name, age und posts definiert, wobei letzteres als Array vom Typ Post festgelegt und somit eine 1-n-Beziehung implementiert wurde.

Neben den einfachen Typen gibt es auch sogenannte root-Typen Query, Mutation und Subscription, welche als konzeptionelle Entrypoints für Client-Requests dienen.

type Query {
  allPersons: [Person!]!
}

Die Query allPersons dient demnach zur Abfrage aller Personen.

type Mutation {
  createPerson(name: String!, age: Int!): Person!
}

Das createPerson-Objekt des root-Typs Mutation dient zur Erstellung eines neuen Person-Eintrags. Diese Mutation bietet zwei Parameter für den Namen und das Alter der Person an. Mithilfe von Mutationen können create-, update- und delete-Operationen implementiert werden.

2.6. Queries (Client)

query GetPersonData {
  person(name: "Johnny") {
    name
    age
    posts {
      title
    }
  }
}

Diese Query nimmt ein Namen als Argument und gibt den Namen, das Alter und die Titel der Blog-Einträge der Person aus. Das keyword query und den Namen der Operation GetPersonData sind zwar optional, können aber das Debuggen erleichtern und Mehrdeutigkeit verhindern. Zusätzlich dazu können über Parameter des operation name Variablen wie folgt definiert werden:

query GetPersonData($personName: String! = "Johnny") {
  person(name: $personName) {
    name
    age
    posts {
      title
    }
  }
}

Dadurch können redundante Query-Definitionen verhindert werden. Im Allgemeinen funktionieren Variablen identisch, wie Parameter von Funktionen in gängigen Programmiersprachen. Sie werden mit $ gekennzeichnet und weisen immer einen Typ (in diesem Fall String! auf). Darüber hinaus kann man den Variablen Default-Werte zuweisen (hier „Johnny“). Verwendet werden diese Abfragen wie folgt:

{
  "personName": "Johnny"
}

Die Verwendung der vordefinierten „Endpunkte“ allPersons und createPerson funktioniert wie folgt:

{
  allPersons {
    name
    age
    posts {
      title
    }
  }
}

Unter Verwendung der allPersons-Query werden hier von allen Personen in der Datenbank der Name, das Alter und die Überschriften aller Blog-Einträge der jewiligen Person abgefragt. Wenn lediglich die Blog-Beiträge, jedoch nicht die Namen oder das Alter der Personen benötigt werden, können die entsprechenden Felder in der Abfrage einfach ausgelassen werden, ohne weitere Entrypoints in der API definieren zu müssen.

mutation CreatePerson($name: String!, $age: Int!) {
    createPerson(name: $name, age: $age) {
      name
      age
    }
}

Mithilfe von Variablen kann auch die createPerson-Operation implementiert werden.

2.7. Verwendung von GraphQL in der TodoApp

2.7.1. Umsetzung im Backend mit GraphQL.NET

GraphQL.NET ist ein open source NuGet Paket für die .NET Umgebung. Es erlaubt das Definieren von GraphQL Queries und Mutations, ohne dabei auf starke Typisierung verzichten zu müssen. Dabei können beliebige Datenquellen verwendet werden.

Beliebige Queries können in einer Klasse definiert werden, die von ObjectGraphType erbt:

public sealed class ToDoQuery : ObjectGraphType<object>
{
  public ToDoQuery(IToDoService toDoService)
  {
    Name = "Query";

    Field<ToDosObject, ToDos>("toDos")
      .ResolveAsync(async _ => new ToDos
      {
        Lists = await toDoService.GetLists()
      });
  }
}

Dabei muss ein Typ für die Query und ein Typ für den Return Type angegeben werden. Die Typen der Query müssen dabei das interface IGrpahType implementieren.

Anschließend können Queries und Mutations dem Schema hinzugefügt werden:

public class ToDoSchema : Schema
{
  public ToDoSchema(ToDoQuery query, ToDoMutation mutation, IServiceProvider sp) : base(sp)
  {
    Query = query;
    Mutation = mutation;
  }
}

Das Schema, die Queries und die Mutations können anschließend in der Startup registriert werden. Zusätzlich muss ein Json Serializer spezifiziert werden:

builder.Services.AddGraphQL(graphQlBuilder => graphQlBuilder
  .AddSystemTextJson()
  .AddSchema<ToDoSchema>(ServiceLifetime.Scoped)
  .AddGraphTypes());

Mit der folgenden Zeile wird dann automatisch der GraphQL endpunkt erzeugt:

app.UseGraphQL<ToDoSchema>();

2.7.2. ApolloClient

ApolloClient dient als Bibliothek für die Verwaltung von Datenbankzugriffen über GraphQL. Die Bibliothek übernimmt den gesamten Anfragezyklus von Anfang bis Ende und bietet dabei ggf. Lade- und Fehlerzustände an. Zusätzlich dazu bietet ApolloCLient weitere Features, die den Umgang mit GraphQL vereinfachen können. Zum Beispiel ist es möglich in einer Query auch lokale Daten einzubeziehen.

const GET_DOG = gql`
  query GetDogByBreed($breed: String!) {
    dog(breed: $breed) {
      images {
        url
        id
        isLiked @client
      }
    }
  }
`;

In dem oberen Beispiel wird durch das Keyword @client angegeben, dass es sich beim Feld isLiked um einen Boolean im lokalen Storage handelt. Ein weiteres Key-Feature des ApolloClients ist die Implementierung eines lokalen Caches.

const client = new ApolloClient({
  cache: new InMemoryCache(),
});

Sobald man einen Cache an ApolloClient übergeben hast, versucht Apollo Client automatisch, die unterschiedlichen Objekte aus den Antwortdaten einer Abfrage zu identifizieren und in separate Einträge innerhalb seines Caches zu speichern.

In Verbindung mit dem Package svelte-apollo lassen sich auch weitere Szenarien umsetzen:

  const books = query(SEARCH_BY_AUTHOR, {
    variables: { author, search },
  });

  // `books` is refetched when author or search change
  $: books.refetch({ author, search });

In diesem Codebeispiel wird eine weitere Query abgesetzt, sobald sich entweder der Wert der Variable author oder search ändert. Damit ist recht wenig Code erforderlich, um dynamisch auf Änderungen zu reagieren und neue Daten abzufragen.

2.7.3. Erzeugung des Apollo-Clients

Im Folgenden wird die Funktion setApolloClient definiert, die den ApolloClient mit der gegebenen Backend-URL und einem In-Memory-Cache initialisiert.

export function setApolloClient() {
const client = new ApolloClient({
    uri: 'https://localhost:5000/graphql',
    cache: new InMemoryCache(),
});

setClient(client);
    return client;
}

2.7.4. Aufrufen von Funktionen des Clients

Der Aufruf der Funktion query auf den client verlangt eine query, die optional Parameter enthalten kann. Die Queries haben wir in der Datei graphql-api.ts hinterlegt und in den jeweils benötigten Dateien importiert. Die getAllLists Query schaut wie folgt aus:

export const getAllLists = gql`
  query {
  toDos {
    lists {
      id
      name
      # description <- desch isn kommentar und wird nicht mitgefetscht
      items {
        id
        sequenceNumber
        name
        # description <- desch isn auchn kommentar und wird ebenso nicht mitgefetscht 
      }
    }
  }
}`;

In der App.svelte wird der Apollo-Client erstmalig initialisiert und in den Kontext gesetzt und anschließend die client.query Funktion aufgerufen. Den restlichen schon bestehenden Code unserer Todo-App, der auf einer Rest HTTP Schnittstelle aufgebaut war, konnten wir größtenteils behalten, da die query-Funktion nach wie vor ein Promise zurückliefert.

  const client = setApolloClient();
  const todoListsPromise = client.query({
    query: getAllLists,
  }).then((result) => {
    const { lists }= result.data.toDos;
    selectedListId = lists[0]?.id;
    todoLists = lists;
    return lists;
  });

Nach dem einmaligen Setzen des Clients in der App.svelte mit setApolloClient() kann dieser mithilfe der getClient() in jeder anderen Svelte-Komponente, die in der App-Komponente genested ist, aufgerufen werden. Im Hintergrund wird hier der Svelte Kontext verwendet, welcher es ermöglicht, Daten zu setzen, die innerhalb des DOM Trees für alle darunterstehenden Komponenten verfügbar sind.

2.8. Nachteile von GraphQL

  • Komplexität: Bei einfachen REST-APIs mit konsistenten Daten ist REST vorzuziehen, während GraphQL für Projekte mit sich schnell ändernden Daten und Ressourcen bei der Anpassung der API viele Probleme lösen kann

  • erschwertes Caching: GraphQL verwendet im Gegensatz zu REST keine GET-Nachrichten (sondern POSTs), wodurch das klar definierte und durch Browser und Webserver unterstütze Caching-Verhalten nicht übertragen werden kann

  • sicherheitskritisch: GraphQL kann bei unvorsichtiger Implementierung sicherheitslücken öffnen und z.B. Table-Scans ermöglichen

2.8.1. Fazit

GraphQL bietet einige Vorteile, darunter Flexibilität und Effizienz. Da auch nur ein einziger Endpoint für die Abfrage nötig ist, ist GraphQL eine gute Alternative zu REST, wenn es sich um komplexere Projekte, mit schnell ändernden Daten mit großen Datensätzen handelt. Im Bezug auf eine Todo-Listen Anwendung reicht aufgrund der Einfachheit eine REST-API vollkommen aus. Die Struktur der Anwendung ist relativ einfach strukturiert und erfordert nicht die Komplexität die GraphQL bietet. Daher ist eine REST-API ausreichend um die CRUD-Operationen auf dieses Datenmodell zu unterstützen. Zudem sollte man abschließend noch erwähnen, dass das Errorhandling und die höhere Komplexität im Vergleich zu REST es erschwert, schnell und effizient zu Entwickeln.

2.9. Quellen