Den Resolvern einen Schritt voraus
Autor
Tobias Reich
Software Architekt
bei SYZYGY Techsolutions
Lesedauer
9 Minuten
Publiziert
06. Mai 2024
GraphQL findet inzwischen in vielen Projekten Verwendung und hat sich als echte Alternative zur klassischen REST-APIs bewiesen. In diesem Beitrag zeige ich euch, wie ihr eure Resolver auf das nächste Level bringt und Daten mithilfe von Lookaheads bereits ladet, bevor die eigentlichen Resolver greifen.
GraphQL
Einer der Vorteile von GraphQL ist, dass der Client bestimmen kann, welche Felder er vom Backend erhält. Um dies zu veranschaulichen, stellen wir uns eine Webseite zur Verwaltung von Fotos vor, in der Bilder in Alben eingeordnet sind. Wenn beispielsweise nur die Id und der Name zur Darstellung eines Albums benötigt werden, kann das Frontend genau diese abfragen. Ungenutzte Felder, die ein Album ebenfalls bietet, werden ignoriert und nicht ausgegeben. Dies ist ein großer Unterschied zu klassischen REST-APIs, wo oft mehr als nötig übertragen wird, einschließlich Felder, mit denen der Client in diesem Moment nichts anfangen kann.
Je nach Implementierung reduziert GraphQL nicht nur die Anzahl an Bytes, die übertragen werden müssen, sondern auch die Last auf der Datenbank und spart somit unnötigen Overhead. Weniger Overhead, weniger Daten, schnellere Ladezeiten. All das kommt dem Produkt und den Anwenderinnen und Anwendern zugute.
Resolver wie man sie kennt
Während Types definieren, was abgefragt werden kann, sind für die Beschaffung von Daten in GraphQL die sogenannten Resolver zuständig. Bleiben wir bei dem Beispiel mit einem Album, welches neben Id und Name auch Bilder hat, dann sieht der Code üblicherweise folgendermaßen aus.
type Album {
id: ID!
name: String!
photos: [Photo!]!
}
type Photo {
id: ID!
name: String!
album: Album!
}
type Query {
album(id: ID!): Album
}
const resolvers = {
Album: {
photos: (parent, args, contextValue) => {
return database.getPhotos(parent.id);
},
},
Photo: {
album: (parent, args, contextValue) => {
return database.getAlbum(parent.albumId);
},
},
Query: {
album: (parent, args, contextValue) => {
return database.getAlbum(args.id);
},
},
};
const albums = [
{
id: "4ed92f77",
name: "Example",
},
];
const photos = [
{
id: "fe7cd444",
name: "Example",
albumId: "4ed92f77",
},
];
const database = {
getAlbum: (albumId) => albums.find((album) => album.id === albumId),
getPhotos: (albumId) => photos.filter((photo) => photos.albumId === albumId),
};
Möchte der Client ein spezifisches Album abfragen, so kann er dies mit einer Query machen:
query getAlbum {
album(id: "4ed92f77") {
id
name
photos {
id
name
}
}
}
Wirft man einen Blick auf unsere Resolver dann läuft bei einer Abfrage folgendes ab:
- Der Resolver für album wird ausgeführt und ruft die Datenbank auf
- Die Datenbank gibt ein Album mit Id und Name zurück
- Der Resolver gibt das Album von der Datenbank zurück
- Da album keine Bilder geliefert hat, wird Album.photos ausgeführt
- Der Resolver für photos wird ausgeführt und ruft die Datenbank auf
- Die Datenbank gibt die Bilder des Albums zurück
- Der Resolver gibt die Bilder des Albums von der Datenbank zurück
Das Problem
Interessant ist hierbei eine Sache, die nicht direkt ins Auge sticht:
Der Resolver Album.photos wird erst nach Query.album ausgeführt, obwohl Album.photos keine Daten von Query.album benötigt. Beide setzen lediglich die Id des Albums voraus, welche bereits am Anfang zur Verfügung steht. Die Resolver laufen somit seriell statt parallel. Eine unnötige Verzögerung, die einen großen Impact auf die Ladezeit haben kann: Geht man davon aus, dass der erste Resolver 200ms benötigt und der zweite Resolver 160ms, dann bekommt der Client seine Antwort niemals unter 360ms. Würden beide Resolver dagegen parallel laden, wäre eine Antwort schon in 200ms denkbar. 160ms weniger. Ein Problem was sich weiter verstärken kann umso komplexer der Graph wird.
Geht das auch besser?
Die Reihenfolge, in welcher die Resolver ausgeführt werden, lässt sich nicht beeinflussen. Die Implementierung der Resolver dagegen schon:
Arbeitet Frontend und Backend Hand in Hand und es ist sicher, dass mit dem Album immer auch die Bilder abgefragt werden, dann macht es Sinn diese direkt bei database.getAlbum zu laden und sich einen erneuten Weg zur Datenbank via database.getPhotos zu sparen. Alternativ kann Query.album auch database.getAlbum und database.getPhotos aufrufen und die Daten kombinieren. Der Resolver Album.photos wird obsolet.
const resolvers = {
Photo: {
album: (parent, args, contextValue) => {
return database.getAlbum(parent.albumId);
},
},
Query: {
album: async (parent, args, contextValue) => {
// Wichtig: Parallel laden statt hintereinander
const [album, photos] = await Promise.all([
database.getAlbum(args.id),
database.getPhotos(args.id),
]);
return {
...album,
photos,
};
},
},
};
Braucht das Frontend allerdings keine Bilder, so hat man unnötig viel von der Datenbank abgefragt. Vor allem bei öffentlichen APIs, wo das Backend-Team den Use Case nur eingeschränkt kennt, ist der letzte Weg daher keine Option. Es bedarf einer Lösung, die prüft, welche Felder der Client haben möchte. Diese kann dann genutzt werden, um intelligent vorzuladen.
GraphQL Lookaheads
Die Information, welche Felder angefragt wurden, stellt GraphQL zur Verfügung, hält sie aber gut versteckt. Sie befindet sich im vierten Parameter der Resolver: dem info Objekt. Genaugenommen ist das, was wir suchen, nicht direkt im info Objekt zu finden, die Information lässt sich aber mit zwei Helper-Functions daraus gewinnen. Eine Funktion, um alle ausgewählten Field Nodes (diese repräsentieren abgefragte Felder) zu bekommen. Eine weitere, um die Namen der Field Nodes zu bekommen.
const selectedFieldNodes = (info) => {
const selectedNodesOfNode = (node) => {
return node.selectionSet?.selections ?? [];
};
const selectedFieldsOfNode = (fragments) => (node) => {
switch (node.kind) {
case "Field":
return [node];
case "InlineFragment":
return selectedNodesOfNode(node).flatMap(
selectedFieldsOfNode(fragments)
);
case "FragmentSpread":
return selectedNodesOfNode(fragments[node.name.value]).flatMap(
selectedFieldsOfNode(fragments)
);
}
};
const currentNode = info.fieldNodes[0];
// Alle Nodes in der aktuellen
const selectedNodes = selectedNodesOfNode(currentNode);
// Manche Nodes verstecken sich in Fragments
return selectedNodes.flatMap(selectedFieldsOfNode(info.fragments));
};
const fieldNodesNames = (fieldNodes) => {
return fieldNodes.map((field) => field.name.value);
};
Beide Funktionen können jetzt in unseren Resolvern verwendet werden. Mithilfe von includes prüfen wir ob Bilder angefragt wurden. Ist dies der Fall, so laden wir diese parallel. So wie auch in unserem vorletzten Beispiel.
const resolvers = {
Photo: {
album: (parent, args, contextValue) => {
return database.getAlbum(parent.albumId);
},
},
Query: {
album: async (parent, args, contextValue, info) => {
const fieldNodes = selectedFieldNodes(info);
const fieldNames = fieldNodesNames(fieldNodes);
const hasPhotosField = fieldNames.includes("photos");
const [album, photos] = await Promise.all([
// Benötigte Felder
database.getAlbum(args.id),
// Optionale Felder
hasPhotosField === true ? database.getPhotos(args.id) : undefined,
]);
return {
...album,
photos,
};
},
},
};
Fazit
Ja es ist möglich: Mit nur wenig extra Code kann der Graph noch schneller Antworten liefern, ohne dabei zu viel zu laden, wenn doch weniger abgefragt wird. Die Resolver bleiben verständlich und lassen sich schnell um weitere Felder ergänzen. GraphQL Field Lookaheads sind ein sinnvoller Weg, um Bottlenecks in Resolvern zu umgehen und Ladezeiten zu reduzieren. Eine smarte Lösung, welche wir regelmäßig bei unseren Kunden im Einsatz haben.
Head of Technology