GraphQL: Dotazovací jazyk, Resolver a problém N+1
GraphQL řeší jeden ze základních problémů REST API: klient nemůže ovládat
jaké údaje přijímá. S ODPOČINEM, GET /users/123 vždy vrátí všechna uživatelská pole,
i když klient potřebuje jen dva. Chcete-li nahrát kompletní uživatelský profil s objednávkami
nedávné a vyrobené, často potřebují 3-4 samostatná volání HTTP. GraphQL tento problém odstraňuje
umožňuje klientovi přesně specifikovat data potřebná v jediném dotazu.
Ale GraphQL představuje svou vlastní složitost: Problém N+1 a riziko zákeřnější architektura, schopná transformovat dotaz, který se zdá neškodný, na a lavina desítek či stovek databázových dotazů. Tato příručka vysvětluje, jak to funguje systému resolveru, jak se problém N+1 projevuje a jak DataLoader to řeší automatickým dávkováním a ukládáním do mezipaměti.
Co se naučíte
- Schéma GraphQL: typy, dotazy, mutace a odběry
- Solving Loop: Jak GraphQL provádí dotaz
- Resolver: Funkce, které poskytují data pro každé pole
- Problém N+1: jak se projevuje a proč je nebezpečný
- DataLoader: dávkování a ukládání do mezipaměti pro vyřešení N+1
- Dokončete nastavení pomocí serveru Apollo a TypeScript
Schéma GraphQL: Typovaná smlouva
Každé GraphQL API začíná schématem: smlouvou, která definuje všechny dostupné typy, dotazy (čtení), mutace (zápisy) a odběry (streamy v reálném čase). Schéma je napsáno v SDL (Schema Definition Language):
// schema.graphql
type User {
id: ID!
name: String!
email: String!
createdAt: String!
orders: [Order!]! # Un utente ha molti ordini
profile: UserProfile # Nullable: non tutti gli utenti hanno un profilo
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
createdAt: String!
items: [OrderItem!]! # Un ordine ha molti item
user: User! # Relazione bidirezionale
}
type OrderItem {
id: ID!
quantity: Int!
price: Float!
product: Product! # L'item ha un prodotto
}
type Product {
id: ID!
name: String!
price: Float!
category: String!
}
type UserProfile {
bio: String
avatarUrl: String
website: String
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
# Query: operazioni di lettura (equivalente GET in REST)
type Query {
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
order(id: ID!): Order
searchProducts(query: String!, limit: Int = 10): [Product!]!
}
# Mutation: operazioni di scrittura (equivalente POST/PUT/DELETE)
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createOrder(input: CreateOrderInput!): Order!
}
# Subscription: stream real-time (WebSocket)
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newUserRegistered: User!
}
input CreateUserInput {
name: String!
email: String!
password: String!
}
input UpdateUserInput {
name: String
email: String
}
Resolution Loop: Jak GraphQL provádí dotaz
Když klient odešle dotaz GraphQL, server jej provede prostřednictvím procesu zvaného "rozlišení", které prochází schématem jako strom a volá funkci řešitelé pro každé povinné pole:
// Questa query del client:
query {
users(limit: 5) {
id
name
orders {
id
total
status
}
}
}
// Produce questo albero di risoluzione:
// 1. Query.users -> chiama il resolver "users"
// 2. Per ogni User restituito:
// - User.id -> valore direttamente dall'oggetto
// - User.name -> valore direttamente dall'oggetto
// - User.orders -> chiama il resolver "orders" con parent = user
// 3. Per ogni Order dentro ogni User:
// - Order.id -> valore direttamente dall'oggetto
// - Order.total -> valore direttamente dall'oggetto
// - Order.status -> valore direttamente dall'oggetto
Implementujte nástroje pro řešení pomocí serveru Apollo
Řešitel je funkce, která ví, jak načíst data pro konkrétní pole. Každý resolver obdrží čtyři argumenty: rodič (objekt nadřazeného prvku), argumenty (argumenty dotazu), kontext (sdílená data, jako je databáze a ověřování) a informace (metadata dotazu):
// src/resolvers/index.ts
import { Resolvers } from './generated/types'; // Tipi generati da GraphQL Code Generator
import DataLoader from 'dataloader';
// Context condiviso tra tutti i resolver
export interface GraphQLContext {
db: DatabaseClient;
currentUser: User | null;
loaders: {
userById: DataLoader<string, User>;
ordersByUserId: DataLoader<string, Order[]>;
productById: DataLoader<string, Product>;
};
}
export const resolvers: Resolvers<GraphQLContext> = {
Query: {
// parent = {} (root query), args = { limit, offset }, ctx = context
users: async (_, { limit = 20, offset = 0 }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' }
});
return db.users.findMany({
take: Math.min(limit, 100), // Limite massimo di sicurezza
skip: offset,
orderBy: { createdAt: 'desc' }
});
},
user: async (_, { id }, { db }) => {
return db.users.findUnique({ where: { id } });
},
searchProducts: async (_, { query, limit }, { db }) => {
return db.products.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ category: { contains: query, mode: 'insensitive' } }
]
},
take: Math.min(limit, 50)
});
}
},
// Resolver per i campi del tipo User
User: {
// parent = User object, args = {}, ctx = context
orders: async (user, _, { loaders }) => {
// USA DataLoader invece di db.orders.findMany({ where: { userId: user.id } })
// Spiegazione nella sezione successiva!
return loaders.ordersByUserId.load(user.id);
},
profile: async (user, _, { db }) => {
return db.userProfiles.findUnique({ where: { userId: user.id } });
}
},
// Resolver per i campi del tipo Order
Order: {
items: async (order, _, { db }) => {
return db.orderItems.findMany({ where: { orderId: order.id } });
},
user: async (order, _, { loaders }) => {
return loaders.userById.load(order.userId);
}
},
// Resolver per i campi del tipo OrderItem
OrderItem: {
product: async (item, _, { loaders }) => {
return loaders.productById.load(item.productId);
}
},
Mutation: {
createUser: async (_, { input }, { db }) => {
const hashedPassword = await bcrypt.hash(input.password, 12);
return db.users.create({
data: { ...input, password: hashedPassword }
});
},
createOrder: async (_, { input }, { db, currentUser }) => {
if (!currentUser) throw new GraphQLError('Authentication required');
return db.orders.create({
data: { ...input, userId: currentUser.id, status: 'PENDING' }
});
}
}
};
Problém N+1: Skryté nebezpečí
Problém N+1 je nejčastějším architektonickým rizikem v GraphQL. Představte si tento dotaz:
query {
users(limit: 10) { # 1 query: SELECT * FROM users LIMIT 10
name
orders { # 10 query: SELECT * FROM orders WHERE userId = ?
total # Una per ogni utente!
}
}
}
Tento "nevinný" dotaz vytváří 11 databázových dotazů: 1 pro uživatele + 1 pro každého uživatele za jeho rozkazy. Při 10 uživatelích to je 11 dotazů. Při 100 uživatelích (pokud limit zvýšíte) je jich 101. S 1000 uživateli na stránce správce? 1001 dotazů. Počet dotazů je N (uživatelů) + 1 — od zde název "N+1 problém".
// SENZA DataLoader: N+1 in azione
const User_orders = async (user, _, { db }) => {
// Questa riga viene chiamata UNA VOLTA PER OGNI UTENTE nella lista
return db.orders.findMany({ where: { userId: user.id } });
// Con 10 utenti = 10 query separate al database
// SELECT * FROM orders WHERE userId = '1'
// SELECT * FROM orders WHERE userId = '2'
// ... (10 volte!)
};
// SENZA DataLoader: prodotti per ogni item di ogni ordine
const OrderItem_product = async (item, _, { db }) => {
return db.products.findUnique({ where: { id: item.productId } });
// Se hai 10 utenti x 5 ordini x 3 items = 150 query solo per i prodotti!
};
DataLoader: Řešení problému N+1
DataLoader (vytvořený Facebookem, nyní spravovaný komunitou) řeší Problém N+1 se dvěma mechanismy: dávkování (seskupuje více požadavků do jednoho jediný dotaz) e ukládání do mezipaměti (znovu použít výsledky pro stejný klíč):
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { DatabaseClient } from './db';
export function createLoaders(db: DatabaseClient) {
return {
// Batch function: riceve un ARRAY di chiavi, ritorna un ARRAY di risultati
// L'ordine dei risultati DEVE corrispondere all'ordine delle chiavi
userById: new DataLoader<string, User | null>(
async (userIds) => {
// UNA SOLA QUERY per tutti gli ID! (invece di N query)
const users = await db.users.findMany({
where: { id: { in: [...userIds] } }
});
// Mappa risultati mantenendo l'ordine originale delle chiavi
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? null);
}
),
ordersByUserId: new DataLoader<string, Order[]>(
async (userIds) => {
// UNA SOLA QUERY invece di N query
const orders = await db.orders.findMany({
where: { userId: { in: [...userIds] } }
});
// Raggruppa per userId
const ordersByUser = new Map<string, Order[]>();
for (const order of orders) {
const existing = ordersByUser.get(order.userId) ?? [];
ordersByUser.set(order.userId, [...existing, order]);
}
return userIds.map(id => ordersByUser.get(id) ?? []);
},
{
// Ottimizzazione: raggruppa le chiavi duplicate
cacheKeyFn: (key) => key,
// Massimo 100 chiavi per batch (evita query enormi)
maxBatchSize: 100
}
),
productById: new DataLoader<string, Product | null>(
async (productIds) => {
const products = await db.products.findMany({
where: { id: { in: [...productIds] } }
});
const productMap = new Map(products.map(p => [p.id, p]));
return productIds.map(id => productMap.get(id) ?? null);
},
{
// Cache per tutta la durata della request (default)
cache: true
}
)
};
}
// Come funziona DataLoader internamente:
// 1. Il resolver chiama loader.load('userId-1') e loader.load('userId-2')
// 2. DataLoader aspetta il "tick" successivo di JavaScript
// 3. Raggruppa tutte le chiamate .load() accumulate in quel tick
// 4. Chiama la batch function UNA SOLA VOLTA con ['userId-1', 'userId-2']
// 5. Distribuisce i risultati ai chiamanti originali
S DataLoaderem: Dotaz vytváří 3 dotazy místo 111
// Query: 10 utenti con 5 ordini ciascuno, ogni ordine con 3 prodotti
// SENZA DataLoader: 1 + 10 + 50 + 150 = 211 query database
// SENZA DataLoader con 100 utenti: 1 + 100 + 500 + 1500 = 2101 query!
// CON DataLoader:
// 1 query: SELECT * FROM users LIMIT 10
// 1 query: SELECT * FROM orders WHERE userId IN (1,2,3,...,10)
// 1 query: SELECT * FROM products WHERE id IN (product-id-1, ..., product-id-15)
// = 3 query totali, indipendentemente da quanti utenti/ordini/prodotti!
Dokončete nastavení serveru Apollo pomocí TypeScript
// src/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers';
import { createLoaders } from './loaders';
import { GraphQLContext } from './types';
import { db } from './db';
import { authenticateRequest } from './auth';
const typeDefs = readFileSync('./schema.graphql', 'utf8');
const server = new ApolloServer<GraphQLContext>({
typeDefs,
resolvers,
// Plugin per logging e monitoring
plugins: [
// Log ogni richiesta in production
{
requestDidStart: async () => ({
didResolveOperation: async ({ request, document }) => {
console.log('GraphQL operation:', request.operationName);
}
})
}
],
// Limiti di sicurezza
introspection: process.env.NODE_ENV !== 'production'
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// I DataLoader devono essere creati PER REQUEST (non globali!)
// Ogni request ha il suo scope di caching
return {
db,
currentUser: await authenticateRequest(req.headers.authorization),
loaders: createLoaders(db)
// IMPORTANTE: crea nuovi loaders per ogni request
// (altrimenti il cache persiste tra requests e puo causare
// problemi di autorizzazione)
};
}
});
console.log(`GraphQL server running at: ${url}`);
Hloubkové omezení a analýza složitosti
GraphQL umožňuje klientům provádět libovolně hluboké nebo složité dotazy, které lze použít pro útoky DoS. Je důležité implementovat ochranu:
// Protezione con graphql-depth-limit e graphql-query-complexity
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Massima profondita della query: previene query tipo user.orders.items.product.vendor.orders.items...
depthLimit(7),
// Massima complessita calcolata
createComplexityLimitRule(1000, {
onCost: (cost) => console.log('Query complexity:', cost),
formatErrorMessage: (cost) =>
`Query too complex (${cost}). Maximum complexity: 1000`
})
]
});
Doporučené postupy GraphQL
- Vždy používejte DataLoader pro vztahy: nikdy neprovádějte přímé databázové dotazy v resolverech typu (User.orders, Order.items atd.)
- Vytvořte DataLoader pro požadavky, nikoli pro globální: Aby se tomu zabránilo, musí ukládání do mezipaměti DataLoader vypršet na konci každého požadavku Problémy s autorizací mezi různými uživateli
- Implementujte hloubkové omezení a analýzu složitosti: Bez těchto kontrol je GraphQL zranitelný vůči útokům DoS prostřednictvím složitých dotazů
- Použít trvalé dotazy v produkci: předem uloží dotazy na straně klienta a pouze odešle hash, čímž zabrání dotazům libovolné ve výrobě
- Zakázat introspekci ve výrobě: introspekce odhaluje celé schéma potenciálním útočníkům
Závěry a další kroky
GraphQL je výkonný nástroj pro API, která potřebují sloužit klientům s heterogenními potřebami, ale problém N+1 je skutečný a ke správnému vyřešení vyžaduje DataLoader. API GraphQL ve výrobě bez DataLoaderu a bez omezení hloubky a technicky nejistý a neefektivní. Při správné konfiguraci nabízí GraphQL se serverem Apollo a vynikající vývojářské zkušenosti a konkurenční výkon s REST.
Další článek zkoumá Federace GraphQL: jako Apollo Router (psáno v Rustu) skládá více nezávislých podgrafů do jednotného supergrafu, což umožňuje autonomní týmy, které budou samostatně vyvíjet části systému.
Řada: Design API – porovnání REST, GraphQL, gRPC a tRPC
- Článek 1: Krajina API v roce 2026 – rozhodovací matice
- Článek 2: REST v roce 2026 – Best Practice, Versioning a Richardson Maturity Model
- Článek 3 (tento): GraphQL — Dotazovací jazyk, Resolver a problém N+1
- Článek 4: GraphQL Federation — Supergraph, Subgraph a Apollo Router
- Článek 5: gRPC – Protobuf, Performance a Service-to-Service Communication
- Článek 6: tRPC – typová bezpečnost end-to-end bez generování kódu
- Článek 7: Webhooky – vzory, zabezpečení a logika opakování
- Článek 8: Verze rozhraní API – Zásady URI, záhlaví a ukončení podpory
- Článek 9: Omezení a omezování rychlosti – Algoritmy a implementace
- Článek 10: Hybrid API Architecture – REST + tRPC + gRPC v roce 2026







