Zum Inhalt springen
DasPeTe Tutorials

Eine schnelle GraphQL-API mit Fastify und Yoga · Teil 4 von 8 der Serie

Teil 4: Bündeln mit DataLoader

Sammle die Abfragen eines Requests mit DataLoader und beantworte sie mit einer Abfrage pro Tabelle, sodass das Questbrett drei SQL-Abfragen braucht statt sechzehn.

Quest-Ziele

  • Verstehen, wie DataLoader bündelt und cacht
  • Batch-Funktionen mit Drizzle und inArray schreiben
  • Für jeden Request frische Loader anlegen
  • Eins-zu-viele-Beziehungen gebündelt laden
  • Einen Loader mit Zeilen füllen, die du schon hast

Die Resolver aus dem letzten Teil holen jeder ihre eigene Zeile. Das ist leicht zu schreiben und furchtbar langsam. DataLoader behält das Leichte und behebt das Langsame.

Wie DataLoader arbeitet

Ein Loader hat eine Methode, auf die es ankommt: load(key). Sie gibt ein Promise zurück, holt aber noch nichts. Sie merkt sich den Schlüssel und wartet, bis der Code, der gerade läuft, fertig ist. GraphQL ruft in dieser Zeit die Resolver aller zehn Quests nacheinander auf, der Loader hat dann also jede Gilden-ID, nach der sie gefragt haben. Er ruft deine Batch-Funktion einmal mit allen auf, und diese Funktion antwortet mit einer Abfrage.

Zwei Regeln lassen das funktionieren. Die Batch-Funktion gibt genau einen Wert pro Schlüssel zurück, in der Reihenfolge der Schlüssel. Und der Loader merkt sich jeden Schlüssel, den er gesehen hat, dieselbe Gilde wird also einmal geholt, egal wie viele Quests zu ihr gehören.

Die Loader

~/projects/guild-api
$ pnpm add dataloaderdependencies:+ dataloader 2.2.3Done in 98ms using pnpm v12.16.1

Leg src/loaders.ts an:

src/loaders.ts
import DataLoader from 'dataloader';
import { inArray } from 'drizzle-orm';
import type { Database } from './db/index.ts';
import { adventurers, guilds, quests } from './db/schema.ts';

/** One row per id, in the order of the ids, or an error where a row is missing. */
function one<T extends { id: number }>(ids: readonly number[], rows: T[], type: string) {
  const byId = new Map(rows.map((row) => [row.id, row]));
  return ids.map((id) => byId.get(id) ?? new Error(`${type} ${id} not found`));
}

/** All rows per key, in the order of the keys, and an empty list where there are none. */
function many<T>(keys: readonly number[], rows: T[], keyOf: (row: T) => number | null) {
  const groups = Map.groupBy(rows, keyOf);
  return keys.map((key) => groups.get(key) ?? []);
}

export function createLoaders(db: Database) {
  const guild = new DataLoader(async (ids: readonly number[]) => {
    const rows = await db.select().from(guilds).where(inArray(guilds.id, [...ids]));
    return one(ids, rows, 'Guild');
  });

  const adventurer = new DataLoader(async (ids: readonly number[]) => {
    const rows = await db.select().from(adventurers).where(inArray(adventurers.id, [...ids]));
    return one(ids, rows, 'Adventurer');
  });

  const adventurersByGuild = new DataLoader(async (guildIds: readonly number[]) => {
    const rows = await db
      .select()
      .from(adventurers)
      .where(inArray(adventurers.guildId, [...guildIds]))
      .orderBy(adventurers.id);
    return many(guildIds, rows, (row) => row.guildId);
  });

  const questsByGuild = new DataLoader(async (guildIds: readonly number[]) => {
    const rows = await db
      .select()
      .from(quests)
      .where(inArray(quests.guildId, [...guildIds]))
      .orderBy(quests.id);
    return many(guildIds, rows, (row) => row.guildId);
  });

  const questsByAssignee = new DataLoader(async (adventurerIds: readonly number[]) => {
    const rows = await db
      .select()
      .from(quests)
      .where(inArray(quests.assigneeId, [...adventurerIds]))
      .orderBy(quests.id);
    return many(adventurerIds, rows, (row) => row.assigneeId);
  });

  return { guild, adventurer, adventurersByGuild, questsByGuild, questsByAssignee };
}

export type Loaders = ReturnType<typeof createLoaders>;

Die Abfrage in jeder Batch-Funktion fragt mit inArray nach allen Schlüsseln auf einmal, sie wird also zu where id in (…). Die Datenbank antwortet in beliebiger Reihenfolge und lässt IDs weg, die sie nicht hat, deshalb bringen die beiden Helfer die Zeilen zurück in die Reihenfolge der Schlüssel:

  • one ist für das Laden über die ID. Eine fehlende Zeile wird an ihrer Stelle zu einem Error, und nur das load für diese ID schlägt fehl.

  • many ist für Listen, etwa die Abenteurer einer Gilde. Es gruppiert die Zeilen mit Map.groupBy und gibt einer Gilde ohne Abenteurer eine leere Liste.

Ein Satz Loader pro Request

Der Cache eines Loaders vergisst nie. Ein Loader, der so lange lebt wie der Server, würde die Gilde, die er am Montag geladen hat, die ganze Woche lang ausgeben, und er würde Daten zwischen Nutzern teilen, die die Daten der anderen nicht sehen dürfen. Deshalb bekommt jeder Request seine eigenen Loader, das kostet wenig, und ihr Cache lebt genau so lange wie der Request. Leg sie in den Kontext:

src/context.ts
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { YogaInitialContext } from 'graphql-yoga';
import { db, type Database } from './db/index.ts';
import { createLoaders, type Loaders } from './loaders.ts';

export interface ServerContext {
  req: FastifyRequest;
  reply: FastifyReply;
}

export interface Context extends YogaInitialContext, ServerContext {
  db: Database;
  loaders: Loaders;
}

export function createContext(): Pick<Context, 'db' | 'loaders'> {
  return { db, loaders: createLoaders(db) };
}

Dann lass die Feld-Resolver über sie laden. Ersetz in src/resolvers.ts alles von Guild bis zum Ende:

src/resolvers.ts
Guild: {
    adventurers: (guild, _args, { loaders }) => loaders.adventurersByGuild.load(guild.id),

    quests: async (guild, { status }, { loaders }) => {
      const guildQuests = await loaders.questsByGuild.load(guild.id);
      return status ? guildQuests.filter((quest) => quest.status === status) : guildQuests;
    },
  },

  Adventurer: {
    guild: (adventurer, _args, { loaders }) => loaders.guild.load(adventurer.guildId),

    quests: (adventurer, _args, { loaders }) => loaders.questsByAssignee.load(adventurer.id),
  },

  Quest: {
    guild: (quest, _args, { loaders }) => loaders.guild.load(quest.guildId),

    assignee: (quest, _args, { loaders }) =>
      quest.assigneeId === null ? null : loaders.adventurer.load(quest.assigneeId),
  },
};

Lösch findGuild und findAdventurer, sie werden nicht mehr gebraucht, und mit ihnen die Imports von and, Database und adventurers. Guild.quests hat noch sein Argument status. Der Loader hat nur die Gilde als Schlüssel, also lädt der Resolver alle Quests der Gilde und filtert sie. Ein Loader kann auch Gilde und Status zusammen als Schlüssel nehmen, mit einer cacheKeyFn, die beides zu einem Schlüssel macht, aber für die wenigen Quests einer Gilde ist das hier einfacher.

Noch einmal das Questbrett

Schick dieselbe Questbrett-Query wie vorher. Die Antwort ist dieselbe, und das ist das Log:

Terminal
Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" order by "quests"."id" limit $1 -- params: [20]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" in ($1, $2, $3) -- params: [1, 2, 3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 5, 4, 7][15:20:50.428] INFO (141): Request processed    durationMs: 34

Drei Abfragen: die Liste, alle Gilden, alle Abenteurer. Die ID-Liste in jeder ist jeder Schlüssel, nach dem die Resolver gefragt haben, ohne Wiederholungen. Mit 50 Quests auf der Seite wären es immer noch drei.

Und die große Gilden-Query aus dem letzten Teil:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { name adventurers { name quests { title } } quests { title assignee { name } } } }"}'
Terminal
Query: select "id", "name", "motto" from "guilds" order by "guilds"."id"Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."guild_id" in ($1, $2, $3) order by "adventurers"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."guild_id" in ($1, $2, $3) order by "quests"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."assignee_id" in ($1, $2, $3, $4, $5, $6, $7, $8, $9) order by "quests"."id" -- params: [1, 2, 3, 4, 5, 6, 7, 8, 9]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 5, 4, 7]

Aus 21 Abfragen wurden 5, eine für jede Ebene der Query und Tabelle. Die Zahl hängt nicht mehr davon ab, wie viele Zeilen es gibt, nur noch davon, wie tief die Query geht.

Vorbefüllen

Schau dir die letzten beiden Abfragen an. Die zweite lädt alle neun Abenteurer der Gilden, und die fünfte lädt fünf davon noch einmal, über die ID, für die Abenteurer der Quests. Die Loader wissen nichts voneinander. Die Zeilen sind aber schon da, also gib sie weiter: prime legt einen Wert in den Cache eines Loaders, ohne etwas zu laden. Änder in createLoaders den Loader adventurersByGuild:

src/loaders.ts
const adventurersByGuild = new DataLoader(async (guildIds: readonly number[]) => {
    const rows = await db
      .select()
      .from(adventurers)
      .where(inArray(adventurers.guildId, [...guildIds]))
      .orderBy(adventurers.id);
    // These adventurers need no query of their own any more in this request.
    for (const row of rows) adventurer.prime(row.id, row);
    return many(guildIds, rows, (row) => row.guildId);
  });

Schick die Gilden-Query noch einmal:

Terminal
Query: select "id", "name", "motto" from "guilds" order by "guilds"."id"Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."guild_id" in ($1, $2, $3) order by "adventurers"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."guild_id" in ($1, $2, $3) order by "quests"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."assignee_id" in ($1, $2, $3, $4, $5, $6, $7, $8, $9) order by "quests"."id" -- params: [1, 2, 3, 4, 5, 6, 7, 8, 9]

Vier Abfragen. Die Abenteurer der Quests kamen aus dem Cache, den die zweite Abfrage gefüllt hat.

Lesen ist jetzt schnell. Im nächsten Teil beginnt sich das Brett zu verändern: Mutations schreiben Quests aus, nehmen sie an und erledigen sie, und Subscriptions erzählen jedem offenen Brett davon.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.