Tanquem el mòdul amb la pregunta que va deixar oberta la lliçó anterior. La pantalla d'un esdeveniment a l'aplicació d'Escena Viva necessita l'esdeveniment, les seves sessions i les dades de la sala. Amb l'API REST, això són tres crides encadenades, o una resposta amb ?incloure=sessions,sala que retorna molt més del que la pantalla fa servir. Cap de les dues opcions no és dolenta; simplement, REST administra aquest dilema en comptes d'eliminar-lo. GraphQL l'elimina, i en cobra un preu. Aquesta lliçó explica què resol, quin preu té, com es munta sobre l'aplicació Express que ja existeix, i —el més important— quan no l'hauries de fer servir.
Contingut
- El problema: sobrecàrrega i infracàrrega de dades
- REST davant de GraphQL: comparació honesta
- L'esquema com a contracte
- Resolutors: com es resol un arbre
- Muntar el servidor sobre Express
- Context i autorització
- El problema N+1 i DataLoader
- Límits obligatoris: l'API sense límits és un DoS
- Errors a GraphQL
- Subscripcions, i quan triar cada cosa
- El problema: sobrecàrrega i infracàrrega de dades
Dos símptomes amb nom propi. La infracàrrega (under-fetching): la resposta no porta tot el que cal i s'han d'encadenar GET /api/v1/esdeveniments/evt-003, després /sessions, després /sales/org-ribera. Tres viatges d'anada i tornada que, en una connexió mòbil de 120 ms de latència, són 360 ms només de xarxa abans que el servidor faci res; i el segon no pot començar fins que acaba el primer, perquè necessita l'identificador. La sobrecàrrega (over-fetching): la resposta porta de sobres. El llistat del catàleg retorna per cada esdeveniment el títol, la descripció llarga, les polítiques de devolució, les etiquetes, les imatges en quatre mides i les dades de l'organitzador; la targeta de la pantalla d'inici fa servir tres camps. La resta són bytes que se serialitzen (CPU, lliçó 10-04), es transmeten i es descarten. Amb GraphQL, el client escriu el que vol i rep exactament això, en una sola petició i amb la forma exacta de la consulta:
query PantallaDEsdeveniment {
esdeveniment(id: "evt-003") {
titol dataInici
sala { nom ciutat }
sessions { id inici aforamentDisponible preuBaseCentims }
}
}
- REST davant de GraphQL: comparació honesta
| Aspecte | REST | GraphQL |
|---|---|---|
| Forma de la resposta i nre. de peticions | La decideix el servidor; una per recurs | La decideix el client; una per pantalla |
| Memòria cau HTTP | Nativa: Cache-Control, ETag, 304, proxies, CDN |
Gairebé nul·la: tot és POST /graphql |
| Complexitat del servidor | Baixa | Alta: resolutors, DataLoader, límits de cost |
| Versionat | Explícit (/v1, /v2) |
Evolutiu: s'afegeixen camps i es marquen @deprecated |
| Codis d'estat | Semàntics i rics | Sempre 200; els errors van a errors |
| Pujada de fitxers i corba d'aprenentatge | multipart/form-data; baixa, és HTTP |
Especificació a part; mitjana-alta |
| Eines | curl, Postman, qualsevol proxy |
GraphiQL, Apollo Studio, tipatge per al client |
| Monitoratge i límits | Per ruta i estat, de franc; paginació | Per operació, a mà; profunditat i complexitat obligatòries |
| Encaixa bé amb | APIs públiques, memòries cau, recursos clars | Clients variats (web, mòbil, TV), grafs de dades |
Dues caselles mereixen èmfasi. La memòria cau HTTP és la pèrdua més greu: a la lliçó 10-05 vam encadenar tres capes (navegador, ETag/304, Redis) i les dues primeres desapareixen gairebé del tot, perquè totes les consultes van per POST a la mateixa URL; se'n pot recuperar part amb consultes persistides i GET, però és feina extra. I els codis d'estat: perdre la semàntica de 404, 409 o 429 té conseqüències a tot el teu monitoratge, com veurem a l'apartat 9. El missatge clar, i no és un tòpic: GraphQL no substitueix REST, hi coexisteix. A Escena Viva mantenim /api/v1/* per a les integracions de tercers, les descàrregues de PDF i els webhooks de la passarel·la de pagament, i afegim /graphql per a les pantalles de l'aplicació, on la flexibilitat compensa.
- L'esquema com a contracte
L'esquema s'escriu en SDL (Schema Definition Language) i és el contracte: defineix què existeix, quin tipus té i què es pot demanar. És tipat, obligatori i verificable.
"Un esdeveniment cultural programat en una sala."
type Esdeveniment {
id: ID! titol: String! descripcio: String
estat: EstatEsdeveniment! dataInici: DataHora! preuBaseCentims: Int!
sala: Sala!
sessions(nomesDisponibles: Boolean = false): [Sessio!]!
}
type Sessio {
id: ID! esdeveniment: Esdeveniment! inici: DataHora!
aforamentTotal: Int! entradesVenudes: Int! aforamentDisponible: Int! exhaurida: Boolean!
}
type Sala { id: ID! nom: String! ciutat: String! esdeveniments: [Esdeveniment!]! }La sintaxi de tipus és la part que més confusió genera al principi:
| Notació | Significat |
|---|---|
String / String! |
Cadena que pot ser null / que mai no ho és |
[Sessio] |
Llista que pot ser null, amb elements que poden ser null |
[Sessio!]! |
Llista que mai no és null, amb elements que mai no són null |
[Sessio!]! és el que gairebé sempre vols per a una col·lecció: si no hi ha sessions, retorna [], no null. I un advertiment pràctic sobre !: si un camp declarat String! resol a null, GraphQL propaga l'error cap amunt anul·lant l'objecte sencer, i fins i tot la consulta completa si la cadena de ! arriba fins a l'arrel. Marca ! només on la garantia sigui real.
"Data i hora en ISO 8601 amb zona horaria."
scalar DataHora
enum EstatEsdeveniment { ESBORRANY PUBLICAT EXHAURIT CANCELLAT }
enum RolUsuari { ASSISTENT ORGANITZADOR ADMINISTRADOR }
enum EstatComanda { PENDENT PAGAT ANULLAT }
"Tot el que pot apareixer en una cerca global."
interface Resultat { id: ID! titol: String! }
type Usuari { id: ID! nom: String! correu: String! rol: RolUsuari! comandes: [Comanda!]! }
type Entrada { id: ID! codi: String! butaca: String preuCentims: Int! }
type Comanda {
id: ID! usuari: Usuari! sessio: Sessio! estat: EstatComanda!
totalCentims: Int! creatEl: DataHora! entrades: [Entrada!]!
}Els escalars personalitzats com DataHora no són decoració: porten funcions de serialització i validació, de manera que una data invàlida es rebutja a la vora de l'esquema, igual que feia zod a REST (M6). I els tres tipus arrel són les tres portes d'entrada:
type Query {
esdeveniment(id: ID!): Esdeveniment
esdeveniments(estat: EstatEsdeveniment, limit: Int = 20, cursor: String): ConnexioEsdeveniments!
sessio(id: ID!): Sessio
elMeuUsuari: Usuari
laMevaComanda(id: ID!): Comanda
}
type Mutation {
crearComanda(entrada: EntradaCrearComanda!): ResultatComanda!
anullarComanda(comandaId: ID!, motiu: String!): ResultatComanda!
publicarEsdeveniment(esdevenimentId: ID!): Esdeveniment!
}
type Subscription { aforamentActualitzat(sessioId: ID!): Sessio! }
"Els arguments complexos fan servir tipus d'entrada, no tipus d'objecte."
input EntradaCrearComanda { sessioId: ID! quantitat: Int! clauIdempotencia: String! }
type ResultatComanda { comanda: Comanda errorDeNegoci: ErrorDeNegoci }
type ErrorDeNegoci { codi: String! missatge: String! detalls: [String!]! }
type ConnexioEsdeveniments { nodes: [Esdeveniment!]! cursorFinal: String hiHaMesPagines: Boolean! }Fixa't en ResultatComanda: els errors de negoci previsibles (aforament insuficient, sessió cancel·lada) es modelen com a part de l'esquema, no com a excepcions; hi tornarem a l'apartat 9. I a clauIdempotencia: la lliçó 10-05 no es queda fora per canviar de protocol, només canvia de lloc.
- Resolutors: com es resol un arbre
Un resolutor és la funció que produeix el valor d'un camp, amb una signatura de quatre arguments: pare (el valor retornat pel resolutor del nivell anterior), parametres (els del camp), context (compartit per tota la petició: usuari, repositoris, carregadors) i info (metadades de la consulta: quins camps es demanen, ruta a l'arbre). GraphQL resol en amplada, nivell a nivell: primer esdeveniment, després tots els seus camps, després els de sala i sessions, i així cap avall. Si no defineixes un resolutor per a un camp, s'aplica el resolutor per defecte —cercar una propietat amb aquell nom al pare—, i per això titol o dataInici no necessiten codi.
'use strict';
const { GraphQLError } = require('graphql');
// Factories amb dependencies injectades (mateixa disciplina del M9) que
// reutilitzen els MATEIXOS repositoris del M7: la capa de dades no es duplica.
const exigirUsuari = (context) => {
if (context.usuari) return;
throw new GraphQLError('Autenticacio requerida',
{ extensions: { codi: 'NO_AUTENTICAT', estat: 401 } });
};
const crearResolutors = ({ repositoris }) => ({
Query: {
esdeveniment: (pare, { id }, context) => context.carregadors.esdeveniment.load(id),
esdeveniments: (pare, { estat, limit, cursor }) => repositoris.esdeveniments.obtenirCataleg(
{ estat, cursor, limit: Math.min(limit, 50) }), // sostre del servidor
elMeuUsuari: (pare, parametres, context) =>
context.usuari ? repositoris.usuaris.obtenirPerId(context.usuari.id) : null,
laMevaComanda: async (pare, { id }, context) => {
exigirUsuari(context);
const comanda = await repositoris.comandes.obtenirPerId(id);
// Mateixa politica del M8, comprovada aqui i no en una ruta.
return comanda && comanda.usuariId === context.usuari.id ? comanda : null;
},
},
// Resolutors de camp: 'pare' es l'Esdeveniment ja resolt.
Esdeveniment: {
sala: (esdeveniment, parametres, context) => context.carregadors.sala.load(esdeveniment.salaId),
sessions: (esdeveniment, { nomesDisponibles }, context) =>
context.carregadors.sessionsPerEsdeveniment.load(esdeveniment.id)
.then((s) => (nomesDisponibles ? s.filter((x) => x.aforamentDisponible > 0) : s)),
},
Sessio: {
// Camps calculats: no existeixen a la BD, es deriven del domini.
aforamentDisponible: (sessio) => sessio.aforamentTotal - sessio.entradesVenudes,
exhaurida: (sessio) => sessio.entradesVenudes >= sessio.aforamentTotal,
esdeveniment: (sessio, parametres, context) => context.carregadors.esdeveniment.load(sessio.esdevenimentId),
},
Mutation: {
crearComanda: async (pare, { entrada }, context) => {
exigirUsuari(context);
try {
// El MATEIX repositori transaccional del M7, amb SELECT FOR UPDATE.
const comanda = await repositoris.compres.comprarEntrades({
usuariId: context.usuari.id, sessioId: entrada.sessioId,
quantitat: entrada.quantitat, clauIdempotencia: entrada.clauIdempotencia,
});
return { comanda, errorDeNegoci: null };
} catch (error) {
// Error esperat: forma part de l'esquema, no es una excepcio.
if (error.codi !== 'AFORAMENT_INSUFICIENT') throw error;
return { comanda: null, errorDeNegoci: { codi: error.codi,
missatge: error.message, detalls: error.detalls || [] } };
}
},
},
});
module.exports = { crearResolutors };Aquesta és la recompensa d'haver aïllat la capa de dades al mòdul 7: els repositoris són exactament els mateixos. La lògica de negoci, les transaccions amb SELECT ... FOR UPDATE, les regles d'aforament, l'autorització pura de src/autoritzacio/politica.js: tot es reutilitza. GraphQL és una façana diferent sobre el mateix nucli, i si el teu domini estigués enredat amb Express aquest pas seria inviable.
- Muntar el servidor sobre Express
Amb npm install graphql @apollo/server @as-integrations/express5 dataloader graphql-depth-limit:
'use strict';
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@as-integrations/express5');
const depthLimit = require('graphql-depth-limit');
const { tipusDefinits } = require('./esquema.js');
const { crearResolutors } = require('./resolutors.js');
const { crearCarregadors } = require('./carregadors.js');
const { crearContext } = require('./context.js');
const { configuracio } = require('../config/index.js');
async function muntarGraphql({ aplicacio, repositoris }) {
const servidor = new ApolloServer({
typeDefs: tipusDefinits,
resolvers: crearResolutors({ repositoris }),
introspection: configuracio.entorn !== 'produccio', // veure apartat 8
validationRules: [depthLimit(8)],
formatError: (formatat, original) => { // mai filtrar detalls (M8)
if (formatat.extensions && formatat.extensions.codi) return formatat;
console.error('[graphql] error no controlat', original);
return { message: 'Error intern', extensions: { codi: 'ERROR_INTERN', estat: 500 } };
},
});
await servidor.start();
// Conviu amb l'API REST: /api/v1/* continua exactament igual.
aplicacio.use('/graphql', expressMiddleware(servidor, {
context: async ({ req }) => crearContext({ peticio: req, repositoris, crearCarregadors }),
}));
return servidor;
}
module.exports = { muntarGraphql };S'integra a crearAplicacio() (M6) com una ruta més, i els middlewares que ja teníem —id-peticio.js, registre-http.js, cors.js, helmet, limits.js— continuen aplicant-se perquè estan muntats abans. El limitador és especialment important aquí, però no n'hi ha prou: una sola consulta GraphQL pot ser tan cara com deu mil peticions REST, així que limitar per nombre de peticions és limitar la mètrica equivocada. Ho arreglem a l'apartat 8. graphql-http és l'alternativa minimalista si no vols Apollo: implementa l'especificació de transport i poca cosa més, mentre que Apollo aporta memòria cau del pla de consulta, mètriques, connectors i consultes persistides.
- Context i autorització
El context es construeix una vegada per petició i és on s'injecta tot el que els resolutors necessiten:
'use strict';
const { verificarTokenDAcces } = require('../serveis/tokens.js');
async function crearContext({ peticio, repositoris, crearCarregadors }) {
let usuari = null;
const capcalera = peticio.get('authorization');
if (capcalera && capcalera.startsWith('Bearer ')) {
// Mateix servei de tokens del M8 (JWT HS256, 15 min). Token
// invalid: es tracta com a anonim.
try { usuari = await verificarTokenDAcces(capcalera.slice(7)); } catch { usuari = null; }
}
// Carregadors NOUS a cada peticio: la seva cache no ha de sobreviure ni
// creuar-se entre usuaris. Es correccio i seguretat, no optimitzacio.
return {
usuari, repositoris, idPeticio: peticio.idPeticio,
carregadors: crearCarregadors({ repositoris }),
};
}
module.exports = { crearContext };I aquí arriba el que és delicat. A REST l'autorització es munta a la ruta (router.post('/esdeveniments', autenticar, exigirRol('organitzador'), ...)), i si oblides el middleware es nota, perquè la ruta és una unitat visible. A GraphQL no hi ha rutes: hi ha un únic punt d'entrada i un graf pel qual el client navega lliurement, així que un camp desprotegit en qualsevol racó de l'esquema és accessible des de qualsevol consulta que hi arribi. Una consulta aparentment innocent que demana esdeveniments { sala { esdeveniments { ... } } } pot acabar arribant a Comanda.usuari i, si aquell resolutor no comprova res, llegir el correu de Lucía. L'autorització es comprova a cada resolutor que exposa dades sensibles, no a l'entrada.
'use strict';
const { GraphQLError } = require('graphql');
const { pot } = require('../autoritzacio/permisos.js');
// Embolcall que aplica una politica abans de resoldre, reutilitzant les
// funcions pures del M8: la politica es una de sola per a REST i GraphQL.
const ambPermis = (accio, resolutor) => (pare, parametres, context, info) => {
if (!context.usuari || !pot(context.usuari, accio, { pare, parametres })) {
throw new GraphQLError('No tens permis per a aquesta operacio',
{ extensions: { codi: 'PROHIBIT', estat: 403 } });
}
return resolutor(pare, parametres, context, info);
};
// Els camps sensibles es declaren protegits de manera explicita.
const resolutorsUsuari = { Usuari: {
correu: ambPermis('llegir-correu-usuari', (usuari) => usuari.correu),
comandes: ambPermis('llegir-comandes-usuari', (usuari, parametres, context) =>
context.repositoris.comandes.llistarPerUsuari(usuari.id)),
} };
module.exports = { ambPermis, resolutorsUsuari };Regla defensiva: per defecte, denegar. Existeixen biblioteques de directives (@auth(requereix: ORGANITZADOR)) que permeten declarar-ho al mateix SDL, cosa que és més difícil d'oblidar que un embolcall al resolutor.
- El problema N+1 i DataLoader
El problema N+1 del mòdul 7 apareix a REST, però a GraphQL és estructuralment pitjor, perquè el client tria la forma de la consulta i el pot provocar sense saber-ho. Una consulta que demana esdeveniments { nodes { titol sala { nom } sessions { id } } } amb 3 esdeveniments són 7 consultes: 1 del catàleg, 3 de sales i 3 de sessions. Amb 100 esdeveniments serien 201, i el client no ha fet res estrany: ha demanat les dades que necessita. A REST podies optimitzar el punt d'entrada concret; aquí no saps per endavant quina combinació demanaran. DataLoader ho resol amb dos mecanismes: lots, acumulant totes les crides a .load(id) que passen al mateix tick del bucle d'esdeveniments per fer una sola crida amb tots els identificadors; i memòria cau per petició, de manera que el mateix id no es consulta dues vegades.
'use strict';
const DataLoader = require('dataloader');
// CRITIC: retornar un array de la MATEIXA mida i en el MATEIX ordre que els
// ids rebuts; si en falta un, es retorna null al seu lloc.
const perClau = (registres, ids) => {
const index = new Map(registres.map((r) => [r.id, r]));
return ids.map((id) => index.get(id) || null);
};
// Un joc de carregadors NOU per peticio (veure crearContext). Cadascun
// fa una sola consulta amb WHERE id IN (...).
const crearCarregadors = ({ repositoris }) => ({
esdeveniment: new DataLoader(async (ids) =>
perClau(await repositoris.esdeveniments.obtenirPerIds(ids), ids)),
sala: new DataLoader(async (ids) =>
perClau(await repositoris.sales.obtenirPerIds(ids), ids)),
// Carregador d'un-a-molts: retorna un array per cada clau.
sessionsPerEsdeveniment: new DataLoader(async (esdevenimentIds) => {
const sessions = await repositoris.sessions.llistarPerEsdeveniments(esdevenimentIds);
const perEsdeveniment = new Map(esdevenimentIds.map((id) => [id, []]));
for (const sessio of sessions) perEsdeveniment.get(sessio.esdevenimentId).push(sessio);
return esdevenimentIds.map((id) => perEsdeveniment.get(id)); // array buit si no n'hi ha
}),
});
module.exports = { crearCarregadors };Mesura sobre el catàleg d'Escena Viva amb la consulta anterior:
| Mètrica | Sense DataLoader | Amb DataLoader |
|---|---|---|
| Consultes a PostgreSQL (3 esdeveniments) | 7 | 3 |
| Latència p50 / p99 (3 esdeveniments) | 84 ms / 240 ms | 21 ms / 46 ms |
| Consultes / p99 amb 100 esdeveniments | 201 / 3 100 ms | 3 / 78 ms |
La xifra de 201 a 3 amb 100 esdeveniments és la que cal retenir: DataLoader converteix un problema que creix linealment amb les dades en un de constant. A GraphQL no és una optimització opcional, és un requisit d'arquitectura. Dos advertiments: els carregadors s'han de crear per petició, perquè si els crees a l'arrencada la seva memòria cau sobreviu entre peticions i usuaris diferents, servint dades obsoletes i, pitjor, dades d'un altre usuari; i l'ordre i la mida de l'array retornat han de coincidir exactament amb les claus rebudes, que és l'error d'implementació més freqüent i produeix dades creuades entre entitats de manera silenciosa.
- Límits obligatoris: l'API sense límits és un DoS
Una API GraphQL sense límits és un atac de denegació de servei esperant a passar, i no cal ser sofisticat: n'hi ha prou d'imbricar esdeveniments { nodes { sala { esdeveniments { nodes { sala { ... } } } } } } una desena de nivells. Cada nivell multiplica la feina, així que amb relacions circulars (Esdeveniment → Sala → Esdeveniment) una consulta de 200 bytes pot generar milions de resolucions i exhaurir la memòria del procés. És una fallada de la categoria API4 (Consum de recursos sense restricció) de l'OWASP API Security Top 10 que vam veure al mòdul 8.
| Defensa | Com | Valor raonable |
|---|---|---|
| Profunditat màxima | graphql-depth-limit |
7-10 nivells |
| Complexitat i paginació | graphql-query-complexity; limit amb sostre |
1 000 punts; màxim 50-100 |
| Temps límit i mida del cos | AbortSignal; express.json({ limit }) |
5-10 s; 16 KB a /graphql |
| Consultes permeses i introspecció | Llista blanca de persistides; introspection: false |
Clients propis; en producció |
| Limitador de peticions | limits.js amb Redis (10-03) |
Per usuari, no només per IP |
'use strict';
const { GraphQLError } = require('graphql');
const { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } =
require('graphql-query-complexity');
// El cost es calcula ABANS d'executar res: si l'excedeix, es rebutja.
const reglaDeComplexitat = (maxim = 1000) => (context) => createComplexityRule({
maximumComplexity: maxim,
variables: context.request.variables,
// fieldExtensionsEstimator llegeix el cost declarat a cada camp de
// l'esquema; simpleEstimator assigna 1 punt als que no el declaren.
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
createError: (permes, actual) => new GraphQLError(
`La consulta es massa complexa: ${actual} punts (maxim ${permes}).`,
{ extensions: { codi: 'CONSULTA_MASSA_COMPLEXA', estat: 400 } }),
});
module.exports = { reglaDeComplexitat };Sobre la introspecció: és la capacitat de preguntar al servidor pel seu propi esquema, i és el que fa funcionar GraphiQL i les eines de generació de tipus. En producció lliura a qualsevol el mapa complet de la teva API, inclosos els camps que encara no has anunciat i les mutacions d'administració: desactiva-la i publica l'esquema pels canals que tu controlis. No és seguretat per obscuritat —els límits i l'autorització continuen sent la teva defensa real— però no hi ha cap raó per regalar el mapa. I les consultes persistides són la defensa definitiva quan l'únic client és teu: el client envia un hash en comptes de la consulta i el servidor només executa les de la seva llista aprovada, cosa que elimina d'un cop les bombes de profunditat i complexitat.
- Errors a GraphQL
A GraphQL tot respon 200 OK (llevat d'errors de transport o de sintaxi, que poden donar 400). Els errors viatgen al cos, i hi ha una cosa que a REST no existeix: les respostes parcials.
{
"data": { "esdeveniment": { "titol": "Festival de Jazz de Primavera", "sala": null } },
"errors": [{ "message": "No tens permis per a aquesta operacio",
"path": ["esdeveniment", "sala"],
"extensions": { "codi": "PROHIBIT", "estat": 403, "idPeticio": "f1c2..." } }]
}data.esdeveniment.titol és vàlid i data.esdeveniment.sala és null amb el seu error associat. És potent i obliga el client a comprovar errors sempre, fins i tot quan hi ha dades. Les conseqüències són concretes:
| Conseqüència | Què implica |
|---|---|
| El client no pot confiar en el codi HTTP | Cal inspeccionar errors a cada resposta |
| Els reintents automàtics no s'activen | Un 200 amb error no dispara cap política de reintent |
| El monitoratge menteix | La teva taxa de 5xx serà del 0 % amb el sistema cremant |
| Proxies i alertes per codi d'estat no serveixen | Poden desar un error a la memòria cau; cal instrumentar per extensions.codi |
La lliçó 10-04 va posar la taxa d'errors entre les quatre senyals d'or; amb GraphQL cal produir aquesta senyal a mà, amb un connector d'Apollo que a willSendResponse registri el nom de l'operació, la seva durada i els extensions.codi de cada error. Sense això, el teu panell del mòdul 11 mostrarà 0 % d'errors per sempre. I per això l'esquema distingia dues classes d'error. Els de negoci previsibles (aforament insuficient) viatgen a ResultatComanda.errorDeNegoci: són part del contracte, estan tipats i el client els gestiona amb el compilador del seu costat. Els inesperats (base de dades caiguda) van a errors. Aquesta separació —de vegades anomenada "errors com a dades"— és una de les millors pràctiques de l'ecosistema, i evita que el client hagi d'endevinar llegint cadenes de text.
- Subscripcions, i quan triar cada cosa
Subscription permet que el servidor empenyi dades cap al client sobre una connexió persistent (WebSocket, normalment amb graphql-ws), i a Escena Viva el cas natural és l'aforament durant l'estrena del Festival de Jazz: amb subscription { aforamentActualitzat(sessioId: "ses-003-1") { aforamentDisponible exhaurida } }, el comptador baixa en viu mentre la gent compra, alimentat pels esdeveniments venda-registrada i aforament-baix que el GestorDeVendes ja emet des del mòdul 2, publicats a través de Redis (10-03) perquè arribin als set treballadors.
Ho deixem anunciat: la comunicació en temps real, amb WebSockets, sales i difusió de missatges, es treballa de debò al mòdul 12 amb Socket.IO i el projecte de xat, on els problemes —connexions amb estat, escalat entre processos, reconnexió— es resolen en profunditat. El criteri final, que és el que t'ha de quedar:
| Situació | Tria |
|---|---|
| API pública per a tercers, contingut cacheable, fitxers i webhooks | REST: memòria cau HTTP amb ETag i CDN, codis d'estat, eines universals |
| Aplicació pròpia amb moltes pantalles, clients heterogenis o dades en graf | GraphQL |
| Equip petit, terminis curts, domini senzill | REST: menys peces que puguin fallar |
| Producte madur amb clients externs i aplicació pròpia | Tots dos, sobre el mateix domini |
Escena Viva es queda a l'última fila, i s'ho pot permetre per l'arquitectura construïda al llarg del curs: domini pur, repositoris aïllats, polítiques d'autorització com a funcions pures. Sobre aquest nucli, REST i GraphQL són dues façanes; si el negoci fos dins dels controladors d'Express, mantenir-les totes dues seria duplicar la lògica i garantir que un dia divergeixen.
Errors Comuns i Consells
- Creure que GraphQL substitueix REST. Són eines amb compromisos diferents; la majoria de sistemes madurs fan servir totes dues. I oblidar DataLoader: sense ell, qualsevol consulta imbricada és un N+1.
- Carregadors compartits entre peticions, o que retornen un array de mida o ordre diferent: el primer creua dades entre usuaris, el segon creua dades entre entitats. Tots dos, en silenci.
- Autoritzar només a l'arrel. El graf es navega des de qualsevol lloc: es comprova a cada resolutor sensible.
- Publicar sense límits de profunditat i complexitat, o deixar la introspecció activa en producció: el primer és un DoS de 200 bytes, el segon regala el mapa de la teva API. I monitorar per codi HTTP no serveix: amb GraphQL sempre és 200, així que instrumenta per
extensions.codi. - Consell: comença amb un esquema petit sobre els teus repositoris existents, i modela el domini, no les teves taules: un esquema que reflecteix la base de dades una a una sol ser mal disseny.
- Consell: marca els camps que retires amb
@deprecated(reason: "...")i mesura'n l'ús abans d'eliminar-los; és l'equivalent de la capçaleraSunsetde la 10-05.
Exercicis
Exercici 1: esquema i resolutors del catàleg
Defineix l'esquema d'Esdeveniment, Sessio i Sala amb els seus camps calculats (aforamentDisponible, exhaurida) i munta /graphql sobre crearAplicacio() reutilitzant els repositoris del M7, sense duplicar lògica. Comprova que l'API REST continua funcionant i escriu una prova d'integració amb supertest que compari el resultat de la consulta GraphQL amb el de GET /api/v1/esdeveniments/evt-003.
Exercici 2: mesurar i eliminar el N+1
Instrumenta el repositori per comptar consultes SQL per petició. Executa la consulta que demana 3 esdeveniments amb la seva sala i les seves sessions, i anota el nombre de consultes sense DataLoader. Implementa els tres carregadors i torna a mesurar. Repeteix-ho amb 100 esdeveniments sembrats.
Exercici 3: bomba de consulta i defensa
Escriu una consulta amb 12 nivells d'imbricació aprofitant la relació circular Esdeveniment → Sala → Esdeveniment. Mesura el temps de resposta i el retard del bucle d'esdeveniments (10-04) sense límits. Aplica depthLimit(8) i una regla de complexitat de 1 000 punts, i verifica que la consulta es rebutja abans d'executar-se.
Solucions
Exercici 1. La clau és no escriure lògica nova: Sessio.aforamentDisponible és sessio.aforamentTotal - sessio.entradesVenudes, la mateixa fórmula que ja viu a src/domini/sessio.js, i el correcte és cridar aquella funció, no reescriure-la. La prova compara camp a camp el resultat de peticio.get('/api/v1/esdeveniments/evt-003') amb el de peticio.post('/graphql').send({ query: '{ esdeveniment(id: "evt-003") { titol sala { nom } } }' }), comprovant a més que body.errors és undefined. Que tots dos coincideixin demostra el que buscàvem: dues façanes sobre un domini. Si divergeixen, hi ha lògica duplicada en algun lloc i això és deute tècnic des del primer dia.
Exercici 2. Amb 3 esdeveniments i sense carregadors, el comptador dona 7 consultes: 1 del catàleg, 3 de sales, 3 de sessions. Amb els carregadors baixa a 3: catàleg, sales WHERE id IN (...) i sessions WHERE esdevenimentId IN (...). Amb 100 esdeveniments sembrats la diferència es torna dramàtica —201 davant de 3, i el p99 passa de ~3,1 s a ~78 ms—, però l'important no és el factor de millora sinó la forma de la corba: sense DataLoader el cost creix amb el nombre d'elements i amb ell és constant. Detall que sorprèn molta gent: els carregadors poden agrupar perquè GraphQL resol en amplada, així que totes les crides a .load() d'un mateix nivell passen al mateix tick del bucle d'esdeveniments —exactament el mecanisme de microtasques del mòdul 2— i DataLoader les recull totes abans de consultar.
Exercici 3. Sense límits, la consulta de 12 nivells amb relació circular triga entre 8 i 40 segons segons les dades sembrades, i el retard del bucle d'esdeveniments supera els 3 000 ms: el procés queda inservible per a tots els altres usuaris, exactament el símptoma de la lliçó 10-02 però provocat des de fora amb una cadena de text de 200 bytes. Amb depthLimit(8) la consulta es rebutja durant la fase de validació, abans d'executar un sol resolutor, amb Query exceeds maximum operation depth of 8 i en menys de 2 ms. La regla de complexitat captura a més el cas que la profunditat no veu: una consulta plana però amb limit: 10000 en diverses llistes. Totes dues són necessàries, i cap no substitueix l'autorització: els límits protegeixen la disponibilitat, no la confidencialitat.
Conclusió
Escena Viva arriba al final del mòdul 10 sent un sistema diferent del que va començar. Fa servir tots els nuclis de la màquina amb cluster, amb supervisió, recàrrega sense talls i la lliçó apresa que l'estat en memòria no sobreviu a diversos processos. No bloqueja el bucle d'esdeveniments: la generació dels QR i els PDF viu en un pool de fils de treball, i el retard del bucle va baixar de 4 176 ms a 11 ms. Desa a la memòria cau el catàleg a Redis amb el patró cache-aside, claus versionades i invalidació en vendre, passant de 31 050 consultes a PostgreSQL a 7. Encua la feina pesada amb BullMQ, respon 202 Accepted i protegeix la idempotència al productor i al consumidor. Està mesurada: amb proves de càrrega honestes, perfils de CPU, gràfics de flama, instantànies del munt i el retard del bucle com a mètrica de salut, i amb un ordre clar de palanques —algorisme, consulta, memòria cau, concurrència, maquinari—. Té una API ben dissenyada, amb recursos coherents, mètodes amb les seves garanties, claus d'idempotència, codis d'estat amb criteri, paginació per cursor, versionat amb política de deprecació, memòria cau HTTP amb ETag i If-Match, i documentació OpenAPI generada des dels esquemes. I ara té també una alternativa GraphQL que conviu amb REST sobre el mateix domini, amb DataLoader contra el N+1 i límits de profunditat i complexitat per no ser una denegació de servei esperant a passar.
És un sistema que aguanta l'estrena del Festival de Jazz de Primavera. I tanmateix, tot això continua corrent al teu portàtil.
Els secrets són en un .env local que no pots compartir amb ningú sense enviar-lo per un canal insegur. Els registres es perden quan tanques la terminal, i amb set treballadors escrivint alhora, ni tan sols es llegeixen bé. No hi ha ningú que reiniciï el procés si mor a les quatre de la matinada: el src/cluster.js que vam escriure supervisa els seus treballadors, però ningú no supervisa el primari. No hi ha un contenidor que garanteixi que la versió de Node, de PostgreSQL i de Redis sigui la mateixa a la teva màquina i al servidor. I no hi ha desplegament: pujar una versió nova és una seqüència d'ordres manuals que només tu coneixes i que un dia faràs malament a les dues de la matinada.
Al Mòdul 11, Desplegament i DevOps, tanquem aquesta distància: configuració i variables d'entorn gestionades com cal, registre i monitoratge en producció perquè les mètriques que hem après a produir arribin a algun lloc on algú se les miri, PM2 per supervisar i executar en mode cluster el que aquí vam implementar a mà, empaquetatge amb Docker perquè l'entorn viatgi amb l'aplicació, desplegament a Heroku i altres PaaS, i una canonada d'integració i desplegament continus que converteixi pujar una versió en una cosa avorrida. Que és, al capdavall, el major elogi que es pot fer a un desplegament.
Curs de Node.js: De Principiant a Avançat
Mòdul 1: Introducció a Node.js
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
