GraphQL: język zapytań, narzędzie do rozwiązywania problemów i problem N+1
GraphQL rozwiązuje jeden z podstawowych problemów API REST: klient nie ma nad nim kontroli
jakie dane otrzymuje. Z ODPOCZYNKIEM, GET /users/123 zawsze zwraca wszystkie pola użytkownika,
nawet jeśli klient potrzebuje tylko dwóch. Aby przesłać pełny profil użytkownika z zamówieniami
najnowsze i wyprodukowane, często wymagają 3-4 oddzielnych wywołań HTTP. GraphQL eliminuje ten problem
umożliwiając klientowi dokładne określenie danych potrzebnych w pojedynczym zapytaniu.
Ale GraphQL wprowadza własną złożoność: Problem N+1 i ryzyko bardziej podstępna architektura, zdolna do przekształcenia zapytania, które wydaje się nieszkodliwe, w zapytanie lawina dziesiątek lub setek zapytań do baz danych. W tym przewodniku wyjaśniono, jak to działa system rozpoznawania nazw, jak objawia się problem N+1 i w jaki sposób Moduł ładowania danych rozwiązuje ten problem dzięki automatycznemu przetwarzaniu wsadowemu i buforowaniu.
Czego się nauczysz
- Schemat GraphQL: typy, zapytania, mutacje i subskrypcje
- Pętla rozwiązywania: Jak GraphQL wykonuje zapytanie
- Resolver: Funkcje dostarczające dane dla każdego pola
- Problem N+1: jak się objawia i dlaczego jest niebezpieczny
- DataLoader: przetwarzanie wsadowe i buforowanie w celu rozwiązania N+1
- Zakończ konfigurację z serwerem Apollo i TypeScript
Schemat GraphQL: kontrakt wpisany
Każde API GraphQL zaczyna się od schematu: kontraktu definiującego wszystkie dostępne typy, zapytania (odczyty), mutacje (zapisy) i subskrypcje (strumienie w czasie rzeczywistym). Schemat jest napisany w języku SDL (język definicji schematu):
// 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
}
Pętla rozdzielczości: jak GraphQL wykonuje zapytanie
Kiedy klient wysyła zapytanie GraphQL, serwer wykonuje je w procesie zwanym „rozwiązanie”, które przemierza schemat jak drzewo, wywołując funkcję rozwiązania dla każdego wymaganego pola:
// 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
Zaimplementuj programy tłumaczące z serwerem Apollo
Funkcja rozpoznawania nazw to funkcja, która wie, jak pobrać dane dla określonego pola. Każdy mechanizm rozpoznawania nazw otrzymuje cztery argumenty: rodzic (obiekt elementu nadrzędnego) i argumenty (argumenty zapytania), kontekst (współdzielone dane, takie jak baza danych i uwierzytelnianie) oraz informacje (metadane zapytania):
// 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' }
});
}
}
};
Problem N+1: Ukryte niebezpieczeństwo
Problem N+1 jest najczęstszym ryzykiem architektonicznym w GraphQL. Wyobraź sobie to zapytanie:
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!
}
}
}
To „niewinne” zapytanie generuje 11 zapytań do bazy danych: 1 dla użytkowników + 1 dla każdego użytkownika dla jego rozkazów. Przy 10 użytkownikach to 11 zapytań. Przy 100 użytkownikach (jeśli zwiększysz limit) jest 101. Z 1000 użytkownikami na stronie administracyjnej? 1001 zapytań. Liczba zapytań to N (użytkownicy) + 1 — od tutaj nazwa „problem N+1”.
// 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: rozwiązanie problemu N+1
Moduł ładowania danych (stworzony przez Facebooka, obecnie utrzymywany przez społeczność) rozwiązuje problem Problem N+1 z dwoma mechanizmami: dozowanie (grupuje wiele żądań w jedno pojedyncze zapytanie) e buforowanie (użyj ponownie wyników dla tego samego klucza):
// 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
Z DataLoaderem: zapytanie generuje 3 zapytania zamiast 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!
Zakończ konfigurację serwera Apollo za 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}`);
Ograniczanie głębokości i analiza złożoności
GraphQL umożliwia klientom tworzenie dowolnie głębokich lub złożonych zapytań, które można wykorzystać do ataków DoS. Ważne jest wdrożenie zabezpieczeń:
// 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`
})
]
});
Najlepsze praktyki GraphQL
- Zawsze używaj DataLoader dla relacji: nigdy nie wykonuj bezpośrednich zapytań do bazy danych w programach rozpoznawania typu (User.orders, Order.items itp.)
- Utwórz DataLoader dla żądań, a nie globali: Aby tego uniknąć, buforowanie DataLoader musi wygasnąć na końcu każdego żądania Problemy z autoryzacją pomiędzy różnymi użytkownikami
- Wdrażaj analizę ograniczania głębokości i złożoności: Bez tych kontroli GraphQL jest podatny na ataki DoS za pośrednictwem złożonych zapytań
- Użyj utrwalonych zapytań w środowisku produkcyjnym: zapisuje z wyprzedzeniem zapytania po stronie klienta i wysyła tylko skrót, zapobiegając zapytaniom dowolne w produkcji
- Wyłącz introspekcję w produkcji: introspekcja naraża cały schemat na potencjalnych napastników
Wnioski i dalsze kroki
GraphQL to potężne narzędzie dla interfejsów API, które muszą obsługiwać klientów o heterogenicznych potrzebach, ale problem N+1 jest prawdziwy i wymaga poprawnego rozwiązania modułu DataLoader. Interfejs API GraphQL w produkcji bez DataLoadera i bez ograniczenia głębokości i niepewny technicznie i nieefektywne. Przy prawidłowej konfiguracji GraphQL z Apollo Server oferuje: doskonałe doświadczenie programistyczne i konkurencyjna wydajność dzięki REST.
Następny artykuł eksploruje Federacja GraphQL: jak router Apollo (napisany w języku Rust) składa wiele niezależnych podgrafów w ujednolicony supergraf, umożliwiając autonomiczne zespoły, które będą samodzielnie opracowywać części programu.
Seria: Projektowanie API — porównanie REST, GraphQL, gRPC i tRPC
- Artykuł 1: Krajobraz API w 2026 r. – matryca decyzyjna
- Artykuł 2: REST w 2026 r. – najlepsze praktyki, wersjonowanie i model dojrzałości Richardsona
- Artykuł 3 (ten): GraphQL — język zapytań, narzędzie do rozwiązywania problemów i problem N+1
- Artykuł 4: Federacja GraphQL — Supergraph, Subgraph i Router Apollo
- Artykuł 5: gRPC — Protobuf, wydajność i komunikacja między usługami
- Artykuł 6: tRPC — kompleksowe bezpieczeństwo typu bez generowania kodu
- Artykuł 7: Elementy webhook — wzorce, zabezpieczenia i logika ponownych prób
- Artykuł 8: Wersjonowanie API – URI, nagłówki i zasady wycofywania
- Artykuł 9: Ograniczanie i dławienie szybkości — algorytmy i implementacje
- Artykuł 10: Architektura hybrydowego API – REST + tRPC + gRPC w 2026 r







