Code-first GraphQL, резолверы и DataLoader
Code-first GraphQL генерирует SDL из классов @ObjectType/@Resolver. Field resolver порождает N+1; DataLoader на запрос батчит ключи в один вызов БД. Прод закаляем лимитами глубины/сложности.
GraphQL-эндпоинт уехал чистым, и демо-запрос был мгновенным: один автор с его постами. Потом команда дашборда написала authors { id posts { title } } по всему списку — и Postgres вспыхнул: 1 запрос на 200 авторов, а потом ещё 200, по одному на автора, чтобы достать посты каждого. p99 прыгнул с 40ms до 6 секунд, и пул соединений начал копить очередь. В резолвере не было ничего «неправильного»; field resolver для posts просто выполнился по разу на каждого родителя, а 200 родителей — это 200 round-trip’ов. Это и есть GraphQL N+1, и это самый частый способ, которым типизированный graph API расплавляет базу. Фикс — это не join, а батчинг ключей, собранных за один тик, в единственный вызов.
Code-first: схема генерируется из твоих классов
В подходе code-first ты никогда не пишешь SDL руками. Ты навешиваешь на TypeScript-классы @ObjectType() и @Field(), на методы резолвера — @Query() / @Mutation() / @ResolveField(), и Nest генерирует схему из этой информации о типах при старте. autoSchemaFile пишет SDL на диск (или держит в памяти), чтобы тулинг мог её читать, — но источник истины это твой код. Альтернатива schema-first обратна: ты пишешь SDL руками и генерируешь из него TypeScript-типы. Code-first выигрывает, когда команда живёт в TypeScript и хочет, чтобы типы и схема никогда не расходились.
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { Module } from '@nestjs/common';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql', // ГЕНЕРИРУЕМ SDL из TS-классов
playground: false, // выключен в проде
introspection: process.env.NODE_ENV !== 'production', // прячем схему в проде
}),
],
})
export class AppModule {}Объектные типы — обычные классы; @Field(() => Int) фиксирует GraphQL-скаляр там, где рефлексия TS не может его вывести (числа, массивы, nullability):
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Post {
@Field(() => Int) id: number;
@Field() title: string;
}
@ObjectType()
export class Author {
@Field(() => Int) id: number;
@Field({ nullable: true }) firstName?: string;
@Field(() => [Post]) posts: Post[]; // связь -> резолвится лениво field resolver'ом
}Резолверы и field resolver — где рождается N+1
Класс @Resolver(() => Author) хостит корневые операции (@Query, @Mutation) и, что критично, field resolver’ы (@ResolveField), которые вычисляют поле лениво, на каждый родительский объект. Поле posts у Author не загружается вместе с автором — оно резолвится, только если запрос его попросил, вызовом field resolver по разу на каждого автора в выборке. Это исполнение на родителя и есть N+1: один запрос на N авторов, потом N отдельных запросов на их посты.
import { Resolver, Query, ResolveField, Parent, Args, Int } from '@nestjs/graphql';
@Resolver(() => Author)
export class AuthorsResolver {
constructor(
private readonly authors: AuthorsService,
private readonly posts: PostsService,
) {}
@Query(() => [Author])
authors() {
return this.authors.findAll(); // 1 запрос -> N авторов
}
@ResolveField(() => [Post])
async postsFor(@Parent() author: Author) {
return this.posts.findByAuthorId(author.id); // выполняется ПО РАЗУ НА АВТОРА -> N+1
}
}DataLoader: батчим ключи, собранные за один тик
DataLoader сидит между field resolver’ом и базой. Вместо немедленного запроса каждый вызов load(id) ставит ключ в очередь; в конце текущего тика event-loop’а DataLoader отдаёт весь батч ключей в один findByAuthorIds([...]) и раздаёт результат обратно каждому вызывающему. Он также мемоизирует внутри батча, так что два вызова load(5) бьют по БД один раз. Коллапс драматичен: 1 + N запросов превращаются в 1 + 1.
Loader обязан быть на запрос — его кэш держит данные запроса и не должен утекать между пользователями, — поэтому подключаем его как request-scoped provider и резолвим в GraphQL-context:
import * as DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST }) // свежий loader на каждый запрос -> нет кросс-запросного кэша
export class PostsLoader {
constructor(private readonly posts: PostsService) {}
readonly byAuthorId = new DataLoader<number, Post[]>(async (authorIds) => {
// ОДИН round-trip на все ключи, собранные в этом тике:
const rows = await this.posts.findByAuthorIds(authorIds as number[]);
const byId = new Map<number, Post[]>(authorIds.map((id) => [id, []]));
for (const p of rows) byId.get(p.authorId)!.push(p);
return authorIds.map((id) => byId.get(id)!); // ОБЯЗАН вернуть результаты в порядке ключей
});
}@ResolveField(() => [Post])
postsFor(@Parent() author: Author) {
return this.loader.byAuthorId.load(author.id); // ставит ключ в очередь; батчится, а не на вызов
}Контракт batch-функции строгий: она обязана вернуть массив той же длины и в том же порядке, что и входные ключи, отображая отсутствующий ключ в [] (или null), никогда молча не выбрасывая его, — перепутаешь порядок, и авторы получат чужие посты.
Context, guards и закалка для прода
Guards, interceptors и filters по-прежнему работают в GraphQL — но если попробуешь использовать их точно так же, как в HTTP-контроллере, они тихо провалятся: не смогут прочитать args запроса. Форма аргументов резолвера другая (root, args, context, info), так что общий ExecutionContext ты адаптируешь через GqlExecutionContext.create(context), чтобы прочитать args или request:
import { GqlExecutionContext } from '@nestjs/graphql';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
@Injectable()
export class GqlAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const { req } = ctx.getContext(); // request лежит на GraphQL-context
return Boolean(req?.user);
}
}Единственный эндпоинт, принимающий произвольные запросы, — это поверхность для denial-of-service: глубоко вложенный posts { author { posts { author ... } } } может взорваться в миллионы field-резолвов. Продакшен-графы ограничивают это лимитом глубины запроса (query depth) и бюджетом сложности (complexity) — назначь каждому полю стоимость и отклоняй любую операцию выше потолка ещё до выполнения:
import { Plugin } from '@nestjs/apollo';
import { GraphQLSchemaHost } from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
@Plugin()
export class ComplexityPlugin {
constructor(private readonly schemaHost: GraphQLSchemaHost) {}
async requestDidStart() {
const max = 20, { schema } = this.schemaHost;
return {
didResolveOperation: async ({ request, document }) => {
const complexity = getComplexity({
schema, query: document, variables: request.variables,
operationName: request.operationName,
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > max) throw new GraphQLError(`Query too complex: ${complexity} > ${max}`);
},
};
}
}Добавь persisted queries (клиенты шлют хеш заранее зарегистрированного запроса, а не произвольный текст) и выключи introspection в проде, чтобы атакующий не мог тривиально замапить весь граф.
| Задача | Инструмент | Механизм | Какой сбой предотвращает |
|---|---|---|---|
| Схема | code-first autoSchemaFile | SDL генерится из @ObjectType-классов | расхождение типов и схемы |
| Связь | @ResolveField | резолвит поле лениво, на родителя | over-fetch незапрошенных связей |
| N+1 | DataLoader на запрос | батч ключей за тик -> 1 вызов | 1 + N round-trip’ов, истощение пула |
| Сквозное | GqlExecutionContext | адаптирует ctx для guards/interceptors | guard не может читать args/req |
| DoS | лимиты глубины + сложности | отклонить дорогой запрос до выполнения | вложенный запрос плавит БД |
▸Почему это работает
Почему DataLoader живёт на запрос, а не синглтоном? Его внутрибатчевый кэш и есть то, что делает батчинг рабочим, но этот кэш держит только что добытые строки — посты автора 5, профиль пользователя 12. Loader-синглтон отдал бы запросу B строки, закэшированные для запроса A, утекая данные одного пользователя в ответ другому и никогда не отражая запись, случившуюся между запросами. Свежий loader на запрос сохраняет выигрыш батчинга, но скоупит кэш одной аутентифицированной единицей работы. Поэтому ты регистрируешь его как Scope.REQUEST (или строишь в фабрике GraphQL-context) — один loader, один запрос, выбрасывается в конце.
Запрос возвращает N авторов, каждый резолвит поле `posts`. Наивный резолвер выполняет один запрос к БД на автора (1 + N). Как резолвить связь в graph API?
Запрос просит 50 авторов и посты каждого через @ResolveField. С корректно подключённым DataLoader на запрос — сколько запросов к БД выполнится?
В code-first NestJS GraphQL откуда берётся схема (SDL) и как guards читают GraphQL-аргументы?
- 01Объясни проблему GraphQL N+1 в резолвере Nest и точно как DataLoader её чинит, включая почему loader обязан быть request-scoped.
- 02Сопоставь code-first и schema-first GraphQL в Nest и назови закалку для прода, которую добавляешь к публичному graph-эндпоинту.
Code-first GraphQL в Nest генерирует схему из твоего TypeScript: классы @ObjectType/@Field и методы @Resolver (@Query, @Mutation, @ResolveField) становятся SDL через autoSchemaFile, так что типы и схема не расходятся — обратное к schema-first, где ты пишешь SDL руками и генерируешь типы. Сеньорская ловушка — это field resolver: @ResolveField резолвит связь лениво, по разу на родителя, так что запрос по N авторам, каждый из которых резолвит свои posts, выполняет 1 + N запросов к базе — GraphQL N+1, истощающий пул соединений на масштабе списка. Фикс — это DataLoader: field resolver вызывает load(id), ключи собранные за один тик event-loop’а батчатся в единственный вызов findByAuthorIds([…]) и мемоизируются в пределах запроса, схлопывая 1 + N в 1 + 1; batch-функция обязана вернуть результаты в том же порядке, что и ключи. Loader регистрируется как Scope.REQUEST (или строится в GraphQL-context), так что его кэш скоупится одним запросом и никогда не утекает между пользователями. Guards, interceptors и filters по-прежнему работают — context ты адаптируешь через GqlExecutionContext.create(context), чтобы добраться до args и request. Наконец, поскольку один эндпоинт принимает произвольные запросы, прод закаляем лимитами глубины и сложности, отклоняющими дорогую операцию до её выполнения, persisted queries и выключенным introspection. Теперь, когда увидишь скачок p99 на запросе по списку, первая мысль — DataLoader, а перед тем как добавлять новую связь в граф, — проверить, вписывается ли она в бюджет сложности.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.