Konzepte, Ideen, Strategien
Konzepte, Ideen, StrategienDas Schema durch Feld-Versionierung weiterentwickeln

Das Schema durch Feld-Versionierung weiterentwickeln

Je mehr sich die Anforderungen unserer Anwendung weiterentwickeln, muss sich auch die GraphQL-API, die ihr Daten liefert, weiterentwickeln und Änderungen an ihrem Schema einführen. Wenn eine Änderung nicht brechend ist – wie beim Hinzufügen eines neuen Typs oder Feldes –, können wir sie direkt anwenden, ohne Nebenwirkungen befürchten zu müssen. Wenn die Änderung jedoch eine Breaking Change ist, müssen wir sicherstellen, dass wir keine Bugs oder unerwartetes Verhalten in der Anwendung einführen.

Breaking Changes sind solche, die einen Typ, ein Feld oder eine Direktive entfernen oder die Signatur eines bereits vorhandenen Feldes (oder einer Direktive) ändern, beispielsweise:

  • Ein Feld umbenennen
  • Den Typ eines vorhandenen Feldarguments ändern oder es verpflichtend machen
  • Ein neues Pflichtargument zum Feld hinzufügen
  • Non-nullable zum Antworttyp eines Feldes hinzufügen

Um mit Breaking Changes umzugehen, gibt es zwei Hauptstrategien: Versionierung und Evolution, so wie sie von REST bzw. GraphQL implementiert werden.

REST-APIs geben die zu verwendende API-Version an – entweder in der Endpoint-URL (wie https://api.mycompany.com/v1 oder https://api-v1.mycompany.com) oder über einen Header (wie Accept-version: v1). Durch Versionierung werden Breaking Changes einer neuen Version der API hinzugefügt, und da Clients explizit auf die neue API-Version verweisen müssen, sind sie sich der Änderungen bewusst.

GraphQL lehnt die Verwendung von Versionierung nicht ab, ermutigt aber zur Nutzung von Evolution. Wie auf der Seite der GraphQL Best Practices angegeben:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

Evolution verhält sich anders, da sie nicht wie die Versionierung nur alle paar Monate stattfinden soll. Sie ist vielmehr ein kontinuierlicher Prozess, der bei Bedarf sogar täglich stattfindet, was sie besser für schnelle Iterationen geeignet macht. Dieser Ansatz wurde von Principled GraphQL formuliert, einer Sammlung von Best Practices zur Entwicklung eines GraphQL-Dienstes, in seinem fünften Prinzip:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Das Schema weiterentwickeln

Durch Evolution müssen Felder mit Breaking Changes den folgenden Prozess durchlaufen:

  1. Das Feld unter einem anderen Namen neu implementieren.
  2. Das Feld als deprecated markieren und Clients bitten, stattdessen das neue Feld zu verwenden.
  3. Sobald das Feld von niemandem mehr genutzt wird, es aus dem Schema entfernen.

Schauen wir uns ein Beispiel an. Angenommen, wir haben einen Typ Account, der ein Konto als Person mit Vor- und Nachname über dieses Schema modelliert (mit GraphQLs SDL – Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

In diesem Schema sind sowohl die Felder name als auch surname verpflichtend (das ist das !-Symbol nach dem Typ String), da wir davon ausgehen, dass alle Personen sowohl einen Vor- als auch einen Nachnamen haben.

Irgendwann erlauben wir auch Organisationen, Konten zu eröffnen. Organisationen haben jedoch keinen Nachnamen, weshalb wir die Signatur des Feldes surname ändern müssen, um es nicht mehr verpflichtend zu machen:

type Account {
  id: Int
  name: String!
  surname: String # Das hat sich geändert
}

Dies ist eine Breaking Change, da die Anwendung nicht erwartet, dass das Feld surname null zurückgibt, und daher diese Bedingung möglicherweise nicht prüft, wie bei der Ausführung dieses JavaScript-Codes:

// This will fail when account.surname is null
const upperCaseSurname = account.surname.toUpperCase();

Die potenziellen Bugs, die aus Breaking Changes resultieren, können vermieden werden, indem das Schema weiterentwickelt wird:

  • Wir ändern die Signatur des Feldes surname nicht; stattdessen markieren wir es als deprecated und fügen eine hilfreiche Nachricht hinzu, die den Namen des ersetzenden Feldes angibt
  • Wir führen einen neuen Feldnamen personSurname (oder accountSurname) in das Schema ein

Unser Typ Account sieht nun so aus:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

Indem wir schließlich die Logs der queries unserer Clients sammeln, können wir analysieren, ob sie auf das neue Feld umgestiegen sind. Sobald wir feststellen, dass das Feld surname von niemandem mehr genutzt wird, können wir es aus dem Schema entfernen:

type Account {
  id: Int
  name: String!
  personSurname: String
}

Probleme mit der Evolution

Das oben beschriebene Beispiel ist sehr einfach, zeigt aber bereits ein paar potenzielle Probleme bei der Weiterentwicklung des Schemas:

ProblemBeschreibung
Feldnamen werden weniger übersichtlichBeim ersten Benennen eines Feldes finden wir möglicherweise den optimalen Namen dafür, wie surname. Wenn wir es jedoch ersetzen müssen, müssen wir einen anderen Namen dafür erstellen, der möglicherweise suboptimal ist (der optimale ist bereits vergeben!). Alle möglichen Ersetzungen im obigen Beispiel haben Probleme:

- personName macht explizit, dass das Konto für eine Person ist; wenn wir später ein Konto für eine Nicht-Person mit Nachnamen eröffnen müssen (wer weiß... ein Marsianer?), müssen wir das Schema erneut weiterentwickeln, um konsistente Namen beizubehalten
- Das „Account"-Bit in accountName ist völlig redundant, da der Typ bereits Account ist
- Welchen anderen Namen soll man sonst verwenden? surname1? surnameNew? Oder noch schlimmer, surnameV2?

Infolgedessen wird das aktualisierte Schema weniger verständlich und umfangreicher.
Das Schema kann deprecated Felder ansammelnFelder zu deprecaten ist am sinnvollsten als vorübergehender Umstand; letztendlich würden wir diese Felder wirklich gern aus dem Schema entfernen, um es aufzuräumen, bevor sie sich ansammeln.

Es könnte jedoch Clients geben, die ihre queries nicht überarbeiten und weiterhin Informationen aus dem deprecated Feld abrufen. In diesem Fall wird unser Schema langsam, aber stetig zu einer Art Feld-Friedhof und sammelt mehrere verschiedene Felder für dieselbe Funktionalität an.

Schauen wir uns an, wie wir diese Probleme lösen können.

Felder versionieren

Wir können unser Feld mit einem Argument namens version erstellen, über das wir angeben, welche Version des Feldes verwendet werden soll.

In diesem Szenario müssen wir die Implementierung für das deprecated Feld dennoch beibehalten, sodass wir in dieser Hinsicht keine Verbesserung erzielen. Jedoch wird sein Vertrag verborgen: Das neue Feld kann nun seinen ursprünglichen Namen behalten (es muss nicht von surname in personSurname umbenannt werden), was verhindert, dass unser Schema zu umfangreich wird.

Beachte, dass dieses Konzept der Versionierung sich von dem in REST unterscheidet:

  • REST legt eine Alles-oder-nichts-Situation fest, bei der die gesamte abgefragte API dieselbe Version hat, da die zu verwendende Version Teil des Endpoints ist
  • Bei diesem anderen Ansatz wird jedes Feld unabhängig versioniert

Daher können wir auf verschiedene Versionen für verschiedene Felder zugreifen, wie folgt:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Außerdem können wir durch das Verwenden von Semantic Versioning Versionsbeschränkungen zur Auswahl der Version nutzen und dabei dieselben Regeln von Composer zur Deklaration von Paketabhängigkeiten befolgen. Wir benennen dann das Feldargument version in versionConstraint um und aktualisieren die query:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Wenden wir diese Strategie auf unser deprecated Feld surname an, können wir nun die deprecated Implementierung als Version "1.0.0" und die neue Implementierung als Version "2.0.0" kennzeichnen und auf beide zugreifen, sogar in derselben query:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Diese Funktion ist in Gato GraphQL verfügbar:

Felder über Versionsbeschränkungen abfragen

Direktiven versionieren

Da Direktiven ebenfalls Argumente entgegennehmen, können wir genau dieselbe Methodik auch für die Versionierung von Direktiven anwenden!

Wenn beispielsweise diese query ausgeführt wird:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

Kann sie für jede Version der Direktive eine andere Antwort erzeugen:

Eine versionierte Direktive abfragen