Zum Inhalt springen
DasPeTe Tutorials

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

Teil 3: Erst das Schema, typisiert von Codegen

Schreib das GraphQL-Schema des Gildenbretts, lass GraphQL Codegen daraus die Typen der Resolver erzeugen, beantworte Queries aus PostgreSQL und finde das N+1-Problem im SQL-Log.

Quest-Ziele

  • Eine API in der GraphQL-Schemasprache beschreiben
  • Typen für die Resolver mit GraphQL Codegen und Mappern erzeugen
  • Typisierte Resolver schreiben, die mit Drizzle lesen
  • Das N+1-Problem im Query-Log erkennen

Eine GraphQL-API beginnt mit ihrem Schema. Du kannst es im Code bauen, oder du schreibst es in der Schema Definition Language, kurz SDL, und ergänzt den Code, der es beantwortet. Diese Serie schreibt SDL: Es ist der Vertrag zwischen der API und ihren Clients, kurz genug, dass jeder im Team es lesen kann, und GraphQL Codegen macht daraus TypeScript-Typen, damit die Resolver sich nicht davon entfernen können.

Das Schema

Leg src/schema.graphql an:

src/schema.graphql
type Query {
  guilds: [Guild!]!
  guild(id: ID!): Guild
  quests(status: QuestStatus, first: Int! = 20): [Quest!]!
  quest(id: ID!): Quest
}

type Guild {
  id: ID!
  name: String!
  motto: String
  adventurers: [Adventurer!]!
  quests(status: QuestStatus): [Quest!]!
}

type Adventurer {
  id: ID!
  name: String!
  class: AdventurerClass!
  level: Int!
  guild: Guild!
  quests: [Quest!]!
}

type Quest {
  id: ID!
  title: String!
  reward: Int!
  status: QuestStatus!
  guild: Guild!
  assignee: Adventurer
}

enum AdventurerClass {
  WARRIOR
  MAGE
  ROGUE
  RANGER
}

enum QuestStatus {
  OPEN
  ACCEPTED
  DONE
}

Query enthält die Einstiegspunkte: Jede Query beginnt bei einem seiner Felder. Ein Ausrufezeichen heißt, dass ein Wert nie null ist, eckige Klammern bedeuten eine Liste. quests nimmt einen optionalen Status und eine Seitengröße, first, die 20 ist, wenn der Client nichts schickt. Die anderen Typen sind das Gildenbrett aus dem letzten Teil, mit den Beziehungen als Feldern: Eine Quest hat eine guild und vielleicht einen assignee, eine Gilde hat adventurers und quests.

GraphQL Codegen

Installier die CLI und zwei Plugins:

~/projects/guild-api
$ pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers[WARN] 3 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5, node-domexception@1.0.0Packages: +238Progress: resolved 238, reused 41, downloaded 238, added 238, donedevDependencies:+ @graphql-codegen/cli 7.4.6+ @graphql-codegen/typescript 6.1.2+ @graphql-codegen/typescript-resolvers 6.1.1Done in 616ms using pnpm v12.16.1

Konfigurier es in der codegen.ts:

codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'src/schema.graphql',
  generates: {
    'src/generated/graphql.ts': {
      plugins: ['typescript', 'typescript-resolvers'],
      config: {
        useTypeImports: true,
        enumsAsTypes: true,
        contextType: '../context.ts#Context',
        mappers: {
          Guild: '../db/schema.ts#GuildRow',
          Adventurer: '../db/schema.ts#AdventurerRow',
          Quest: '../db/schema.ts#QuestRow',
        },
        scalars: {
          ID: { input: 'string', output: 'string | number' },
        },
      },
    },
  },
};

export default config;
  • typescript schreibt einen Typ für jeden Typ im Schema, und typescript-resolvers schreibt Resolvers, den Typ des Objekts, das sie beantwortet.

  • useTypeImports und enumsAsTypes beschränken die erzeugte Datei auf Syntax, die Node entfernen kann: import type und Enums als Unions von Strings, die genau den Werten der PostgreSQL-Enums entsprechen.

  • contextType ist der Kontext, den jeder Resolver bekommt. Den schreibst du gleich.

  • mappers sind der wichtige Teil. Standardmäßig nimmt Codegen an, dass ein Resolver den GraphQL-Typ selbst zurückgibt, eine Quest bräuchte also ein guild-Objekt. Deine Resolver geben aber Datenbankzeilen zurück, und eine Zeile hat guildId. Genau das sagen die Mapper: Wo ein Feld eine Quest liefert, gibst du eine QuestRow zurück, und die bekommen auch die Felder von Quest als Parent.

  • scalars erlaubt Zahlen für ID in den Antworten. Die IDs in der Datenbank sind Zahlen, und GraphQL verschickt sie als Strings.

Ergänz das Skript und führ es aus:

package.json
"codegen": "graphql-codegen",
~/projects/guild-api
$ pnpm codegen✔ Parse Configuration✔ Load GraphQL schemas✔ Load GraphQL documents✔ Generate✔ Generate to src/generated/graphql.ts✔ Generate outputs

Das ist ein Teil dessen, was es in src/generated/graphql.ts geschrieben hat: Die Parents sind deine Zeilen, und jedes Feld einer Quest hat einen typisierten Resolver.

src/generated/graphql.ts
export type ResolversParentTypes = {
  Adventurer: AdventurerRow;
  Boolean: Scalars['Boolean']['output'];
  Guild: GuildRow;
  ID: Scalars['ID']['output'];
  Int: Scalars['Int']['output'];
  Query: Record<PropertyKey, never>;
  Quest: QuestRow;
  String: Scalars['String']['output'];
};

export type QuestResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Quest'] = ResolversParentTypes['Quest']> = {
  assignee?: Resolver<Maybe<ResolversTypes['Adventurer']>, ParentType, ContextType>;
  guild?: Resolver<ResolversTypes['Guild'], ParentType, ContextType>;
  id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
  reward?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
  status?: Resolver<ResolversTypes['QuestStatus'], ParentType, ContextType>;
  title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
};

Führ pnpm codegen jedes Mal aus, wenn du das Schema änderst. Committe die erzeugte Datei, oder lass Codegen in der CI vor der Typprüfung laufen, damit die Typen immer zum Schema passen.

Der Kontext

Jeder Resolver bekommt einen Kontext: was Yoga hineinlegt, den Request und die Reply von Fastify und alles, was du ergänzt. Leg src/context.ts an:

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

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

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

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

createContext läuft bei jedem Request und gibt nur deinen Teil zurück. Yoga fügt ihn mit dem Rest zusammen. Im Moment ist das die Datenbank.

Die Resolver

Leg src/resolvers.ts an:

src/resolvers.ts
import { and, eq } from 'drizzle-orm';
import { GraphQLError } from 'graphql';
import type { Database } from './db/index.ts';
import { adventurers, guilds, quests } from './db/schema.ts';
import type { Resolvers } from './generated/graphql.ts';

function badInput(message: string) {
  return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } });
}

async function findGuild(db: Database, id: number) {
  const [guild] = await db.select().from(guilds).where(eq(guilds.id, id));
  if (!guild) throw new Error(`Guild ${id} not found`);
  return guild;
}

async function findAdventurer(db: Database, id: number) {
  const [adventurer] = await db.select().from(adventurers).where(eq(adventurers.id, id));
  if (!adventurer) throw new Error(`Adventurer ${id} not found`);
  return adventurer;
}

export const resolvers: Resolvers = {
  Query: {
    guilds: (_parent, _args, { db }) => db.select().from(guilds).orderBy(guilds.id),

    guild: async (_parent, { id }, { db }) => {
      const [guild] = await db.select().from(guilds).where(eq(guilds.id, Number(id)));
      return guild ?? null;
    },

    quests: (_parent, { status, first }, { db }) => {
      if (first < 1 || first > 100) throw badInput('first must be between 1 and 100');
      return db
        .select()
        .from(quests)
        .where(status ? eq(quests.status, status) : undefined)
        .orderBy(quests.id)
        .limit(first);
    },

    quest: async (_parent, { id }, { db }) => {
      const [quest] = await db.select().from(quests).where(eq(quests.id, Number(id)));
      return quest ?? null;
    },
  },

  Guild: {
    adventurers: (guild, _args, { db }) =>
      db.select().from(adventurers).where(eq(adventurers.guildId, guild.id)).orderBy(adventurers.id),

    quests: (guild, { status }, { db }) =>
      db
        .select()
        .from(quests)
        .where(and(eq(quests.guildId, guild.id), status ? eq(quests.status, status) : undefined))
        .orderBy(quests.id),
  },

  Adventurer: {
    guild: (adventurer, _args, { db }) => findGuild(db, adventurer.guildId),

    quests: (adventurer, _args, { db }) =>
      db.select().from(quests).where(eq(quests.assigneeId, adventurer.id)).orderBy(quests.id),
  },

  Quest: {
    guild: (quest, _args, { db }) => findGuild(db, quest.guildId),

    assignee: (quest, _args, { db }) =>
      quest.assigneeId === null ? null : findAdventurer(db, quest.assigneeId),
  },
};

Ein Resolver bekommt vier Argumente: das Parent-Objekt, die Argumente des Felds, den Kontext und Informationen über die Query. Felder, die eine Spalte der Zeile sind, wie title oder reward, brauchen gar keinen Resolver: GraphQL liest sie über ihren Namen aus der Zeile. Nur die Beziehungen brauchen Code.

Ein GraphQLError geht so, wie er ist, an den Client, mit seinem code. Jeder andere Fehler wird maskiert: Der Client bekommt Unexpected error., und die Details landen im Log, die API zeigt also niemandem einen Stacktrace oder ein Stück SQL. Deshalb gibt es badInput, und deshalb nutzt es die Prüfung von first: Ohne sie käme ein first von -1 bis zu PostgreSQL.

Bring die Schemadatei und die Resolver in src/schema.ts zusammen:

src/schema.ts
import { readFileSync } from 'node:fs';
import { createSchema } from 'graphql-yoga';
import type { Context } from './context.ts';
import { resolvers } from './resolvers.ts';

export const schema = createSchema<Context>({
  typeDefs: readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8'),
  resolvers,
});

Und nutz das Ergebnis, mit dem Kontext, in src/server.ts:

src/server.ts
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { createContext, type ServerContext } from './context.ts';
import { schema } from './schema.ts';

const app = Fastify({
  logger: true,
  logController: new LogController({ disableRequestLogging: true }),
});

const yoga = createYoga<ServerContext>({
  schema,
  context: createContext,
  logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});

app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});

await app.listen({ port: Number(process.env.PORT) });

Zwei letzte Änderungen. node --watch bemerkt nur Änderungen an Dateien, die importiert werden, und schema.graphql wird gelesen, nicht importiert. Beobachte stattdessen den ganzen Ordner src:

package.json
"dev": "node --env-file=.env --watch-path=src src/server.ts | pino-pretty",
    "codegen": "graphql-codegen",

Und schalt in der .env das SQL-Log ein:

.env
LOG_SQL=true

Starte pnpm dev neu und frag nach etwas Echtem:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 1) { name motto adventurers { name class level } } }"}'{"data":{"guild":{"name":"Ember Lions","motto":"First into the fire.","adventurers":[{"name":"Brakka","class":"WARRIOR","level":7},{"name":"Ilse","class":"MAGE","level":5},{"name":"Corin","class":"ROGUE","level":3}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 500) { title } }"}'{"errors":[{"message":"first must be between 1 and 100","locations":[{"line":1,"column":3}],"path":["quests"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}

Das N+1-Problem

Jetzt das Questbrett, die Query, die ein Frontend als Erstes schicken würde: jede Quest mit ihrer Gilde und ihrem Abenteurer.

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests { title status guild { name } assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","guild":{"name":"Ember Lions"},"assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","guild":{"name":"Ember Lions"},"assignee":null},{"title":"Tame the volcano drake","status":"OPEN","guild":{"name":"Ember Lions"},"assignee":null},{"title":"Chart the sunken reef","status":"ACCEPTED","guild":{"name":"Tidecallers"},"assignee":{"name":"Tamsin"}},{"title":"Calm the storm spirit","status":"DONE","guild":{"name":"Tidecallers"},"assignee":{"name":"Nerys"}},{"title":"Recover the pearl crown","status":"OPEN","guild":{"name":"Tidecallers"},"assignee":null},{"title":"Clear the harbour of krakenlings","status":"OPEN","guild":{"name":"Tidecallers"},"assignee":null},{"title":"Guard the seed vault","status":"ACCEPTED","guild":{"name":"Moss Wardens"},"assignee":{"name":"Fen"}},{"title":"Wake the old treant","status":"OPEN","guild":{"name":"Moss Wardens"},"assignee":null}]}}

Die Antwort stimmt. Schau, was der Server dafür getan hat:

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" = $1 -- params: [1]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [1]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [5]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [4]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [2]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" = $1 -- params: [7]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" = $1 -- params: [3][15:20:11.909] INFO (67): Request processed    requestId: "req-3"    method: "POST"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 0    durationMs: 14

Sechzehn Abfragen für zehn Quests. Eine für die Liste, dann eine für die Gilde jeder Quest und noch eine für jede Quest, die einen Abenteurer hat. Allein die Ember Lions werden viermal geladen. Das ist das N+1-Problem: eine Abfrage für die Liste und eine weitere für jede ihrer N Zeilen.

Es wächst mit jeder Zeile und jeder Ebene. Frag nach allen Gilden mit ihren Abenteurern, den Quests dieser Abenteurer und den Quests der Gilden mit ihren Abenteurern:

graphql
{ guilds { name adventurers { name quests { title } } quests { title assignee { name } } } }

Das braucht 21 Abfragen für drei Gilden. Eine Seite mit 50 Quests braucht bis zu 101, und jede davon ist ein Weg zur Datenbank und zurück.

Im nächsten Teil sammelt DataLoader diese Abfragen und beantwortet sie mit einer Abfrage pro Tabelle: drei statt sechzehn, egal wie groß die Seite ist.

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.