Skip to content

SS2019: Technologierecherche – Kommunikation: GraphQL Implementierung

Dennis Dubbert edited this page Aug 2, 2019 · 36 revisions

Diese Technologierecherche steht in starkem Bezug zu einem Projekt namens "Projekt Q", welches im Rahmen des Studien-Moduls "Projekt 2: Entwicklung" entsteht. Das Projekt befasst sich mit folgender Problematik:

Zahlreiche landwirtschaftliche Betriebe der Region sind in der Lage, marktfähige, regionale Produkte in großer Vielfalt herzustellen. Allerdings verfügen Sie weder über den Marktzugang noch über die Voraussetzungen, diesen ggf. erfolgreich zu gestalten ... Abhilfe soll daher ein tagesaktuelles, ständig wechselndes Frischesortiment auch für den Privatkonsumenten und anderen gewerblichen Abnehmern wie Gastronomie und Caterern eingeführt werden. Dieses soll online verfügbar stehen, worin a) Landwirtschaftliche Erzeuger Ihre Produkte und Produktüberhänge einer breiten Öffentlichkeit anbieten und direkt vermarkten können und/oder b) diese über die bereits vorhandene Logistik und angefahrene Routen der Bergisch Pur Vertriebsgesellschaft zu verwalten, fakturieren und auszuliefern um hof-ferne Verbraucher erreichen zu können.

Für den Technologiestack dieser Anwendung wurde GraphQL als fester Bestandteil gesetzt, sowie die Architektur in Form von Microservices angestrebt. Da die Teammitglieder jedoch noch nicht mit GraphQL gearbeitet haben, soll diese Recherche nun als Einstieg in die Technologie gelten, sowie Möglichkeiten der Implementierung von Microservices mit GraphQL aufzeigen.

Schema Definition Language (SDL) / Typsystem & Resolver

Quelle: GraphQL-Spezifikation Typsystem (Juni 2018)

Folgende Beispiele für Resolver-Funktionen werden anhand von graphql.js definiert, da dieses eine GraphQL-Referenzimplementierung für Javascript darstellt und Resolver in der Spezifikation sehr abstrakt beschrieben sind.

Queries / Mutations

Beschreibung:

Queries und Mutations gehören zu den Root-Typen des Typsystems von GraphQL und sind somit die Eintrittspunkte für eine Anfrage. Sie können mit Funktionen verglichen werden, welche einen Funktionsnamen, Übergabeparameter und Rückgabewerte besitzen. Queries dienen hierbei der reinen Anfrage von Daten, Mutations der Erstellung und Veränderung von Entitäten. Auf CRUD bezogen sind Queries also das READ und Mutations das CREATE, UPDATE und DELETE.

Typdefinition:

Bei der Typdefinition ist darauf zu achten, dass Queries und Mutations als eigenständige Typen gelten, deren Attribute die eigentlichen Anfragen darstellen.

type Query {
  QueryName(ParameterName: ParameterTyp) : RueckgabeTyp

  getProduct(productId: ID!) : Product
}
  
type Mutation {
  MutationName(ParameterName: ParameterTyp) : RueckgabeTyp

  rateProduct(productId: ID!, customerId: ID!, score: Int!, comment: String!) : Rating!
}

Resolver:

Query- und Mutation-Resolver sind, wie der jeweilige Typ, einem Query- / Mutation-Objekt als Attribut zuzuordnen. Der Attributs-Name ist hierbei identisch zu dem Type-Attribut aus dem Schema. Weiterhin sind diese Resolver Funktionen, welche vier Parameter übergeben bekommen: parent, args, context und info. Solche Funktionen sind oft asynchron, da sie auf andere Datenquellen zugreifen.

  • parent: Beinhaltet das Objekt, was von einem vorherigen Resolver zurückgegeben wurde.
  • args: Beinhaltet alle, von dem anfragenden Client, übergebenen Argumente.
  • context: Beinhaltet geteilten internen state, welcher in jedem Resolver erreichbar ist (und beim Start des Servers festgesetzt wurde).
  • info: Beinhaltet genaue Informationen über die Anfrage und dessen momentanen Status. (Welche Attribute wurden angefragt? Auf welcher Verschachtelungsebene befinde ich mich? etc.)
Query {
  QueryName: (parent, args, context, info) => {
    // 1. Benötigte Argumente und andere Informationen aus den Übergabeparametern ermitteln
    // 2. Benötigte Daten anfordern (z.B. aus einer Datenbank oder anderem Service)
    // 3. Daten zurückgeben
  }

  getProduct: async (parent, args, context, info) => {
    const { productId } = args
    const product = await productDB.get(productId)
    return product
  }
}

Scalar Type

Beschreibung:

Wie in Java oder anderen objektorientierten Sprachen finden sich auch bei GraphQL Scalar-Typen, welche als grundlegende Typen gelten, aus denen sich alle anderen Typen zusammensetzen. Diese sind unter anderem:

  • Int
  • Float
  • String
  • Boolean
  • ID : Ähnlich dem String-Type. Hat eher eine symbolische Bedeutung und repräsentiert einzigartige und nicht menschlich lesbare (ausgehend vom Sinn) Identifikationen.

Es können zudem eigene Scalar-Types definiert werden, hierbei ist jedoch anzugeben wie diese serialisiert und deserialisiert werden.

Typdefinition:

  scalar DateTime

Resolver:

Ein Resolver für Scalar-Types muss mindestens 4 Eigenschaften angeben:

  • name: Name des Typen.
  • serialize: Wie wird der Wert vom Server serialisiert, bevor er an den den Client geschickt wird.
  • parseValue: Wie wird ein Wert interpretiert, der als Variable übergeben wurde.
  • parseLiteral: Wie wird ein Wert interpretiert, der inline direkt in einer Query als String angegeben wurde.
  • (Optional) description: Beschreibung des Typen.

(sehr gut beschrieben hier)

DateTime: new GraphQLScalarType({
  name: 'DateTime',
  description: 'The `DateTime` scalar represents a date and time following the ISO 8601 standard',
  serialize(value) {
    // value sent to the client
    return value
  },
  parseValue(value) {
    // value from the client
    if (!moment(value).isValid) {
      throw new TypeError(
        `DateTime must be in a recognized RFC2822 or ISO 8601 format ${String(value)}.`
      )
    }

    return moment.utc(value).toISOString()
  },
  parseLiteral(ast) {
    if (ast.kind !== Kind.STRING) {
      throw new TypeError(
        `DateTime cannot represent non string type ${String(ast.value != null ? ast.value : null)}`
      )
    }

    return moment.utc(ast.value).toISOString()
  },
})

Quelle: graphql-type-datetime GitHub

Nullability:

Jedes Objekt / Jeder Typ einer GraphQL-Schnittstelle kann nullbar oder nicht nullbar sein. Wird ein Typ mit einem Ausrufezeichen ergänzt, so ist dieser immer vorhanden und kann nicht null sein. Spezialfälle finden sich bei Arrays. Diese können wie folgt gekennzeichnet werden:

  • [Typ] : Sowohl das Array, als auch dessen Inhalt kann null sein.
  • [Typ]! : Das Array muss vorhanden sein, dessen Inhalt kann jedoch null sein.
  • [Typ!] : Das Array kann null sein, dessen Inhalt muss jedoch vorhanden sein. (Der am häufigsten auftretende Fall)
  • [Typ!]! : Sowohl das Array, als auch dessen Inhalt müssen vorhanden sein.

Object Type

Beschreibung:

Object-Types sind Repräsentationen der Entitäten einer Schnittstelle / eines Systems. Diese besitzen einen Namen und verschiedene Attribute, welchen jeweils ein Typ zugeordnet wird. So können Object-Types, abgesehen von Input-Types, aus allen Typen zusammengesetzt werden um deren Zusammengehörigkeiten zu definieren.

Typdefinition:

Der Aufbau eines Object-Types im Schema ähnelt dem eines JSON-Objektes, nur dass dem Objektnamen das Schlagwort "type" vorausgestellt ist und den Keys keine direkten Ausprägungen, sondern lediglich Typen zugeordnet werden.

type Product {
  id: ID!
  title: String!
  description: String!
  imageUrl: String
  ratings: [Rating]!
  averageRatingScore: Float
}
  
type Customer {
  id: ID!
  name: String!
  ratings: [Rating]!
}
  
  
type Rating {
  id: ID!
  product: Product!
  customer: Customer!
  score: Int!
  comment: String!
}

Quelle: Einführung in GraphQL - JAXEnter

Resolver:

Für jeden Object-Type kann ein eigenes Resolver-Objekt implementiert werden. Dies ist jedoch nur notwendig, wenn die Objektstruktur der Rückgabe einer Query / Mutation von der des korrespondierenden Typen (aus dem Schema) variiert. Beinhaltet die Rückgabe einer Query / Mutation für den Typen "Customer" (siehe oberes Beispiel) als ratings lediglich ein Array von Referenzen zu den eigentlichen Objekten, so wird für dieses Attribut ein eigener Resolver benötigt, welcher die Referenzen auflöst und die vollständigen Objekte ergänzt. Hierzu kann er über das parent Objekt auf die Ausgabe der zuvor durchlaufenen Query zugreifen. Diese Auflösung könnte auch in dem jeweiligen Query- / Mutation-Resolver vorgenommen werden, jedoch werden Resolver für Object-Types immer aufgerufen wenn dieser Typ angefragt wird, sodass eine automatische Wiederverwendung gegeben ist und Redundanzen im Code vermieden werden. Auch diese Resolver erhalten die Parameter: parent, args, context und info.

/** Datenstruktur des Customers in der Datenbank
 * {
 *   id: "sjhd23oah",
 *   name: "peter lustig",
 *   ratingIds: ["asklhd234", "asodao123", "ioho23hii"],
 * }
*/

Query: {
  getCustomers: async (parent, args, context, info) => {
    const customers = await customerDB.getCustomers()
    return customers
  },
},
Customer: {
  ratings: async (parent, args, context, info) => {
    const { ratingIds } = parent
    const ratings = await ratingDB.getRatingsByIdArray(ratingIds)
    return ratings
  }
}

Input Type

Beschreibung:

Input-Types sind ähnlich den Object-Types, nur dass diese als Übergabeparameter genutzt werden um die Parameterliste zu reduzieren. Im Gegensatz zu Object-Types können hier jedoch keine erweiterten Strukturen wie Interfaces oder Union-Types genutzt werden.

Typdefinition:

Vor dem Typ-Namen wird hierbei das Schlagwort "input" genutzt.

""" Ohne input type """
type Mutation {
  rateProduct(productId: ID!, customerId: ID!, score: Int!, comment: String!) : Rating!
}

""" Mit input type """
type Mutation {
  rateProduct(productId: ID!, ratingInput: RatingCreateInput!) : Rating!
}

input RatingCreateInput {
  customerId: ID!
  score: Int!
  comment: String!
}

Resolver:

Da Input-Types Eingaben des Nutzers repräsentieren, wird für sie kein Resolver benötigt.

Enum Type

Beschreibung:

Ähnlich anderen Sprachen findet sich auch bei GraphQL / SDL ein Enum-Type, welcher eine Auswahl fest definierter Ausprägungen darstellt.

Typdefinition:

Die Ausprägungen werden per Konvention groß geschrieben.

enum Day {
  MONDAY
  TUESDAY
  WEDNESDAY
  THURSDAY
  FRIDAY
  SATURDAY
  SUNDAY
}

Resolver:

Der Resolver eines Enum-Types überführt die Ausprägungen in den jeweils zugehörigen Wert (meist String oder Int), welcher für Datenrepräsentationen im Backend / Datenbanken genutzt wird. So kann die Eingabe eines Clients in den entsprechenden String, bzw. der String bei einer Ausgabe in die entsprechende Ausprägung überführt werden.

Day: {
  MONDAY: "monday",
  TUESDAY: "tuesday",
  WEDNESDAY: "wednesday",
  THURSDAY: "thursday",
  FRIDAY: "friday",
  SATURDAY: "saturday",
  SUNDAY: "sunday"
}

Interface & Union Type

Beschreibung:

Auch in GraphQL gibt es abstrakte Datentypen, hier Interfaces und Union Types. Interfaces repräsentieren eine Oberkategorie mit Eigenschaften, welche andere Typen implementieren können. Diese Datenstruktur wird gewählt, wenn verschiedene Typen geteilte Eigenschaften besitzen und eine logische Verwandtschaft ersichtlich ist. Auch Union-Types stellen eine Zusammenführung verschiedener Typen dar, jedoch finden sich hier keine geteilten Eigenschaften. Union-Types werden genutzt, wenn Typen unterschiedlicher Arten für bestimmte Zwecke zu einer Kategorie zusammengeführt werden müssen (siehe Typdefinition).

Typdefinition:

Sub-Typen eines Interfaces müssen die geteilten Attribute jeweils redundant noch einmal selber definieren, können jedoch noch eigene Attribute ergänzen.

""" Interfaces """
interface User {
  id: ID!
  name: String!
}

type Producer implements User{
  id: ID!
  name: String!
  products: [Product]!
}

type Consumer implements User{
  id: ID!
  name: String!
  transfer_accounts: [TransferAccount]!
}

""" Union-Types """
TransferAccount = Paypal | Bank

type Paypal {
  email: String!
}

type Bank {
  account_number: String!
  bank_code: String!
  bank_name: String!
}

Resolver:

Damit der Server bei einer Ausgabe von Daten zwischen den unterschiedlichen Sub-Typen bzw. Teil-Objekten unterscheiden kann, muss hier für das jeweilige Interface oder den Union-Type ein Resolver geschrieben werden, welche das Objekt erhält und die Unterscheidung vornimmt. Dieser ist für die beiden erweiterten Typen identisch aufgebaut und muss als Rückgabewert den Namen des identifizierten Sub-Typen bieten. Häufige Anhaltspunkte zur Ermittlung des Sub-Typen sind das Vorhandensein bestimmter Attribute (gerade bei Union-Types) oder eine bestimmte Ausprägung eines Attributs (z.B. type als Enum).

TransferAccount: {
  __resolveType: (object) => {
    if (object.email) return "Paypal"
    if (object.account_number) return "Bank"
    throw new Error("Could not identify account.")
  }
}

Directives

Beschreibung:

Directives sind Erweiterungen für GraphQL-Komponenten, welche diese nun um eine weitere Funktionalität ergänzt. Somit könnten sie auch als "Decorator" gesehen werden, welche in zahlreichen anderen Sprachen zum tragen kommen. Solche Directives werden einmal definiert (mehr dazu in dem folgenden Abschnitt) und sind dann für jedes dekorierte Feld / jeden dekorierten Typen gültig. Sie besitzen stets einen Namen, werden durch ein vorangestelltes "@" gekennzeichnet, können optional Argumente entgegen nehmen und sind nur an bestimmten Locations (GraphQL-Komponenten wie Felder oder Typen) positionierbar.

Directives finden breit gefächerte Anwendungsgebiete. Einige der häufigsten sind:

  • Erzwingen von Zugriffsrechten für einzelne Felder
  • Vom Client vorgegebene Formatierung von "date strings"
  • Internationalisierung von Strings (Angabe der gewünschten Sprache)
  • Spezifizierung des Cache-Verhaltens
  • Felder als veraltet kennzeichnen
  • Felder überspringen oder speziell einbeziehen
  • vieles mehr ...

Allgemein finden sich zwei Arten von Directives: Schema Directives, welche statisch serverseitig bei der Schema-Erstellung definiert werden und Query Directives, welche ein Client selbst aufrufen und mit Argumenten versehen muss. Da letztere jedoch für jeden Client zusätzlichen Aufwand bedeuten und Live-Transformationen der Queries und Mutations benötigen, werden sie z.B. von Apollos Frameworks (noch) nicht unterstützt. Apollos Statement:

While directive syntax can also appear in GraphQL queries sent from the client, implementing query directives would require runtime transformation of query documents. We have deliberately restricted this implementation to transformations that take place when you call the makeExecutableSchema function—that is, at schema construction time. We believe confining this logic to your schema is more sustainable than burdening your clients with it, though you can probably imagine a similar sort of abstraction for implementing query directives. If that possibility becomes a desire that becomes a need for you, let us know, and we may consider supporting query directives in a future version of these tools. (siehe Apollo Docs)

Aus diesem Grund wird in diesem Dokument auch lediglich auf die Schema Directives eingegangen.

Im working draft der GraphQL-Spezifikation werden bereits drei Directives genannt, welche von jedem GraphQL-Framework vor implementiert werden sollten: @skip, @include und @deprecated.

@skip und @include beziehen sich beide ausschließlich auf ExecutableDirectiveLocations (Locations die für Query Directives benötigt werden), in diesem Falle FIELD, FRAGMENT_SPREAD und INLINE_FRAGMENT, sodass die Angabe der Argumente dynamisch von dem jeweiligen Client verlangt wird. Sie dienen der Spezifikation einer Bedingung, anhand dessen ein Feld in- oder exkludiert wird.

@deprecated bezieht sich ausschließlich auf TypeSystemDirectiveLocations (Locations die für Schema Directives benötigt werden), dessen Argumente werden also statisch vom Server vorgegeben. Es dient der Markierung einzelner Felder oder Enum-Werte als veraltet. Zusätzlich kann hier ein Grund angegeben werden, in welchem beispielsweise auch das Feld genannt werden kann, welches als neue Alternative zu nutzen ist.

Typdefinition:

Die Definition eines Directives hat folgende Form:

  directive @directiveName(
    argument1: argumentType
    argument2: argumentType
  ) on Location1 | Location2 | ... | LocationN

Zu jedem Directive muss ein eigener Name, sowie zulässige Locations angegeben werden. Die Angabe von Argumenten ist optional und muss stets aus dem Kontext des Directives erschlossen werden.

Das zuvor genannte @deprecated-Directive ist beispielsweise wie folgt definiert:

directive @date(
  format: String
) on FIELD_DEFINITION

Anschließend kann dieses Directive für Feld-Definitionen verwendet werden. In diesem Fall ist es bei der Objekt-Definition hinter dem Typen des Feldes anzuordnen.

scalar Date

type Query {
  today: Date @date(format: "mmmm d, yyyy")
}

(Beispiele entnommen aus den Apollo Docs)

Viele weitere Beispiele für Schema Directives sind in den Apollo Docs einsehbar.

Resolver:

Die GraphQL-Spezifikation definiert keine einheitliche Form der Umsetzung / Integration von Directives, sodass diese Definition jedem GraphQL-Server-Framework überlassen bleibt. Da Apollo-Server das meist genutzte dieser Frameworks darstellt, wird im folgenden lediglich auf deren Implementierung eingegangen.

Apollo (graphql-tools) hatte zuvor sogenannte directiveResolver, welche als Wrapper für die eigentlichen Entitäts- und Feld-Resolver genutzt wurden. Diese wurden jedoch von der SchemaDirectiveVisitor-Klasse abgelöst, welche mehr Flexibilität bieten soll. Um nun ein Directive zu implementieren, muss eine korrespondierende Klasse geschaffen werden, welche von dem SchemaDirectiveVisitor erbt. Je nach angegebenen Locations des Directives müssen nun eine oder mehrere der folgenden Methoden überschrieben und mit der gewünschten Funktionalität gefüllt werden:

  • visitSchema(schema: GraphQLSchema)
  • visitScalar(scalar: GraphQLScalarType)
  • visitObject(object: GraphQLObjectType)
  • visitFieldDefinition(field: GraphQLField<any, any>)
  • visitArgumentDefinition(argument: GraphQLArgument)
  • visitInterface(iface: GraphQLInterfaceType)
  • visitUnion(union: GraphQLUnionType)
  • visitEnum(type: GraphQLEnumType)
  • visitEnumValue(value: GraphQLEnumValue)
  • visitInputObject(object: GraphQLInputObjectType)
  • visitInputFieldDefinition(field: GraphQLInputField)

Für das im vorherigen Abschnitt definierte @date-Directive, welches lediglich für Feld-Definitionen nutzbar ist, würde solche eine Klasse wie folgt aussehen:

class DateFormatDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { format } = this.args;
    field.resolve = async function (...args) {
      const date = await resolve.apply(this, args);
      return require('dateformat')(date, format);
    };
    // The formatted Date becomes a String, so the field type must change:
    field.type = GraphQLString;
  }
}

Diese muss anschließend noch bei der Erstellung eines ausführbaren Schemas angegeben werden, bevor es im Schema genutzt werden kann:

const schema = makeExecutableSchema({
  typeDefs,
  schemaDirectives: {
    date: DateFormatDirective
  }
})

Nun wäre es jedoch praktisch, diese Angabe eines Formats dem Client zu überlassen, was jedoch ein Query Directive verlangen würde. Als Ausweg kann hier jedoch auch durch ein Schema Directives das jeweilige Feld mit eigenen Argumenten angereichert werden, sodass diese vom Client nicht als Directive-Argumente, sondern als normale Feld-Argumente eingegeben werden müssen. Dies würde im Falle des @date-Directives wie folgt aussehen:

directive @date(
  defaultFormat: String = "mmmm d, yyyy"
) on FIELD_DEFINITION

scalar Date

type Query {
  today: Date @date
}
class FormattableDateDirective extends SchemaDirectiveVisitor {
  public visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { defaultFormat } = this.args;

    field.args.push({
      name: 'format',
      type: GraphQLString
    });

    field.resolve = async function (
      source,
      { format, ...otherArgs },
      context,
      info,
    ) {
      const date = await resolve.call(this, source, otherArgs, context, info);
      // If a format argument was not provided, default to the optional
      // defaultFormat argument taken by the @date directive:
      return formatDate(date, format || defaultFormat);
    };

    field.type = GraphQLString;
  }
}

const schema = makeExecutableSchema({
  typeDefs,
  schemaDirectives: {
    date: FormattableDateDirective
  }
})

Ein Aufruf der Query könnte nun wie folgt aussehen:

query {
  today(format: "d mmm yyyy")
}

(Beispiele entnommen aus den Apollo Docs)

Subscriptions

Beschreibung:

In den meisten Architekturen wird eine asynchrone Möglichkeit benötigt, den Client über Neuerungen zu informieren. Für GraphQL wurde in der Spezifikation solch ein Mechanismus in Form von Subscriptions berücksichtigt. Subscriptions ermöglichen ein Pub-Sub System, welches von der dynamischen Art, die GraphQL bietet, profitiert. Subscriptions sind hierbei neben Queries und Mutations eine dritte Root-Operation. Ein Nutzer abonniert indirekt über eine Subscription einen bestimmten Channel und erhält anschließend alle an den Channel gesendeten Nachrichten (meist neue Objekte einer Entität). Über eine Websocket-Verbindung werden diese Nachrichten asynchron an den Client übermittelt. Folgende Beispiele arbeiten mit dem Subscription-System von Apollo (siehe hier).

Typdefinition:

Die Definition einer Subscription ist sehr ähnlich zu der einer Query oder Mutation.

type Subscription {
  productAdded : Product!
  productAddedForProducer(producerId: ID!) : Product!
}

Über die erste Subscription erhält ein Client Nachrichten zu allen neu erstellten Produkten. Über die zweite Subscription können diese Nachrichten auch nach einem bestimmten Produzenten gefiltert werden.

Resolver:

Der Resolver für eine Subscription übernimmt hier nicht die Aufgabe, die Daten aus einer bestimmten Quelle zu extrahieren und zurückzugeben, sondern den Client an einen bestimmten Channel anzubinden. Der Resolver für die erste Subscription sieht demnach wie folgt aus:

const { PubSub } = require(“graphql-subscriptions”)
const pubsub = new PubSub()

module.exports = {
  Subscription: {
    productAdded: {
      resolve: (payload, args, context, info) => {
        // Manipulate and return the new value
        return payload.productAdded;
      },
      subscribe: (parent, args, context, info) => {
        return pubsub.asyncIterator(“productAddedChannel”)
      }
    }
  },
}

Bei diesen Resolvern muss für das Attribut "subscribe" eine Funktion definiert werden, welche dieselben Parameter übergeben bekommt wie die anderen Resolver. Diese Methode muss einen Async-Iterator zurückgibt. Dessen Aufgabe ist die Benachrichtigung des Clients bei eintreffenden Nachrichten den Channels. Zudem wird ein Message-Router (hier pubsub) benötigt, welcher Nachrichten entgegen nimmt und an die Channel verteilt. Das Modul graphql-subscriptions bietet uns hier ein PubSub-Objekt, welches diese Aufgabe übernimmt und uns sogar mithilfe der asyncIterator("channelName")-Methode eine Möglichkeit bietet, den Client an einen Channel anzubinden. Wichtig ist hier jedoch, dass im gesamten Server lediglich eine PubSub-Instanz vorhanden sein sollte, auf welche alle Resolver zugreifen können (meist mithilfe des contexts verteilt). Der Payload kann zudem über das "resolve"-Attribut manipuliert werden, sollte dies gewünscht sein (ansonsten kann es weg gelassen werden). Zudem können gleichzeitig mehrere Channel innerhalb einer Subscription abonniert werden. Hierfür wird anstelle eines einzelnen Channel-Namens, ein Array mit mehreren Namen an die "asyncIterator"-Methode übergeben.

Meist möchte eine Client jedoch die Nachrichten nach speziellen Faktoren filtern. Hierzu dient beispielsweise die folgende Form von des Resolvers, welche zu der zweiten Subscription gehört:

const { PubSub, withFilter } = require(“graphql-subscriptions”)
const pubsub = new PubSub()

module.exports = {
  Subscription: {
    productAddedForProducer: {
      subscribe: withFilter(
        (parent, args, context, info) => {
          return pubsub.asyncIterator(“productAddedForProducerChannel”)
        },
        (payload, variables) => {
          return payload.productAddedForProducer.producerId === variables.producerId
        }
      )
    }
  },
}

Hier wird zusätzlich die "withFilter"-Funktion des graphql-subscriptions-Moduls genutzt, welche die selbst geschriebene Resolver-Funktion des subscribe-Attributes ersetzt. Diese High-Order-Function nimmt wiederum zwei Funktionen entgegen. Die erste repräsentiert wiederum die Auswahl und Anbindung eines Channels (wie im vorherigen Beispiel). Die zweite Funktion ist für die Filterung der Nachrichten des Channels zuständig. Sie erhält die zwei Parameter "payload" und "variables", wobei ersteres die jeweilige Nachricht beinhaltet und das zweite die vom Client übermittelten Argumente (sonst immer als "args" bezeichnet). Anhand dieser Parameter kann nun entschieden werden, ob die Nachricht den Wünschen des Clients entspricht. Hier wurde beispielsweise überprüft, ob die ID des Produzenten, welcher das jeweilige Produkt erstellt hat, übereinstimmt. Wichtig ist, dass das Payload-Objekt stets so aufgebaut ist, dass die eigentliche Entität (hier das Produkt) in dem Key gespeichert ist, welcher den selben Namen wie die Subscription trägt (payload.productAddedForProducer).

Nun muss solch eine Subscription auch ausgelöst werden. Hierzu wird die "publish"-Methode der PubSub-Instanz genutzt, welche den Namen des Channels, sowie den Payload übergeben bekommt (auch hier die Syntax des Objektes beachten!). Dies findet meist innerhalb einer Mutation statt, da hier neue Objekte einer Entität / eines Typen erstellt, oder diese bearbeitet werden. Im folgenden Beispiel wurde die PubSub-Instanz über den context an die Resolver verteilt.

module.exports = {
  Mutation: {
    createProduct: (parent, args, context, info) => {
      const { pubsub } = context
      const product = // Produkt erstellen und persistieren

      pubsub.publish("productAddedChannel", { productAdded: product })

      return product
    },
  },
}

GraphQL Query Language (GQL)

Quelle: GraphQL-Spezifikation GQL (Juni 2018)

Queries und Mutations nutzen

Der Aufruf einer Query oder Mutation ähnelt dem Aufruf einer Funktion. Der Name der Operation wird angegeben, gefolgt von den Parametern in runden Klammern. Der einzige Unterschied liegt darin, dass bei dem Aufruf auch die Rückgabe spezifiziert werden muss (da ein Client auch nur ein Sub-Set der möglichen Attribute anfragen kann). Hierzu werden die gewünschten Attribute einer Entität in geschwungenen Klammern aufgeführt (es muss lediglich der Attributname angegeben werden). Syntaktisch unterscheiden sich Queries und Mutations nicht (abgesehen von dem vorangestellten Schlagwort 'query' bzw. 'mutation').

Solche Aufrufe können anonym sein, aber auch intern benannt werden. Die Benennung ist dann notwendig, wenn mehr als eine Query / Mutation vom Client genutzt werden.

Folgende Beispiele arbeiten mit diesem Typ-System:

type Query {
  getProducers(producerId: ID) : [Producer!]
}

type Producer {
    id: ID!
    username: String!
    email: String!
    description: String
    products(name: String): [Product!]
}

type Product {
  id: ID!
  title: String!
  imageUrl: String
}

Anonymer Aufruf:

Alle Produzenten und von jedem Produzenten die ID, den Benutzernamen und die Produkte Anfragen. Da die Produkte ein verschachteltes Attribut darstellen, werden hier wiederum die ID und der Name / Titel angefragt.

{
  getProducers {
    id
    username
    products {
      id
      title
    }
  }
}

Benannter Aufruf:

Es werden die selben Attribute wie in dem anonymen Aufruf angefragt. Dieser Aufruf wurde von dem Client jedoch (intern) benannt um so eine Wiedererkennung im Client-Code zu gewährleisten und mehrere Aufrufe voneinander unterscheiden zu können (die Benennung sollte immer genutzt werden). Hierdurch muss der Aufruf zusätzlich mit dem jeweiligen Typen gekennzeichnet werden, in diesem Falle "query".

query alleProduzenten {
  getProducers {
    id
    username
    products {
      id
      title
    }
  }
}

Benannter Aufruf mit Parametern:

In diesem Aufruf werden Übergabeparameter genutzt, um einen speziellen Produzenten zu erhalten. Solche Parameter können serverseitig auch für Attribute angeboten werden (siehe Typ-Definition), sodass hier die Produkte eines Produzenten weiterhin nach Namen gefiltert werden.

query alleProduzenten {
  getProducers(producerId: "aosdha23") {
    id
    username
    products(name: "Apfel") {
      id
      title
    }
  }
}

(Wie solch ein Aufruf über einen eigenen HTTP-Request gesendet werden kann wird hier beschrieben)

Variables

Im Produktiv-Code werden solche Aufrufe meist wiederverwendet, jedoch bei jeder Verwendung variierende Parameter benötigt. Hierzu können Variablen genutzt, welche als Platzhalter in dem Aufruf definiert und bei der Verwendung gefüllt werden. Hierzu ist die Benennung eines Aufrufs verpflichtend. Die Variablen werden dem benannten Aufruf als Parameter übergeben und können anschließend in diesem verwendet werden.

query alleProduzenten($producerId: ID!, $productName: String!) {
  getProducers(producerId: $producerId) {
    id
    username
    products(name: $productName) {
      id
      title
    }
  }
}

Variablen werden mit einem $-Zeichen gekennzeichnet und für sie muss immer ein Typ definiert werden. Im Playground können anschließend entsprechende Variablen in dem Bereich "Query Variables" wie folgt befüllt werden:

{
  producerId: "aosdha23",
  productName: "Apfel"
}

(Wie solch ein Aufruf über einen eigenen HTTP-Request gesendet werden kann wird hier beschrieben)

Fragments

Meist werden Entitäten in unterschiedlichen Aufrufen angefragt. So können Produkte beispielsweise über den jeweiligen Produzenten, aber auch durch eine eigene Produkt-Query erreicht werden. Damit nicht in jeder Query wieder die selben Attribute für eine Entität geschrieben werden müssen, können diese in ein Fragment zusammengefasst und in den unterschiedlichen Queries nun lediglich dieses Fragment referenziert werden.

Ohne Fragmente:

query alleProduzenten {
  getProducers {
    id
    username
    products {
      id
      title
      description
      imageUrl
    }
  }
}

query alleProdukte {
  getProducts {
    id
    title
    description
    imageUrl
  }
}

Mit Fragmenten:

fragment ProductFragment on Product {
  id
  title
  description
  imageUrl
}

query alleProduzenten {
  getProducers {
    id
    username
    products {
      ...ProductFragment
    }
  }
}

query alleProdukte {
  getProducts {
    ...ProductFragment
  }
}

Fragmente werden (wie in dem Beispiel) stets für einen Typen definiert und benannt. Anschließend können sie mithilfe des "..."-Operators in den Queries und Mutations referenziert werden (ähnlich dem Verhalten des Spread-Operators aus ES6).

(Wie solch ein Aufruf über einen eigenen HTTP-Request gesendet werden kann wird hier beschrieben)

Inline-Fragments

Das Typen-System einer GraphQL-Schnittstelle kann Interfaces und Union-Types beinhalten. Angenommen wir haben folgendes Interface:

interface User {
  id: ID!
  name: String!
}

type Producer implements User{
  id: ID!
  name: String!
  products: [Product]!
}

type Consumer implements User{
  id: ID!
  name: String!
  transfer_accounts: [TransferAccount]!
}

Gibt nun eine Query eine Entität vom Typ "User" zurück, so kann dies ein Producer oder Consumer sein, welche beide teils gleiche aber auch unterschiedliche Attribute aufweisen. Hier kann es als Client nützlich sein diese Typen zu unterscheiden und die entsprechenden Attribute anzufragen. Hierzu sind Inline-Fragments zuständig. Mit diesen kann gesagt werden: "Im Falle eines Produzenten will ich diese Attribute, im Falle eines Konsumenten jene".

query alleUser {
  getUsers {
    id
    name
    ... on Producer {
      products { // gewünschte Attribute eines Produktes }
    }
    ... on Consumer {
      transfer_accounts {
        ...on Paypal { email }
        ...on Bank { account_number }
      }
    }
  }
}

Ähnlich den normalen Fragments findet sich hier auch der "..."-Operator, gefolgt von dem Stichwort "on" und dem Namen des Sub-Typen. Geteilte Attribute können außerhalb dieser Inline-Fragments definiert werden, da sie jeder User besitzt (hier id und name). "transfer_accounts" ist in diesem Beispiel ein Union-Type, bestehend aus den zwei Typen "Paypal" und "Bank". Auch für Union-Types sind Inline-Fragments nutzbar.

(Wie solch ein Aufruf über einen eigenen HTTP-Request gesendet werden kann wird hier beschrieben)

Eigene HTTP-Requests

Eigene HTTP-Requests an eine GraphQL-Schnittstelle werden als POST verschickt, da Queries / Mutations im Body der Anfrage übermittelt werden. Als Standard gilt die "/graphql"-Route als Endpoint eines Servers für solche Anfragen und der Content-Type muss application/json sein. Der Body unterliegen folgender Form:

Body: {
    “query”: ”query alleProducer($producerId: ID!){ getProducers(producerId: $producerId){ id name } },
    “operationName”: ”alleProducer”,
    “variables”: { producerId: "aosdha23" }
}

In dem Key "query" werden die geschriebenen Aufrufe und Fragments (siehe hier) übermittelt. Der Key "operationName" gibt Aufschluss darüber, welche dieser übermittelten Aufrufe vom Server ausgeführt werden soll. Variablen können im "variables"-Key übermittelt werden. Ein Response sieht anschließend folgendermaßen aus:

Response Body: {
    “data”: {
        “getProducers”: [{
            “id”: “aosdha23”,
            “name”: “ProduzentenName”
        }]
    }
}

Subscriptions nutzen

Ein Subscription-Aufruf ist identisch zu dem einer Query oder Mutation:

subscription productSubscription {
  productAdded {
    id
    title
    description
  }
}

Durch diesen Aufruf werden alle neu erstellten Produkte abonniert. Im Playground kann diese Subscription sofort genutzt werden, Subscriptions über eigene Websockets erfordern jedoch etwas mehr Aufwand.

(Folgende Informationen habe ich über das Monitoring der Playground-Aktivitäten bei einer Subscription ermittelt)

Hier muss zunächst eine Websocket-Verbindung mit dem Server aufgebaut werden. Läuft dieser lokal auf dem Port 3000, so wäre die Adresse "ws://localhost:3000/". Anschließend muss der Client über die Websocket-Connection eine Nachricht mit folgendem Inhalt schicken:

{
  payload: {},
  type: "connection_init"
}

Hierdurch wird die Verbindung der Subscription initiiert. Anschließend folgt eine Nachricht vom Client, in welcher er die eigentliche Subscription übermittelt:

{
  id: "1",
  type: "start",
  payload: {
    variables: {},
    extensions: {},
    operationName: "productSubscription",
    query: "subscription productSubscription { productAdded { id title description } }"
  }
}

Wurde die Verbindung vom Server akzeptiert, so sendet dieser eine Nachricht mit dem Inhalt type: "connection_ack". Wird vom Server nun ein Datensatz an den Nutzer geschickt, so sieht dieser wie folgt aus:

{
  type: "data",
  id: "1",
  payload: {
    data: {
      productAdded: {
       // angeforderte Attribute gefüllt
      }
    }
  }
}

Möchte ein Client die Verbindung abbrechen, so schickt dieser die Nachricht { id: "1", type:"stop" }. Server-Fehler sind hierbei stets mit dem Typen "error" gekennzeichnet:

{
  id: "1",
  type: "error",
  payload: {
    message: "Error Nachricht",
    name: "Error Name"
  }
}

Allgemein wird hier jedoch ein Modul wie apollo client genutzt, welches die Zugriffe abstrahiert / vereinfacht und weitere unterstützende Funktionen bietet, wie beispielsweise einen integrierten Cache.

Microservices mit GraphQL

Hier werden unterschiedliche Herangehensweisen der Erstellung von Microservices und einem API-Gateway mit GraphQL erläutert. Als Grundlage gilt hier zudem, dass jedes Bestandteil dieser Architektur mit einer GraphQL-Schnittstelle ausgestattet ist. Zunächst wird auf die "altmodische" Art des manuellen Schema-Stitchings in einem API-Gateway eingegangen und anschließend die Herangehensweise mit Apollo-Federation erläutert, welche am 30.05.2019 in seiner ersten Form veröffentlicht wurde. Da Federation noch nicht gänzlich Implementiert ist (noch nicht alle Anforderungen umgesetzt), ist diese Technologie noch nicht vollständig produktiv einsetzbar, wird hier jedoch aufgrund der sehr innovativen und hilfreichen Grundidee erläutert. Neben diesen beiden Ansätzen kann GraphQL natürlich auch als Gateway für unterschiedliche Microservices genutzt werden, welches eine vollständig eigene, ggf. an einen Client angepasste, Schnittstelle anbietet und lediglich über HTTP-Requests oder Websockets auf die Services zugreift um deren Daten abzufragen. Hier wäre dann egal ob diese Services ebenfalls GraphQL oder z.B. REST nutzen. Da dieser Ansatz jedoch keine weiteren Kenntnisse voraussetzt, als jene aus den vorherigen Kapiteln, wird er hier nicht gesondert aufgeführt.

Zur Erläuterung wird jeweils das selbe Beispiel umgesetzt, welches wie folgt aussieht:

  • Es werden zwei Mikroservices und ein Gateway erstellt.
  • Der erste Mikroservices verwaltet Nutzer und der zweite Mikroservice Produkte.
  • Das vollständige Typensystem des Gateways soll zum Schluss folgende Typen beinhalten:
type Query {
  getUsers: [User!]
  getUser(name: String!): User
  getProductsOfProducer(producerId: ID!): [Product!]
  getProductsBoughtBy(userId: ID!): [Product!]
  getProduct(productId: ID!): Product
}

type Mutation {
  createUser(userInput: UserCreateInput!): User!
  createProduct(producerId: ID!, productInput: ProductCreateInput!): Product!
}

type Subscription {
  userAdded: User!
  productAdded(producerId: ID!): Product!
}

interface User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
}

type Consumer implements User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
  purchases: [Product!]
}

type Producer implements User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
  products: [Product!]
}

input UserCreateInput {
  username: String!
  email: String!
  type: UserType!
}

enum UserType {
  PRODUCER
  CONSUMER
}

type Product {
  id: ID!
  name: String!
  unit: Unit!
  price_per_unit: Float!
  description: String
}

input ProductCreateInput {
  name: String!
  unit: Unit!
  price_per_unit: Float!
  description: String
}

input ProductQueryInput {
  name: String
  productId: ID
  producerId: ID
}

enum Unit {
  QUANTITY
  LITER
  KILOGRAM
}

Manuelles Schema-Stitching

Die vollständigen Code-Beispiele können in diesem GitHub-Repo eingesehen und zum ausprobieren heruntergeladen werden.

Aufbau der Services

In jedem Service werden lediglich die Typen definiert, welche im direkten Zusammenhang mit der Thematik des Services stehen. Im User-Service werden dementsprechend alle User spezifischen Typen definiert, jedoch noch keine Produkte referenziert (da dieser Service nicht für Produkte zuständig ist). Das Typensystem dieses Services sieht somit wie folgt aus:

type Query {
  getUsers: [User!]
  getUser(name: String!): User
}

type Mutation {
  createUser(userInput: UserCreateInput!): User!
}

type Subscription {
  userAdded: User!
}

interface User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
}

type Consumer implements User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
}

type Producer implements User {
  id: ID!
  username: String!
  email: String!
  type: UserType!
}

input UserCreateInput {
  username: String!
  email: String!
  type: UserType!
}

enum UserType {
  PRODUCER
  CONSUMER
}

Auch wenn ein Produzent natürlich Produkte anbieten wird, so werden diese im Produkt-Service definiert und der Zusammenhang zwischen Usern und Produkten erst im Gateway ergänzt. Das Typensystem des Produkt-Services ist nun wie folgt aufgebaut:

type Query {
  getProductsOfProducer(producerId: ID!): [Product!]
  getProductsBoughtBy(userId: ID!): [Product!]
  getProduct(productId: ID!): Product
}

type Mutation {
  createProduct(producerId: ID!, productInput: ProductCreateInput!): Product!
}

type Subscription {
  productAdded(producerId: ID!): Product!
}

type Product {
  id: ID!
  name: String!
  unit: Unit!
  price_per_unit: Float!
  description: String
}

input ProductCreateInput {
  name: String!
  unit: Unit!
  price_per_unit: Float!
  description: String
}

input ProductQueryInput {
  name: String
  productId: ID
  producerId: ID
}

enum Unit {
  QUANTITY
  LITER
  KILOGRAM
}

Die Implementierung der Resolver, sowie das Starten der Services wird in diesem Abschnitt nicht besprochen, da es sich von der herkömmlichen Herangehensweise nicht unterscheidet. Der Quellcode des hier genannten Beispiels findet sich jedoch hier, sodass diese Aspekte stets einsehbar sind.

Beim manuellen Schema-Stitching ist das wichtigste darauf zu achten, dass in den Services keine Typ-Dopplungen auftreten (Typen, welche in unterschiedlichen Services definiert werden), da dann bei dem späteren Stitching ein Fehler geworfen wird. Dennoch kann es teilweise von Vorteil sein, Referenzen zu Objekten von anderen Services zu speichern, da jeder Service auch gesondert vom Gateway verwendet werden könnte. In diesem Beispiel könnte beispielsweise eine Referenz vom Producer auf dessen Produkte hilfreich sein. In diesem Falle kann dem Producer ein Attribut "productIds: [ID!]" hinzugefügt werden, welches lediglich die IDs der Produkte beinhaltet und nicht Objekte vom Typ Produkt selbst. Optional können diese zudem noch mit dem @deprecated-Directive oder einem eigen definierten Directive versehen werden, um anzuzeigen, dass im Gateway eine bessere Alternative geboten wird.

Gateway: Remote-Schemas

Die erste Aufgabe des Gateways ist die Sammlung und Verschmelzung aller Service-Schemas. Weiterhin muss es sicherstellen, dass ein Aufruf an den jeweiligen Service weitergeleitet wird (welche dann als Remote-Schema bezeichnet werden). Hierzu können die Funktion "introspectSchema", "makeRemoteExecutableSchema" sowie "mergeSchemas" des graphql-tools-Moduls von Apollo genutzt werden. Weiterhin wird ein fetch-Modul (hier node-fetch) und ein Link-Modul für die Weiterleitungen der Anfragen benötigt (Apollo bieten auch hierfür eigene Implementierungen mit apollo-link).

Zunächst müssen HttpLinks angelegt werden, welche als Verknüpfung zu den Endpunkten der Services dienen. Diese benötigten die URI des Endpunktes und einen Fetcher.

const { HttpLink } = require('apollo-link-http')
const fetch = require('node-fetch')

function createHttpLink(urlToEndpoint) {
  return new HttpLink({ uri: urlToEndpoint, fetch })
}

const endpoints = [
  'http://localhost:3001/graphql', // user service
  'http://localhost:3002/graphql', // product service
]

Anschließend können mithilfe dieser Links zunächst die Schemas der Services angefragt (introspectSchema) und anschließend ein Remote-Schema erstellt werden (makeRemoteExecutableSchema), welche nun einen Aufruf direkt an den jeweiligen Service weiterleiten.

const {
  makeRemoteExecutableSchema,
  introspectSchema,
} = require('graphql-tools')

(async () => {

  const remoteSchemas = await Promise.all(endpoints.map(async (url) => {
    const link = createHttpLink(url)

    return makeRemoteExecutableSchema({
      link,
      schema: await introspectSchema(link),
    })
  }))

})()

Abschließend müssen nun lediglich die Remote-Schemas zu einem einzigen Schema vereint werden (mergeSchemas). Anschließend kann das Gateway gestartet und genutzt werden.

const { GraphQLServer } = require('graphql-yoga')

const { mergeSchemas } = require('graphql-tools')

(async () => {

  const remoteSchemas = ... // siehe oben

  const fullSchema = mergeSchemas({
    schemas: remoteSchemas,
  })

  const server = new GraphQLServer({
    schema: fullSchema,
  })

  const options = {
    port: 3000,
  }

  server.start(options, () => console.log('Server is running on http://localhost:3000'))

})()

Auf diese Weise werden nun die Schnittstellen der Services vollständig über das Gateway angeboten.

Gateway: Extensions

Im Gateway können die Typen der unterschiedlichen Services nun auch mit dem "extend"-Schlagwort erweitert werden. Auf diese Weise sind nun die benötigten Verbindungen zwischen Typen unterschiedlicher Services einzubinden. Hierzu muss nun zuerst eine weitere Typ-Definition im Gateway erstellt werden.

// extendTypes.graphql

extend type Consumer {
    purchases: [Product!]
}

extend type Producer {
    products: [Product!]
}

Hier wurden nun dem Consumer ein Array von Produkten die er bereits gekauft hat und dem Producer ein Array von Produkten die er anbietet hinzugefügt. Diese neuen Attribute benötigen jetzt jedoch wiederum Resolver, da das Gateway noch nicht weiß, wie es an die benötigten Daten gelangen kann. In diesen Resolvern wird nun auf den Produkt-Service zugegriffen um die jeweiligen Produkte zu erhalten. Im Falle des Consumer-Feldes "purchases" wird die getProductsBoughtBy(userId: ID!)-Query verwendet und im Falle des Producer-Feldes "products" die getProductsOfProducer(producerId: ID!)-Query. Beide Queries sind bereits in unserem Remote-Schema für den Produkt-Service enthalten, sodass wir diese Aufrufe einfach an dieses Schema delegieren können (über das infor-Objekt -> info.mergeInfo.delegateToSchema()). Hier muss das Remote-Schema, sowie die auszuführende Query angegeben werden.

// app.js
const server = new GraphQLServer({
    schema: fullSchema,
    context: () => ({
      remoteSchemas: {
        userSchema: remoteSchemas[0],
        productSchema: remoteSchemas[1],
      },
    }),
  })

// Resolver
module.exports = {
  Producer: {
    products: {
      fragment: '... on Producer { id }',
      resolve(producer, _args, context, info) {
        const { remoteSchemas } = context

        return info.mergeInfo.delegateToSchema({
          schema: remoteSchemas.productSchema,
          operation: 'query',
          fieldName: 'getProducts',
          args: {
            producerId: producer.id,
          },
          context,
          info,
        })
      },
    },
  },
  Consumer: {
    purchases: {
      fragment: '... on Consumer { id }',
      resolve(consumer, _args, context, info) {
        const { remoteSchemas } = context

        return info.mergeInfo.delegateToSchema({
          schema: remoteSchemas.productSchema,
          operation: 'query',
          fieldName: 'getProductsBoughtBy',
          args: {
            userId: consumer.id,
          },
          context,
          info,
        })
      },
    },
  },
}

Damit die Resolver Zugriff auf die Remote-Schemas haben, wurden diese beim Serverstart an das context-Objekt gebunden. Weiterhin benötigen diese Queries immer spezielle Attribute von dem jeweiligen User, um die passenden Produkte herauszufinden. Damit diese zuvor stets vom User-Service angefragt werden, selbst wenn ein Client diese nicht selbst angefragt hat, kann ein Fragment definiert werden. In diesem Fragment wird die Attribute angegeben, welche für die Ausführung dieses Resolvers benötigt werden (hier jeweils das id-Feld vom Consumer bzw. Producer).

Nun müssen bei der Verschmelzung der Schemas zusätzlich die erweiterten Typen und Resolver angegeben werden.

// app.js
(async () => {
  ...

  const remoteSchemas = // create Remote Schemas
  const extendedSchema = // get new (extend) Schema
  const extendedResolver = // get new Resolvers

  const fullSchema = mergeSchemas({
    schemas: [
      ...remoteSchemas,
      extendedSchema,
    ],
    resolvers: extendedResolver,
  })

  ...
})()

Nun wurden die bestehenden Typen in der Gateway-Schnittstelle mit den neuen Attributen erweitert. (Das Gateway ist nichts Anderes als ein normaler GraphQL-Server, also könnten hier auch neue Typen erstellt werden, falls nötig)

Gateway: Subscriptions

Die bestehende Lösung funktioniert für jegliche Art von Queries und Mutations, also für alle einfachen HTTP-Requests, jedoch nicht für Subscriptions, welche auf WebSockets basieren. Damit diese funktionieren, muss neben dem HttpLink zudem ein WebSocketLink verwendet werden. Dieser benötigt einen WebSocket-Client, welcher hier mithilfe von subscriptions-transport-ws von Apollo erstellt wird.

const WebSocket = require('ws')
const { SubscriptionClient } = require('subscriptions-transport-ws')
const { WebSocketLink } = require('apollo-link-ws')

function createWsLink(urlToWsEndpoint) {
  const wsClient = new SubscriptionClient(
    urlToWsEndpoint,
    {
      reconnect: true,
    },
    WebSocket,
  )

  return new WebSocketLink(wsClient)
}

Da ein Remote-Schema lediglich einen Link entgegen nimmt, wird folglich ein dritter (Retry-) Link erstellt, welcher den HttpLink und den WebSocketLink nutzt und je nach Anfrage entscheidet, welcher zu nutzen ist (ist die Operation eines Query-Objektes 'subscription' der WebSocketLink, ansonsten der HttpLink).

const { RetryLink } = require('apollo-link-retry')
const { getMainDefinition } = require('apollo-utilities')

function createLink(url) {
  const httpLink = createHttpLink(url)
  const wsLink = createWsLink(url)

  const link = new RetryLink()
    .split(
      ({ query }) => {
        const { kind, operation } = getMainDefinition(query)
        return kind === 'OperationDefinition' && operation === 'subscription'
      },
      wsLink,
      httpLink,
    )

  return link
}

Dieser Link kann nun an das jeweilige Remote-Schema übergeben und anschließend auch dessen Subscriptions genutzt werden.

Gateway: Transforms

Extensions ermöglichen die Erweiterung von Typen eines Schemas, mit Transformationen kann ein Schema jedoch auch vollständig verändert werden. Hierfür werden die Schemata direkt angepasst und nicht erst durch die Verschmelzung mit einem weiteren Schema ergänzt. Vom graphql-tools-Modul werden bereits einige vorgefertigte Transformationen angeboten, welche z.B. das Umbenennen und Entfernen von Typen beinhalten (siehe hier). Es können jedoch auch eigene Transformationen definiert werden (ebenfalls hier einzusehen). Im folgenden Beispiel werden mithilfe der RenameType-Transormation die Typen der Services mit einem Prefix versehen um Namenskonflikte zu vermeiden (dies ist kein Best-Practice und soll lediglich die Anwendung solcher Transformationen präsentieren):

const {
  mergeSchemas,
  transformSchema,
  RenameTypes,
} = require('graphql-tools')

...

const userSchema = // get user schema
const productSchema = // get product schema

const prefixedUserSchema = transformSchema(userSchema, [
  new RenameTypes(typeName => `UserService_${typeName}`),
])

const prefixedProductSchema = transformSchema(productSchema, [
  new RenameTypes(typeName => `ProductService_${typeName}`),
])

const fullSchema = mergeSchemas({
  schemas: [
    prefixedUserSchema,
    prefixedProductSchema,
  ]
})

...

Apollo Federation

Apollo Federation ermöglicht es Services die Typen anderer Services zu referenzieren und mit Feldern anzureichern. Durch diese Art der Vernetzung kann jeder Service vollständig entkoppelt aufgebaut werden und es entfällt der Aufwand für die manuelle Zusammenführung im Gateway. (siehe Apollo-Docs: Federation)

Die vollständigen Code-Beispiele können in diesem GitHub-Repo eingesehen und zum ausprobieren heruntergeladen werden.

Service: Keys

Mit Federation wurde das Key-Directive hinzugefügt, welches eine Entität / einen Typen für andere Services erreichbar macht.

interface User @key(fields: "id") {
    id: ID!
    member_since: DateTime!
    username: String!
    email: String!
    address: Address!
    type: UserType!
}

In diesem Beispiel wurde das Interface User des User-Services auch für andere Services sichtbar gemacht. Um diesen zu erreichen muss der andere Service lediglich dessen id-Attribut kennen. Solche Keys müssen immer Einzigartig sein. Es können pro Entität auch mehrere Keys angegeben werden. Im folgenden Beispiel ist ein User auch über dessen einzigartiges username-Feld suchbar:

interface User @key(fields: "id") @key(fields: "username"){
    id: ID!
    member_since: DateTime!
    username: String!
    email: String!
    address: Address!
    type: UserType!
}

Keys können auch komponiert werden und auf verschachtelte Attribute zugreifen. In dem folgenden Beispiel (entnommen aus den Apollo-Docs), ist ein User nur innerhalb seiner Organisation einzigartig, sodass dessen id, zusammen mit der id der Organisation den Key ergeben:

type User @key(fields: "id organization { id }") {
  id: ID!
  organization: Organization!
}

type Organization {
  id: ID!
}

Zu jeder @key-Directive muss ein entsprechender Resolver geschaffen werden, welcher die Entität anhand des Keys ermittelt und zurückgibt. Für das User-Interface ist dieser wie folgt aufgebaut:

User: {
  __resolveReference(reference) {
    return userDB.getUserById(reference.id)
  },
},

Der Resolver erhält ein Referenz-Objekt, in welchem sich das als Key ausgewählte Feld befindet. Mit diesem Key kann er nun auf die User-Datenbank zugreifen und die gewünschte Entität zurückgeben.

Service: Typen referenzieren

Um externe Typen anderer Services referenzieren zu können, muss ein Service zunächst eine eigene Repräsentation dieses Typen erstellen, auch Stub-Type genannt. Hierbei muss jedoch nur das als Key ausgewählte Attribut angegeben werden. Wichtig ist zudem, dass die Signatur des Typen mit dem extends-Symbol und einem Key-Directive versehen wird. In diesem Key-Directive muss der Key des Typen angegeben werden, welcher durch den Service angeboten wird (nicht alle möglichen Keys).

// Product-Service
type Product @key(fields: "id") @key(fields: "name"){
    id: ID!
    name: String!
    unit: Unit!
    price_per_unit: Float!
    description: String
}

// User-Service
extend type Product @key(fields: "id") {
  id: ID! @external
}

type Consumer implements User @key(fields: "id") {
    id: ID!
    purchases: [Product!]
}

In diesem Code-Beispiel gibt ein Produkt-Service seinen Produkt-Typen nach außen frei. Der User-Service referenziert diesen Typen, nutzt hierbei jedoch lediglich das Key-Feld id. Dieses Feld ist zusätzlich mit dem @external-Directive versehen worden, was aussagt, dass das Feld bzw. der Typ des Feldes extern definiert wurde (in diesem Falle ist der Typ ID! und wurde im Produkt-Service definiert). Nur Felder, welche als Key definiert wurden, oder in einem @requires- und @provides-Directive genannt wurden (siehe Weitere Directives), dürfen als externes Feld übernommen werden. Anschließend kann der User-Service den Product-Typen nutzen, als hätte er diesen selber erstellt.

Damit diese Referenz jetzt jedoch aufgelöst werden kann, muss zum einen der Produkt-Service angeben, wie eine Entität anhand ihres Keys gefunden werden kann (siehe __resolveReference-Resolver in dem vorherigen Unterkapitel). Weiterhin muss jedoch auch der User-Service angeben, wie er bei einer Anfrage an das Key-Feld bzw. dessen Wert gelangt, welches benötigt wird um die Entität anzufragen. Dies geschieht mithilfe eines einfachen Feld-Resolvers, welcher eine abstrakte Repräsentation der Entität zurück gibt. Diese Repräsentation muss stets den Namen des Feld-Typen und den Key beinhalten: { __typename: "TypName", key: Value }. Für das obere Beispiel würde er etwas anders aussehen. Hier wird ein Array von Produkten referenziert also muss auch ein Array dieser abstrakten Form zurückgegeben werden:

Consumer: {
  purchases: consumer => consumer.purchasedProductIds.map(id => ({ __typename: 'Product', id }))
}

(In der Datenbank hält der Service zu jedem Konsumenten die von ihm gekauften Produkte als Id-Array unter dem purchasedProductIds-Attribut)

Service: Typen erweitern

Mithilfe der Stub-Types können externe Typen auch erweitert werden. Hierzu muss ein Service diesen Stub-Typen um weitere Felder ergänzen, ohne diese mit dem @external-Directive zu versehen. So ist bekannt, dass diese Felder nicht zu dem Originaltypen gehören und lediglich von dem Service, in welchem sie definiert wurden, angeboten werden. Im folgenden Beispiel ergänzt der Produkt-Service den Produzenten, welcher vom User-Service definiert wurde, um das Feld products, welches die angebotenen Produkte dieses Produzenten darstellen soll.

// User-Service types
type Producer implements User @key(fields: "id") {
    id: ID!
    member_since: DateTime!
    username: String!
    email: String!
    address: Address!
    type: UserType!
}

// Product-Service types
extend type Producer @key(fields: "id") {
  id: ID! @external
  products: [Product!]
}

Nun muss der Produkt-Service zusätzlich einen Resolver implementieren, welcher für den Produzenten die Produkte ermittelt. Dieser erhält den referenzierten Produzenten, jedoch lediglich dessen Felder, welche als Key angegeben wurden.

Producer: {
  products: (producer) => {
    const { id } = producer
    return productDB.getProductsMatchingQuery({ producerId: id })
  }
}

Service: Queries, Mutations, Scalars, Enums

Da Queries und Mutations Root-Types sind, welche in jedem Service geschrieben werden müssen, sind diese in den Services stets mit dem extends-Symbol zu kennzeichnen. Im Gateway werden diese Typen einmal definiert und mit den Extensions angereichert.

Scalar-Typen und Enums können zwischen Services geteilt und trotzdem normal ohne extends deklariert werden. Wichtig bei Enums ist jedoch, dass die Deklarationen stets identisch sind (auch wenn ein Service eine gewisse Ausprägung nicht benötigt muss er diese trotzdem angeben). Scalar-Typen sollten ebenfalls in jedem Service identisch umgesetzt sein, um einen gleichmäßigen Umgang mit diesen zu ermöglichen.

Weitere Directives

Neben den @key- und @external-Directives wurden mit Federation noch zwei weitere Directives integriert: @requires und @provides.

Das @requires-Directive kann genutzt werden, wenn ein Typ erweitert werden soll, hierfür jedoch weitere Felder abseits der Keys benötigt werden. Im folgenden Beispiel werden die Namen der Produkte zusätzlich mit dem Namen des Produzenten angereichert. Hierfür wird jedoch neben der Id des Produzenten auch sein Name benötigt, welcher nun im @requires-Directive angegeben wird. Zusätzlich muss nun der Stub-Type um das username-Feld mit dem @external-Directive ergänzt werden.

// Product-Service types
extend type Producer @key(fields: "id") {
  id: ID! @external
  username: String! @external
  products: [Product!] @requires(fields: "username")
}

// Product-Service resolvers
Producer: {
  products: (producer) => {
    const { id, username } = producer
    const products = productDB.getProductsMatchingQuery({ producerId: id })
    return products.map(product => ({ ...product, name: `${username}_${product.name}` }))
  },
},

Das @provides-Directive dient der Reduzierung von unnötiger Kommunikation zwischen Services. Kann ein Service bereits Felder eines referenzierten Typen ausliefern, so kann er diese mit dem @provides-Feld kennzeichnen. Bei einer Anfrage werden nun unnötige Anfragen für diese Felder vermieden und stattdessen die Daten des Services genutzt. Hätte das Produkt beispielsweise eine Referenz des Produzenten und der Produkt-Service zu jedem Produkt den Produzenten-Namen, sowie das Key-Feld id, so könnte dies mithilfe des @provides-Directives wie folgt aussehen:

// Product-Service types
type Product @key(fields: "id"){
    id: ID!
    name: String!
    unit: Unit!
    price_per_unit: Float!
    description: String
    producer: Producer! @provides(fields: "username")
}

extend type Producer @key(fields: "id") {
  id: ID! @external
  username: String! @external
}

// Product-Service resolvers
Product: {
  producer: product => ({ id: product.producerId, username: product.producerName }),
},

Wichtig ist nun, dass in dem Resolver neben dem Key-Feld (hier id) auch der username des Produzenten zurückgegeben wird. Wird nun ein Produkt angefragt und von dessen Produzenten lediglich der username, so wird keine weitere Anfrage an den User-Service benötigt.

Service: Server starten

Beim starten eines Services, welcher mit Apollo-Federation erstellt wurde, muss das Schema mithilfe der buildFederatedSchema-Funktion des Moduls @apollo/federation erstellt werden, da diese die zuvor genannten Directives integriert.

const { ApolloServer } = require('apollo-server')
const { buildFederatedSchema } = require('@apollo/federation')

const typeDefs = // get types
const resolvers = // get resolvers
const schema = buildFederatedSchema([{ typeDefs, resolvers }])

const server = new ApolloServer({ schema })

server
  .listen({ port: config.app.port, endpoint: config.app.endpoint })
  .then(({ url }) => {
    console.log(`Server is running on ${url}`)
  })

Mithilfe dieser Funktion können auch mehrere Schema-Module zu einem Schema zusammengefügt werden, welches dem Federation-Prinzip folgt.

Gateway

Das Gateway übernimmt nun lediglich die Rolle diese Federation-Services zu einer Schnittstelle zusammen zu fassen. Weitere Vernetzungen müssen entgegengesetzt zum Schema-Stitching Ansatz im Gateway nicht mehr vollzogen werden. Hierfür bietet das Modul @apollo/gateway mit ApolloGateway die Möglichkeit, solch ein Gateway anhand von verschiedenen Service-Links aufzubauen.

const { ApolloGateway } = require('@apollo/gateway')

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'users', url: 'http://localhost:3001' },
    { name: 'products', url: 'http://localhost:3002' },
  ],
})

Zu jedem Service wird eine Url und ein Name angegeben, wobei Letzterer vorwiegend für debug-Zwecke vorhanden ist. Anpassungsmöglichkeiten beim Erstellen eines Gateways können hier eingesehen werden.

Nachdem das Gateway erstellt wurde kann es einem mithilfe der load-Methode die Typdefinitionen und Executors (Resolvers) erstellen, welche anschließend zum starten eines GraphQL-Servers genutzt werden können.

// Vollständiger Gateway-Code

const { ApolloServer } = require('apollo-server')
const { ApolloGateway } = require('@apollo/gateway')

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'users', url: 'http://localhost:3001' },
    { name: 'products', url: 'http://localhost:3002' },
  ],
});

(async () => {
  const { schema, executor } = await gateway.load()

  const server = new ApolloServer({
    schema,
    executor
  })

  server.listen().then(({ url }) => {
    console.log(`🚀 Server ready at ${url}`)
  })
})()

Nun ist das Gateway vollständig einsatzbereit. Werden neben diesen Queries und Mutations auch Subscriptions benötigt, welche Federation noch nicht unterstützt, so kann der Workaround aus diesem Apollo Issue verfolgt werden.

Web-Technologien SoSe2018

Perspektiv-Themen

Clone this wiki locally