GraphQL, DataLoader и gRPC: три протокола — три мира
REST из прошлой главы — не единственный способ отдать данные. В этой главе два альтернативных мира. GraphQL переворачивает контракт: не сервер решает, что отдать, а клиент описывает, какие поля ему нужны (тур по языку — graphql.org/learn) — удобно для сложных фронтендов, но приносит свои демоны вроде N+1. gRPC — противоположный полюс: жёсткий контракт, бинарный формат, HTTP/2-стримы; язык общения сервисов между собой, а не с браузером.
Ты напишешь резолвер в NestJS, поймаешь N+1 руками и вылечишь его DataLoader’ом, опишешь сервис на Protocol Buffers и поднимешь gRPC-транспорт через @nestjs/microservices. В конце — честное сравнение и критерии выбора.
GraphQL: клиент диктует форму ответа
Заголовок раздела «GraphQL: клиент диктует форму ответа»Проблема REST, которую GraphQL решает: фронтенд ходит в GET /user, потом в GET /user/42/orders, потом в GET /orders/7/items — три запроса, три оверхеда, лишние данные в каждом. GraphQL предлагает один эндпоинт, на котором клиент шлёт query — декларативное описание нужного графа данных:
query { user(id: 42) { email orders(status: PAID, limit: 5) { total items { title price } } }}Ответ — ровно тот же JSON по форме, без лишних полей. Один запрос — одна сетевая поездка. Это главная ценность: устранение over-fetching (поля, которые не нужны) и under-fetching (не хватило — иди ещё раз).
Схема и типы
Заголовок раздела «Схема и типы»Схема — типизированный контракт. Типы: String, Int, Float, Boolean, ID и пользовательские type/enum/input:
type User { id: ID! email: String! role: Role! orders(status: OrderStatus, limit: Int = 20, cursor: String): OrderPage!}
type Order { id: ID! total: Int! # деньги — в минорных единицах, копейках/центах status: OrderStatus! items: [OrderItem!]!}
type OrderPage { items: [Order!]! nextCursor: String hasMore: Boolean!}
enum Role { USER ADMIN }enum OrderStatus { DRAFT PAID SHIPPED CANCELLED }
type Query { user(id: ID!): User orders(userId: ID!, limit: Int = 20, cursor: String): OrderPage!}
type Mutation { createOrder(input: CreateOrderInput!): Order! cancelOrder(id: ID!): Order!}
input CreateOrderInput { userId: ID! itemIds: [ID!]!}! — non-nullable. Всё в схеме строго: сервер не сможет отдать null там, где стоит !, — это защищает клиент от «а вдруг поля нет».
Queries, Mutations, Subscriptions
Заголовок раздела «Queries, Mutations, Subscriptions»- Query — чтение. Может быть несколько параллельных полей в одном запросе.
- Mutation — изменение. Выполняются последовательно, в порядке следования.
- Subscription — поток событий поверх WebSocket: клиент подписался — сервер пушит.
subscription { orderUpdated(userId: 42) { id status total }}Подписки — самая громоздкая часть GraphQL: нужен отдельный WebSocket-транспорт, при нескольких инстансах — брокер (Redis Pub/Sub) между ними, и авторизация на уровне события. По умолчанию бери SSE или WebSocket-гейтвей напрямую; subscriptions — когда фронт реально ждёт богатый граф событий.
Инструментальная заметка: в разработке включай GraphiQL/Apollo Sandbox (включается одной опцией playground/в новых версиях — по дефолту) — это REPL для схемы: автодополнение полей, документация типов, история запросов. Закрывай его в проде или прячь за авторизацией: открытый playground — рабочий конструктор запросов против твоего API.
Резолверы в NestJS
Заголовок раздела «Резолверы в NestJS»Подключение — @nestjs/graphql с code-first (декораторы генерируют схему) или schema-first (пишешь .graphql, пишешь резолверы). Разберём code-first, он доминирует в Nest-экосистеме:
npm i @nestjs/graphql @apollo/server graphqlimport { Resolver, Query, Args, ID, Parent, ResolveField } from '@nestjs/graphql';
@Resolver(() => UserModel)export class UsersResolver { constructor( private readonly users: UsersService, private readonly ordersLoader: OrdersDataLoader, // DataLoader — ниже ) {}
@Query(() => UserModel, { nullable: true }) async user(@Args('id', { type: () => ID }) id: string) { return this.users.findById(id); }
// ResolveField — «виртуальное поле»: вызывается, когда клиент запросил orders @ResolveField(() => OrderPageModel) async orders( @Parent() user: UserModel, @Args('limit', { type: () => Int, defaultValue: 20 }) limit: number, @Args('cursor', { nullable: true }) cursor?: string, ) { return this.users.ordersOf(user.id, { limit, cursor }); }}@ResolveField — ключевая механика: поле orders в типе User не хранится в таблице users, оно «резолвится» лениво — только если клиент его запросил. Именно здесь прячется демон N+1.
N+1: проклятие GraphQL и DataLoader
Заголовок раздела «N+1: проклятие GraphQL и DataLoader»Запрос:
query { users(limit: 50) { email orders { total } } }Наивная реализация: 1 запрос за 50 пользователей, потом 50 запросов за заказами каждого — итого 51 SQL-запрос. Клиент наращивает глубину — нагрузка растёт экспоненциально. Это N+1, и это причина номер один падения GraphQL-продакшенов.
DataLoader решает это батчингом и кэшированием в рамках одного запроса:
import DataLoader from 'dataloader';
@Injectable()export class OrdersDataLoader { create() { return new DataLoader<string, Order[]>(async (userIds) => { // вызывается ОДИН раз на тик Event Loop со всеми накопленными id const orders = await this.db.query.orders.findMany({ where: inArray(Orders.userId, [...userIds]), }); // порядок ответа должен совпадать с порядком входных id return userIds.map((id) => orders.filter((o) => o.userId === id)); }); }}// в ResolveField@ResolveField(() => [OrderModel])orders(@Parent() user: UserModel) { return this.ordersLoader.load(user.id); // выглядит как запрос, но батчится}Механика: load() не ходит в БД, а кладёт id во внутреннюю очередь. DataLoader ждёт конца текущей итерации Event Loop, собирает все id и делает один запрос WHERE user_id IN (...). Все 50 вызовов получают свои массивы заказов.
// context: фабрика на каждый GraphQL-запросGraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: true, context: ({ req }) => ({ user: req.user, loaders: { orders: ordersDataLoader.create(), // свой DataLoader на запрос }, }),}),Дополнительно: persisted queries (механика — Automatic Persisted Queries в Apollo) — клиент шлёт не текст запроса, а хеш (hash + extensions), сервер хранит текст заранее. Плюсы: меньше трафик, быстрее парсинг, закрытие поверхности для произвольных тяжёлых запросов (безопасность). Минус — нужен процесс публикации запросов из сборки фронта. Используются в мобильных приложениях и высоконагруженных API.
Когда GraphQL уместен
Заголовок раздела «Когда GraphQL уместен»Уместен: сложный фронт с разнообразными экранами из одних данных (дашборды, админки, B2B-кабинеты), много клиентов с разными потребностями, сильно связный граф сущностей. Неуместен: простой CRUD (REST проще), публичный API без доверия к клиентам (нужен rate-limit по сложности запросов — graphql-query-complexity), команда без опыта диагностики (дедлайны запросов отлаживать сложнее, чем HTTP-логи).
Обязательный набор для GraphQL в проде: ограничение глубины и сложности запроса, пагинация каждой коллекции, DataLoader везде, трейсинг резолверов (Apollo Studio/OpenTelemetry), persisted queries для мобильных клиентов.
gRPC: жёсткий контракт и скорость
Заголовок раздела «gRPC: жёсткий контракт и скорость»gRPC — RPC-фреймворк от Google для общения сервисов между собой (концепции — gRPC Core Concepts). Противоположность GraphQL: контракт фиксируется в .proto-файле, из него генерируются сервер и клиенты на любом языке, передача — бинарная (protobuf) поверх HTTP/2.
Protocol Buffers: синтаксис
Заголовок раздела «Protocol Buffers: синтаксис»syntax = "proto3";
package orders.v1; // неймспейс: версия — в пакете
// --- Сообщения: контракты данных ---message GetOrderRequest { string id = 1; // теги (1, 2, 3...) — бинарная сериализация, не меняй номера}
message Order { string id = 1; string user_id = 2; int64 total_cents = 3; // деньги — integer, никогда float OrderStatus status = 4;}
enum OrderStatus { ORDER_STATUS_UNSPECIFIED = 0; // нулевое значение обязательно ORDER_STATUS_DRAFT = 1; ORDER_STATUS_PAID = 2; ORDER_STATUS_SHIPPED = 3;}
message CreateOrderRequest { string user_id = 1; repeated string item_ids = 2; // repeated = массив}
message OrderEvent { string order_id = 1; OrderStatus new_status = 2;}
message WatchOrdersRequest { string user_id = 1;}
// --- Сервис: четыре вида RPC ---service OrdersService { // 1. Unary: запрос → ответ (как обычный HTTP) rpc GetOrder (GetOrderRequest) returns (Order);
// 2. Server streaming: один запрос → поток событий rpc WatchOrders (WatchOrdersRequest) returns (stream OrderEvent);
// 3. Client streaming: поток загрузки → один ответ (импорт логов, чанки файла) rpc UploadOrderAttachments (stream OrderAttachment) returns (UploadSummary);
// 4. Bidirectional streaming: оба потока (чат, realtime-репликация) rpc SyncOrders (stream SyncRequest) returns (stream SyncResponse);}Правила protobuf (Language Guide (proto3)): номера полей — часть бинарного формата, не менять и не переиспользовать; новые поля добавляются новыми номерами — старые клиенты их просто проигнорируют (это и есть эволюция схемы без брейкинга); 0 у enum — всегда «unspecified».
Генерация кода
Заголовок раздела «Генерация кода»# protoc — компилятор; плагины генерируют TSnpm i -D grpc-tools grpc_tools_node_protoc_tsprotoc \ --proto_path=./proto \ --js_out=import_style=commonjs,binary:./src/generated \ --grpc_out=grpc_js:./src/generated \ --ts_out=grpc_js:./src/generated \ orders/v1/orders.protoИз одного .proto получаешь типы сообщений и клиентские стабы. В Go/Rust/Java — свои генераторы из того же файла. Контракт един, реализации — любые.
Микросервисный транспорт в NestJS
Заголовок раздела «Микросервисный транспорт в NestJS»@nestjs/microservices превращает Nest-приложение в gRPC-сервер/клиент:
npm i @nestjs/microservices @grpc/grpc-js @grpc/proto-loader// grpc-server: users.service.ts как микросервисconst app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, { transport: Transport.GRPC, options: { package: 'users.v1', protoPath: join(__dirname, 'proto/users/v1/users.proto'), url: '0.0.0.0:50051', // gRPC живёт на своём порту },});await app.listen();// users.controller.ts — хендлеры RPC как обычные методы@Controller()export class UsersGrpcController { constructor(private readonly users: UsersService) {}
@GrpcMethod('UsersService', 'GetUser') getUser(data: GetUserRequest) { // data — распарсенный protobuf return this.users.findById(data.id); // вернёшь message — сериализуется сам }
@GrpcStreamMethod('UsersService', 'WatchUsers') watchUsers(data: WatchUsersRequest): Observable<UserEvent> { return this.users.watch(data.userId); // RxJS Observable → серверный стрим }}// grpc-client: orders-service вызывает users-service@Module({ imports: [ ClientsModule.register([ { name: 'USERS_PACKAGE', transport: Transport.GRPC, options: { package: 'users.v1', protoPath: join(__dirname, 'proto/users/v1/users.proto'), url: 'users-service:50051', // service discovery / k8s DNS }, }, ]), ],})export class OrdersModule {}
@Injectable()export class OrdersService { @Inject('USERS_PACKAGE') private readonly client: ClientGrpc; private usersSvc!: UsersServiceClient;
onModuleInit() { this.usersSvc = this.client.getService<UsersServiceClient>('UsersService'); }
async createOrder(dto: CreateOrderDto) { const user = await firstValueFrom(this.usersSvc.getUser({ id: dto.userId })); if (!user) throw new NotFoundException('Пользователь не найден'); // ... }}Плюсы, которые делают gRPC стандартом внутри микросервисных сетей: строгий контракт (поменял .proto — компилятор поймает всех), бинарная скорость (protobuf компактнее JSON в разы), HTTP/2 (мультиплексирование, дедлайны через deadline из коробки), стримы как примитив языка.
Ограничения: браузер не говорит gRPC напрямую (нужен grpc-web-прокси — Envoy), отладка curl’ом невозможна (бинарь), схема эволюционирует жёстче JSON. Поэтому gRPC — внутри периметра, REST/GraphQL — наружу.
Сравнение: REST vs GraphQL vs gRPC
Заголовок раздела «Сравнение: REST vs GraphQL vs gRPC»| Критерий | REST | GraphQL | gRPC |
|---|---|---|---|
| Контракт | OpenAPI, мягкий | Схема, строгая | .proto, строгая + codegen |
| Кто выбирает поля | Сервер (фикс. ресурсы) | Клиент (query) | Сервер (фикс. сообщения) |
| Формат | JSON, текст | JSON, текст | Protobuf, бинарь |
| Транспорт | HTTP/1.1–2 | HTTP + WS | HTTP/2 |
| Стримы | SSE/WebSocket вручную | Subscriptions (WS) | 4 вида из коробки |
| Клиент | Браузер, всё | Браузер, приложения | Сервис ↔ сервис |
| Нагрузка | Предсказуемая | Требует лимитов сложности | Максимальная плотность |
| Отладка | curl, легко | Тяжелее (глубокие query) | grpcurl + рефлексия |
Выбор на практике: публичный и клиентский API — REST (+OpenAPI) по умолчанию, GraphQL — если фронт сложный и данные сильно связаны. Между сервисами — gRPC, если больше 2–3 сервисов и высокий трафик; REST между сервисами приемлем и часто проще в маленьких командах. Реалтайм в браузер — WebSocket/SSE поверх REST, не «subscriptions по умолчанию».
Переменные, фрагменты и ошибки GraphQL
Заголовок раздела «Переменные, фрагменты и ошибки GraphQL»Практические примитивы языка, без которых реальные запросы не пишут:
query UserCard($id: ID!, $withOrders: Boolean!) { user(id: $id) { ...UserBase orders @include(if: $withOrders) { total } }}
fragment UserBase on User { id email role}Переменные — параметры запроса, чтобы не конкатенировать строки (инъекции через query string — это реальность). Фрагменты — переиспользуемые куски схемы: фронтенд-дублирование полей исчезает. Директивы (@include, @skip) — условное включение полей на уровне клиента.
Модель ошибок в GraphQL отличается от HTTP-стиля: ответ может содержать и данные, и ошибки одновременно:
{ "data": { "user": { "email": "a@b.c", "orders": null } }, "errors": [ { "message": "Orders service timeout", "path": ["user", "orders"], "extensions": { "code": "UPSTREAM_TIMEOUT" } } ]}Это partial data: один резолвер упал — остальное отдалось. Клиент обязан проверять errors, а не только data. Для типизации extensions.code — машиночитаемый код ошибки, аналог твоего type из RFC 7807.
В мульти-командных системах поверх схем возникает федерация (Apollo Federation) — каждый сервис владеет своими типами, gateway склеивает из них единый граф. Мощно, но это целая под-инфраструктура: начинай с одного GraphQL-сервера, федерацию подключай только когда граф реально разрезается по командам.
gRPC в проде: ошибки, дедлайны, health-checks
Заголовок раздела «gRPC в проде: ошибки, дедлайны, health-checks»Три вещи, отличающие игрушечный gRPC от продакшен-интеграции.
Коды ошибок. gRPC не несёт HTTP-статусы — у него свои status codes, и их надо маппить осознанно:
import { RpcException } from '@nestjs/microservices';import { status } from '@grpc/grpc-js';
if (!user) throw new RpcException({ code: status.NOT_FOUND, message: 'Пользователь не найден' });// INVALID_ARGUMENT -> 400, UNAUTHENTICATED -> 401, PERMISSION_DENIED -> 403,// ALREADY_EXISTS -> 409, UNAVAILABLE -> апстрим упал (ретрайбельно)UNAVAILABLE — единственный из частых кодов, который по умолчанию стоит ретраить: сеть моргнула, инстанс ушёл на рестарт. Остальное — постоянные ошибки, ретрай лишь умножит нагрузку.
Дедлайны. Каждый вызов должен иметь дедлайн — иначе зависший апстрим повесит всю цепочку:
import { Metadata } from '@grpc/grpc-js';
const metadata = new Metadata();metadata.set('deadline', String(Date.now() + 2000)); // 2 секунды на вызов
this.usersSvc.getUser({ id }, metadata).subscribe({ ... });В распределённой цепочке (gateway → orders → users → payments) дедлайн наследуется: каждый хоп вычитает уже потраченное время. Это тот же таймаут, что мы обсуждали в interceptor-главе, только на уровне транспорта.
Health-checks и reflection. Оркестратор (Kubernetes) должен знать, жив ли gRPC-процесс: используй grpc_health_v1 (probe-интеграция через grpc_health_probe или @grpc/grpc-js-health-check). Reflection — runtime-описание сервисов для grpcurl: без неё отладка бинарного протокола превращается в угадайку, включай её хотя бы на staging.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- DataLoader-синглтон. Общий на приложение DataLoader смешивает батчи запросов → утечка данных между пользователями. На каждый запрос — новый инстанс.
- Резолверы без пагинации.
User.ordersбез limit на пользователе с 100 000 заказами = OOM. Любое поле-коллекция — только с пагинацией. - GraphQL без лимита сложности. Клиент шлёт глубокий рекурсивный запрос, сервер считает минуты.
depthLimit+ query-complexity — обязательны для публичных API. - Мутация с сайд-эффектами без идемпотентности. Ретрай мутации создаёт дубли — те же идемпотентные ключи, что и в REST, только через headers/input.
- Float для денег в proto.
float total = 1— классическая ошибка; деньги — всегдаint64в минорных единицах. Потеря копеек на округлениях в бинарном формате — не теория. - Правка номеров полей в .proto. Поменял
string id = 1наint64 id = 1— старые клиенты распарсят мусор. Только новые номера для новых полей; удалённые номера не переиспользовать. - gRPC наружу без прокси. Браузер не поймёт HTTP/2-фреймы gRPC; публичный gRPC без grpc-web/Envoy — неработающая архитектура.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое N+1 в GraphQL и как его лечат? Связные поля резолвятся по одному запросу на родителя: 50 пользователей → 50 запросов за заказами. Лечение — DataLoader: батчинг всех
load()в рамках одного тика Event Loop в один запросIN (...), плюс кэш в рамках запроса. - Почему DataLoader на каждый запрос? Батч-кэш живёт в инстансе. Общий инстанс смешает данные разных клиентов — утечка. Создаётся в context/REQUEST-scope на каждый GraphQL-запрос.
- Query vs Mutation vs Subscription? Query — чтение (поля параллельны), Mutation — изменение (последовательно), Subscription — поток событий по WebSocket.
- Четыре вида RPC в gRPC? Unary (1→1), server streaming (1→поток), client streaming (поток→1), bidirectional (поток↔поток). Через
streamв объявлении rpc. - Почему поля в protobuf имеют номера и почему их нельзя менять? Номер — позиция в бинарном формате. Смена номера/типа сломает сериализацию для старых клиентов. Эволюция — только добавление новых номеров.
- Когда GraphQL, а когда REST? GraphQL — сложный связный фронт, разные клиенты, over/under-fetching больно. REST — простые ресурсы, публичный API, предсказуемая нагрузка. GraphQL дороже в эксплуатации.
- Почему gRPC не для браузера? HTTP/2-фрейминг и бинарный формат не поддерживаются браузером напрямую; нужен grpc-web-прокси. Плюс человекочитаемая отладка. Поэтому gRPC — внутри, REST наружу.
- Как версионировать gRPC API? Версия в package (
orders.v1/orders.v2) и в пути proto-файла. Поля эволюционируют без брейкинга; брейкинг — новый пакет.
Практика
Заголовок раздела «Практика»- Подними
@nestjs/graphql(Apollo, code-first): типыUser/Order, Queryuser(id), ResolveFieldordersс cursor-пагинацией. Критерий: один запросuser(id:1){ email orders{ total } }возвращает вложенные данные. - Воспроизведи N+1: залогируй число SQL-запросов при 20 пользователях (будет 21), подключи DataLoader (станет 2). Скриншот логов — в репозиторий.
- Ограничь глубину запроса через
graphql-depth-limitи сложность черезgraphql-query-complexity; докажи, что рекурсивный запрос отклоняется. - Опиши
orders.proto(unaryGetOrder+ server-streamingWatchOrders), сгенерируй TS-клиент, вызови метод из второго Nest-сервиса черезClientsModuleи RxJSObservable. - Реализуй идемпотентную мутацию
createOrderв GraphQL (ключ в input) и gRPC-unary с дедлайном 2 секунды на клиенте (metadata.set('deadline', ...)через call options).
Что почитать
Заголовок раздела «Что почитать»- GraphQL: Learn — официальный тур по языку запросов.
- DataLoader на GitHub — README с механикой батчинга и кэширования.
- NestJS GraphQL и NestJS Microservices (gRPC) — интеграция в обоих направлениях.
- Protocol Buffers: Language Guide (proto3) — правила эволюции схем.
- gRPC Core Concepts — четыре вида RPC и дедлайны.
- persisted queries в Apollo — automatic persisted queries.