Intro
Node.js peut sembler trompeusement simple : un seul thread, beaucoup de vitesse. La réalité est un runtime bien défini avec des règles claires. Ce guide explique l'architecture, montre comment les données et le contrôle circulent dans une application, et met en lumière les points de défaillance et les compromis opérationnels. Vous obtiendrez un pattern complet et fonctionnel utilisant Express pour le HTTP, MongoDB pour le stockage, Redis pour le cache, Docker pour l'emballage, et un exemple minimal de GitLab CI/CD.
Ce que vous allez apprendre :
- Les composants cœur de Node.js et le fonctionnement de la boucle d'événements (event loop)
- Un modèle de couches propre pour les routes, contrôleurs, services, dépôts et clients
- Le flux de données, le flux de contrôle, et où placer les frontières
- Les points de défaillance à surveiller et comment les atténuer
- Un pilote local sûr et mesurable que vous pouvez lancer aujourd'hui
Fondamentaux de l'architecture
À haut niveau, Node.js est :
- Une exécution JavaScript mono-thread sur V8.
- De l'I/O événementielle sur libuv avec un petit pool de threads pour le système de fichiers, le DNS, la crypto, et certaine compression.
- Les microtâches Promise s'exécutent entre les tours de la boucle d'événements.
- Les flux (streams) et la contre-pression (backpressure) sont des citoyens de premier ordre pour une I/O efficace.
- Les Worker Threads peuvent isoler les tâches CPU-intensives.
- Plusieurs processus ou répliques de conteneurs assurent la montée en charge horizontale.
Éléments clés du runtime :
- V8 : compile et optimise le JavaScript.
- libuv : boucle d'événements, timers, phases poll, check, et close. Il gère aussi un pool de threads pour les appels système bloquants.
- Boucle d'événements (event loop) : planifie les rappels (callbacks) quand l'I/O se termine. La garder libre de JavaScript long.
- Microtâches : mises en file par les Promises ; elles s'exécutent avant le prochain tour de macrotâche. Éviter les boucles affamées en ne chaînant pas d'énormes files de microtâches.
- APIs Node : http, net, tls, fs, stream, worker_threads, cluster, process, buffer.
Règles pratiques :
- Ne jamais bloquer la boucle d'événements avec du travail CPU-lourd (ex. : bcrypt à 12 tours ~100 ms, gros JSON.parse, redimensionnement d'image). Utiliser worker_threads ou un service séparé.
- Définir des délais d'attente (timeouts) sur chaque appel sortant (HTTP, BD, cache) et sur le serveur.
- Utiliser les flux et la contre-pression pour les gros payloads.
- Journaliser en JSON structuré et émettre des métriques.
Flux de données et de contrôle
Un chemin de requête typique dans un service Node.js bien structuré :
- Entrée : client -> proxy inverse (ex. : Nginx) -> processus Node.
- Routage : Express matche une route et appelle un contrôleur.
- Contrôleur : valide l'entrée, appelle un service, mappe les erreurs vers HTTP.
- Service : applique les règles métier, compose les dépôts et les caches.
- Dépôt (repository) : exécute les requêtes BD et retourne des objets simples.
- Cache : court-circuite les lectures et protège la BD.
- Réponse : le contrôleur envoie le résultat, souvent en JSON ; pour les grosses réponses, utiliser les flux.
Le flux de contrôle est asynchrone. L'I/O ne bloque pas le JavaScript ; les rappels ou await reprennent quand c'est prêt. Le travail CPU-lourd doit être délégué pour garder la latence stable — un seul appel bcrypt de 200 ms sur la boucle d'événements bloque toute autre requête pendant cette durée.
Architecture d'exemple pratique
Nous allons construire une API de lecture d'utilisateur minimale avec cache.
Arborescence des dossiers :
app/
src/
server.js
routes/userRoutes.js
controllers/userController.js
services/userService.js
repos/userRepo.js
clients/mongo.js
clients/redis.js
middleware/errors.js
util/http.js
package.json
server.js
const http = require('http');
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const { errorHandler } = require('./middleware/errors');
const app = express();
app.use(express.json());
app.get('/healthz', (req, res) => res.json({ ok: true }));
app.use('/v1/users', userRoutes);
app.use(errorHandler);
const server = http.createServer(app);
// Délais sensés pour éviter slowloris et sockets bloqués
server.keepAliveTimeout = 65000; // ms
server.headersTimeout = 67000; // ms
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(JSON.stringify({ level: 'info', msg: `listening on :${port}` }));
});
routes/userRoutes.js
const express = require('express');
const { getUserById } = require('../controllers/userController');
const router = express.Router();
router.get('/:id', getUserById);
module.exports = router;
controllers/userController.js
const { findUser } = require('../services/userService');
async function getUserById(req, res, next) {
try {
const id = req.params.id;
if (!id || id.length < 3) {
return res.status(400).json({ error: 'invalid id' });
}
const user = await findUser(id);
if (!user) return res.status(404).json({ error: 'not found' });
return res.json(user);
} catch (err) {
return next(err);
}
}
module.exports = { getUserById };
services/userService.js
const { getUserById } = require('../repos/userRepo');
const cache = require('../clients/redis');
const USER_TTL_SECONDS = 60;
async function findUser(id) {
const cacheKey = `user:${id}`;
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await getUserById(id);
if (user) {
await cache.set(cacheKey, JSON.stringify(user), { EX: USER_TTL_SECONDS });
}
return user;
}
module.exports = { findUser };
repos/userRepo.js
const { getDb } = require('../clients/mongo');
async function getUserById(id) {
const db = await getDb();
return db.collection('users').findOne({ _id: id }, { projection: { _id: 1, name: 1, email: 1 } });
}
module.exports = { getUserById };
clients/mongo.js
const { MongoClient } = require('mongodb');
const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/app';
const client = new MongoClient(uri, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 3000
});
let db;
async function getDb() {
if (!db) {
await client.connect();
db = client.db();
}
return db;
}
// En production, ajouter client.on('error') et logique de retry pour les problèmes réseau transitoires
module.exports = { getDb };
clients/redis.js
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
client.on('error', (err) => {
console.log(JSON.stringify({ level: 'error', msg: 'redis error', err: String(err) }));
});
let initPromise;
async function ensureReady() {
if (!initPromise) initPromise = client.connect();
return initPromise;
}
module.exports = {
async get(key) { await ensureReady(); return client.get(key); },
async set(key, value, opts) { await ensureReady(); return client.set(key, value, opts); }
};
middleware/errors.js
function errorHandler(err, req, res, next) { // eslint-disable-line
const status = err.statusCode || 500;
const payload = { error: status === 500 ? 'internal error' : err.message };
console.log(JSON.stringify({ level: 'error', msg: 'request error', status, err: String(err) }));
res.status(status).json(payload);
}
module.exports = { errorHandler };
Délégation du travail CPU-intensif
Si vous devez faire des tâches CPU lourdes, utilisez worker_threads :
// workers/hash.js
const { parentPort, workerData } = require('worker_threads');
const crypto = require('crypto');
const result = crypto.pbkdf2Sync(workerData.pwd, 'salt', 100000, 64, 'sha512').toString('hex');
parentPort.postMessage(result);
// dans un contrôleur ou service
const { Worker } = require('worker_threads');
function hashPassword(pwd) {
return new Promise((resolve, reject) => {
const w = new Worker(require.resolve('./workers/hash'), { workerData: { pwd } });
w.once('message', resolve);
w.once('error', reject);
w.once('exit', (code) => { if (code !== 0) reject(new Error('worker exit ' + code)); });
});
}
Pour céder la boucle d'événements dans une longue boucle synchrone, insérez await new Promise(r => setImmediate(r)) tous les quelques milliers d'itérations.
Aperçu du flux de travail
Un flux de travail clair réduit le retravail et aligne le code avec les opérations :
- Modéliser l'API et les frontières de données. Définir les contrats de routes et les formes d'erreurs.
- Implémenter les couches avec de petites fonctions testables. Garder les contrôleurs minces.
- Ajouter les garde-fous d'abord : timeouts serveur et clients, validation d'entrée, journalisation JSON.
- Ajouter le cache où les lectures dominent. Choisir des TTL conservateurs et valider les clés de cache.
- Conteneuriser. Utiliser une image de base petite et un utilisateur non-root.
- Automatiser tests et builds avec GitLab CI/CD.
- Déployer avec plusieurs répliques derrière un proxy inverse. Utiliser des sondes de disponibilité (readiness probes).
- Observer et ajuster : surveiller la latence p95, la saturation, les taux d'erreur, et la mémoire.
Exemple de Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S nodejs && adduser -S node -G nodejs
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "src/server.js" ]
.gitlab-ci.yml minimal
stages: [test, build]
node: test:
image: node:20-alpine
stage: test
script:
- npm ci
- npm test -- --ci
docker: build:
image: gcr.io/kaniko-project/executor:latest
stage: build
script:
- /kaniko/executor --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" --context "$CI_PROJECT_DIR" --dockerfile Dockerfile
only:
- main
Plan de pilote local
Commencez petit pour pouvoir mesurer et itérer rapidement.
Périmètre :
- Un endpoint : GET /v1/users/:id
- Données dans la collection users de MongoDB
- Cache Redis avec TTL de 60 s
- Vérification de santé : GET /healthz
Environnement avec Docker Compose
version: '3.9'
services:
app:
build: .
ports: ["3000:3000" ]
environment:
- MONGO_URI=mongodb://mongo:27017/app
- REDIS_URL=redis://redis:6379
depends_on: [mongo, redis]
mongo:
image: mongo:7
ports: ["27017:27017" ]
redis:
image: redis:7-alpine
ports: ["6379:6379" ]
Données de test (à exécuter une fois)
mongosh mongodb://localhost:27017/app --eval "db.users.insertOne({ _id: 'u1', name: 'Ada', email: '[email protected]' })"
Critères d'acceptation
- Exactitude : retourne 200 avec l'utilisateur pour un id valide, 404 sinon.
- Latence : p95 < 50 ms pour les lectures en cache localement, < 150 ms pour les lectures à froid.
- Stabilité : pas de unhandledRejection, pas de uncaughtException.
- Ressources : mémoire stable sous charge légère.
Mesure avec autocannon
npx autocannon -c 50 -d 20 http://localhost:3000/v1/users/u1
Attendre un p95 autour de 30–40 ms sur les lectures en cache. Si le p95 dépasse 100 ms, vérifier les indexes manquants, la latence Redis, ou le blocage de la boucle d'événements.
Inspecter le taux de succès du cache (cache hit rate) en journalisant quand une lecture de cache retourne ou définit une valeur. Augmenter le TTL si la charge BD est haute et les données stables ; diminuer le TTL si la fraîcheur est une préoccupation.
Opérations et compromis
Points de défaillance courants et atténuations :
- Blocage de la boucle d'événements : Éviter crypto synchrone, zlib, ou grosses opérations JSON sur les chemins chauds. Déléguer à worker_threads. Utiliser
setImmediatedans les longues boucles pour céder. - Timeouts manquants : Définir server headersTimeout et keepAliveTimeout ; définir les timeouts clients sur Mongo et Redis.
- Concurrence non bornée : Utiliser un contrôle style p-limit pour les tâches lourdes ou mettre le travail en file.
- Contre-pression : Utiliser stream.pipeline pour les uploads/downloads de fichiers. Ne pas bufferiser les payloads entiers en mémoire.
- Tempêtes de cache (cache stampedes) : Utiliser la coalescence de requêtes et des TTL avec gigue (jitter) pour réduire les effets de troupeau.
- Gestion d'erreurs : Convertir les erreurs métier en 4xx ; traiter les erreurs inattendues comme 5xx. Journaliser avec des IDs de corrélation si disponibles.
- Arrêt (shutdown) : Gérer SIGTERM, arrêter d'accepter de nouvelles connexions, et attendre que les requêtes en vol se terminent.
Compromis :
- Processus unique vs répliques : Les répliques ajoutent résilience et parallélisme. Préférer les conteneurs ou processus multiples derrière un proxy plutôt que le cluster in-process si vous orchestrez déjà des conteneurs.
- Worker threads vs services séparés : Les threads réduisent la latence pour les courtes tâches CPU ; les services isolent les pannes et permettent une mise à l'échelle indépendante pour les charges lourdes.
- Stratégie de cache : Le read-through est simple ; le write-through ou write-behind améliorent la cohérence ou le débit selon les besoins.
Liste de contrôle rapide :
- [ ] Timeouts sur le serveur et tous les clients
- [ ] Endpoints santé, disponibilité (readiness), et vivacité (liveness)
- [ ] Logs structurés et métriques de base
- [ ] Contre-pression sur les flux
- [ ] Gestionnaires d'arrêt sûrs
Conclusion
Vous avez maintenant un plan pratique pour l'architecture Node.js :
- Comprendre la boucle d'événements et la garder libre de travail CPU-lourd.
- Structurer le code en routes, contrôleurs, services, dépôts, et clients.
- Mettre les timeouts, la journalisation, et le cache dès le départ.
- Emballer avec Docker et automatiser tests et builds avec GitLab CI/CD.
- Commencer avec un pilote local étroit, mesurer la latence p95 et l'utilisation des ressources, puis itérer.
Adoptez ces patterns de façon incrémentale. Gardez les préoccupations séparées pour réduire le retravail, construisez d'abord le pilote minimal, et étendez seulement quand les données montrent que vous en avez besoin.