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:
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:
$ 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:
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;typescriptschreibt einen Typ für jeden Typ im Schema, undtypescript-resolversschreibtResolvers, den Typ des Objekts, das sie beantwortet.useTypeImportsundenumsAsTypesbeschränken die erzeugte Datei auf Syntax, die Node entfernen kann:import typeund Enums als Unions von Strings, die genau den Werten der PostgreSQL-Enums entsprechen.contextTypeist der Kontext, den jeder Resolver bekommt. Den schreibst du gleich.mapperssind der wichtige Teil. Standardmäßig nimmt Codegen an, dass ein Resolver den GraphQL-Typ selbst zurückgibt, eine Quest bräuchte also einguild-Objekt. Deine Resolver geben aber Datenbankzeilen zurück, und eine Zeile hatguildId. Genau das sagen die Mapper: Wo ein Feld eineQuestliefert, gibst du eineQuestRowzurück, und die bekommen auch die Felder vonQuestals Parent.scalarserlaubt Zahlen fürIDin den Antworten. Die IDs in der Datenbank sind Zahlen, und GraphQL verschickt sie als Strings.
Ergänz das Skript und führ es aus:
"codegen": "graphql-codegen",$ 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.
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:
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:
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:
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:
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:
"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:
LOG_SQL=trueStarte pnpm dev neu und frag nach etwas Echtem:
$ 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.
$ 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:
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:
{ 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.