Accueil / Articles

De script de développement à API Node.js en production : boucle d’événements, threads de travail, journalisation

Comment le cycle d’événements, le travail bloquant, les threads de travail, les délais d’expiration, la concurrence, les journaux structurés et l’accès à la base de données façonnent une API Node.js capable de fonctionner en production.

Éditeur : REACTAPP.TOP11 min de lecture2621 mots

Sources: The Node.js Runtime Is the Easy Part

Obtenir que une API Node.js réponde est rarement la partie difficile. Le point de terminaison renvoie des données, l’interface utilisateur les affiche, et le jeu de tests passe. La véritable difficulté commence en production, lorsque des milliers de requêtes arrivent en même temps, que la base de données ralentit, qu’une API tierce cesse de répondre, que la mémoire augmente progressivement, ou qu’une requête qui met normalement 200 millisecondes met soudainement 12 secondes. Il y a aussi une question plus subtile : un autre développeur comprendra-t-il ce code dans six mois ?

Ces questions trouvent leurs réponses dans le comportement du moteur d’exécution ainsi que dans quelques habitudes de conception. Vous verrez ci-dessous où le modèle d’exécution de Node.js excelle, où il échoue, et quels patterns permettent à un service de rester réactif face à un trafic réel : le transfert des tâches CPU vers des threads de travail, les délais d’expiration, les appels réseau simultanés, un enregistrement structuré des événements et des requêtes méticuleuses.

En quoi le moteur d’exécution est-il réellement efficace

On dit généralement que Node.js permet à JavaScript de s’exécuter en dehors du navigateur. C’est vrai, mais cela omet l’aspect le plus intéressant. Ce que Node.js offre réellement, c’est un environnement d’exécution basé sur les événements avec des opérations I/O asynchrones, ainsi qu’un écosystème très vaste autour de lui. Cette conception s’avère particulièrement utile lorsque un serveur passe la majeure partie de son temps à attendre quelque chose d’autre :

  • des requêtes vers une base de données
  • des appels HTTP sortants
  • la lecture et l’écriture de fichiers
  • le trafic réseau en général
  • des files d’attente de messages
  • des connexions WebSocket à long terme

Suivez une seule requête à travers un backend typique :

Client
  ↓
API Server
  ↓
Database
  ↓
External API
  ↓
Response

À presque aucun moment de cette chaîne, le serveur n’est pas occupé à calculer. Il attend principalement la base de données, puis l’API externe. Le modèle Node.js est conçu pour utiliser ce temps d’inactivité pour traiter d’autres requêtes, et comprendre cela est plus précieux que n’importe quelle API en particulier.

Comment le cycle d’événements change votre modèle mental

JavaScript dans une application Node.js s’exécute normalement sur un seul thread principal, tandis que le cycle d’événements coordonne les opérations asynchrones autour de celui-ci. Une représentation simplifiée du cycle de vie d’une requête :

Incoming Request
       ↓
   JavaScript
       ↓
Start I/O operation
       ↓
Don't block execution
       ↓
   Event Loop
       ↓
I/O completes
       ↓
Callback / Promise
       ↓
Continue execution

Voici à quoi cela ressemble dans du code ordinaire :

const user = await database.getUser(userId);
const orders = await database.getOrders(userId);
return {
  user,
  orders
};

Le await pause cette fonction, mais pas le processus. Tandis que la base de données traite la première requête, le moteur de exécution peut gérer d’autres appels de retour et d’autres requêtes. C’est pourquoi un seul processus Node.js peut gérer un grand nombre de requêtes qui attendent principalement en réseau.

Une nuance à noter : les deux requêtes ci-dessus s’exécutent l’une après l’autre, car le deuxième await ne commence qu’après que le premier ait abouti. Nous reviendrons sur ce point dans la section consacrée à la concurrence.

Asynchrone ne signifie pas rapide

Ce modèle présente une limite stricte. Si votre propre code JavaScript effectue un calcul long, rien d’autre sur cette thread ne peut s’exécuter jusqu’à ce qu’il soit terminé. Une boucle comme celle-ci maintient la thread occupée pendant toute sa durée :

function expensiveOperation() {
  let result = 0;
for (let i = 0; i < 10_000_000_000; i++) {
    result += i;
  }
  return result;
}

Pendant qu’il s’exécute, les requêtes entrantes ne sont pas acceptées, les promesses ne se résolvent pas, et les compteurs temporels ne déclenchent pas à l’heure prévue. Il n’existe aucun planificateur capable d’interrompre la boucle pour laisser la parole à d’autres requêtes. C’est la raison fondamentale pour laquelle comprendre le comportement du moteur d’exécution est plus important que de mémoriser ses API.

Le problème du gestionnaire bloquant

Placez maintenant ce type de tâche à l’intérieur d’un gestionnaire HTTP. La redimensionnement d’images, les transformations de gros volumes de données ou les calculs intensifs effectués directement dans le chemin de la requête semblent inoffensifs dans le code :

app.post("/process", async (req, res) => {
  const result = expensiveComputation(req.body);
res.json(result);
});

Le problème ne se manifeste qu’en situation de charge. Une seule requête déclenche la fonction coûteuse et monopolise le boucle d’événements. Toutes les autres requêtes sont mises en file d’attente derrière elle. La latence augmente globalement, pas seulement pour l’endpoint gourmand, et le processus d’application devient lui-même un goulot d’étranglement. Marquer le gestionnaire async ne sert à rien, car le calcul à l’intérieur reste synchronique.

Une conception plus solide déplace complètement les tâches gourmandes en CPU en dehors du parcours de la requête :

Client
  ↓
Node.js API
  ↓
Queue
  ↓
Worker
  ↓
Heavy computation
  ↓
Database / Storage

L’API accepte la tâche, la place dans une file d’attente et répond rapidement, tandis que des travailleurs dédiés traitent les tâches à leur propre rythme et enregistrent les résultats dans une base de données ou un système de stockage d’objets. L’API reste réactive, et vous pouvez échaler les travailleurs indépendamment de la couche web. Ce changement, qui consiste à passer de l’écriture de gestionnaires à la conception du flux de travail, est en grande partie ce qui distingue la simple maîtrise de Node.js de la création de systèmes avec ce langage.

Quand ne pas utiliser de file d’attente

Une file d’attente ajoute des aspects d’infrastructure, des tentatives de réexécution et des considérations liées à la cohérence éventuelle à prendre en compte. Pour des tâches courtes nécessitant un traitement par le CPU qui doivent retourner un résultat de manière synchrone, les threads de travail de Node conservent le travail en mémoire mais en dehors du thread principal. Pour des tâches qui ne durent que quelques millisecondes, il est souvent suffisant de les exécuter directement. La décision dépend de la durée du traitement et du fait que le client doive attendre la réponse.

Express est un framework, pas l’environnement d’exécution

De nombreux développeurs confondent les frontières entre Node.js et Express. Node.js est l’environnement d’exécution. Express est un framework web construit sur son socle. Vous pouvez servir du HTTP rien qu’avec le module intégré http :

const http = require("http");
const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "application/json"
  });
  res.end(JSON.stringify({
    message: "Hello"
  }));
});
server.listen(3000);

Cela fonctionne, mais le routage, le middleware, l’analyse du corps des requêtes, la validation et la gestion des erreurs deviennent tous votre responsabilité à mesure que l’application grandit. Les frameworks existent pour alléger cette charge. La même idée en Express se présente comme ceci :

import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
  const user = await getUser(req.params.id);
  res.json(user);
});
app.listen(3000);

Express ne remplace rien dans Node.js ; il offre une abstraction plus pratique sur les mêmes primitives. Maintenir cette distinction claire est utile lors du débogage : un point d’entrée lent peut être un problème lié au framework, un problème lié au temps d’exécution, ou les deux à la fois.

Le code de production concerne principalement les limites

Un petit service peut exister dans un seul fichier. Un système de production ne peut pas en faire autant, car différentes responsabilités nécessitent des séparations claires entre elles. Un schéma raisonnable :

src/
├── api/
│   ├── routes/
│   ├── controllers/
│   └── middleware/
│
├── services/
│   └── userService.js
│
├── repositories/
│   └── userRepository.js
│
├── config/
│   └── environment.js
│
├── jobs/
│   └── emailWorker.js
│
└── app.js

Les noms spécifiques des dossiers ne sont pas importants. Ce qui compte, c’est que les responsabilités restent séparées :

  • Un contrôleur ne devrait pas accumuler des centaines de lignes de règles métier.
  • Un repository ne devrait rien savoir sur la manière dont les réponses HTTP sont formatées.
  • Un service ne devrait pas prendre en entrée les objets req ou res d’Express.

Ces séparations permettent au code d’être testable de manière isolée, de pouvoir être remplacé progressivement et d’être compris par la personne qui le lit ensuite. Pour en savoir plus sur cette structure, consultez notre guide sur la conception en couches d’une API Node.js.

Concevoir pour les pannes dans le code asynchrone

Chaque développeur Node.js apprend tôt cette règle :

const data = await fetchData();

La production soulève des questions plus difficiles à ce sujet. Que se passe-t-il si fetchData() met 30 secondes ? Et s’il lance une exception ? Et s’il la lance trois fois de suite ? Et si le service source est complètement down ? Et si une écriture dans la base de données réussit mais que l’étape suivante échoue, laissant le système partiellement mis à jour ?

Un code backend fiable définit le comportement dans ces cas plutôt que de ne décrire que le scénario idéal. Voici un petit exemple qui met explicitement en évidence les échecs :

async function getUserProfile(userId) {
  try {
    const user = await userService.getUser(userId);
if (!user) {
      throw new Error("User not found");
    }
    return user;
  } catch (error) {
    logger.error({
      userId,
      error: error.message
    });
    throw error;
  }
}

La fonction transforme l’absence d’utilisateur en erreur, enregistre les échecs avec le contexte nécessaire pour les enquêter, et les relance afin que l’appelant puisse décider de la manière de réagir. Le principe fondamental est que la gestion des échecs relève d’une décision de conception, et non d’un ajout effectué après un incident.

Une précaution pratique : si chaque couche enregistre les événements et les relance, un échec génère plusieurs lignes de journal. Déterminez où les erreurs doivent être enregistrées (généralement une seule fois, à la frontière qui les gère) et laissez les couches internes ajouter le contexte en lieu et place.

Ne jamais attendre indéfiniment : les délais d’expiration

Voici une appel à un autre service sans aucune protection :

const response = await fetch("https://example.com/api");

Si le serveur distant accepte la connexion mais ne répond jamais, cet await peut rester bloqué bien plus longtemps que tout utilisateur ne le tolérera, en occupant des ressources en permanence. Un système de production nécessite une limite supérieure explicite. Avec le AbortController standard, disponible dans les versions récentes de Node.js, il est possible d’en imposer une :

const controller = new AbortController();
const timeout = setTimeout(() => {
  controller.abort();
}, 5000);
try {
  const response = await fetch(
    "https://example.com/api",
    {
      signal: controller.signal
    }
  );
  return await response.json();
} finally {
  clearTimeout(timeout);
}

Le chronomètre annule la requête après cinq secondes, et le bloc finally le réinitialise afin qu’il ne s’active pas en cas de réponse réussie. Les environnements de exécution plus récents proposent également AbortSignal.timeout(ms), qui exprime la même idée en une seule expression.

Cinq secondes ne constituent pas un nombre magique et peuvent être bien trop généreuses pour une interface utilisateur. Choisissez une valeur en fonction du budget de latence de l’endpoint. Ce qui est incontournable, c’est que l’attente illimitée n’est pas une façon acceptable d’échouer.

Concurrence sans parallélisme

Lorsque deux opérations ne dépendent pas l’une de l’autre, les attendre l’une après l’autre est une perte de temps :

const user = await getUser();
const orders = await getOrders();

La deuxième appel ne commence même pas avant que la première ne soit terminée, donc la latence totale est la somme des deux. Il est généralement préférable de les lancer en même temps et d’attendre leur fin à toutes deux :

const [user, orders] = await Promise.all([
  getUser(),
  getOrders()
]);

Les deux requêtes sont actuellement en cours d’exécution simultanément, et le temps de latence total correspond approximativement à celui de la requête la plus lente. Il s’agit de concurrence et non de calcul parallèle : votre JavaScript s’exécute toujours sur un seul thread, mais les temps d’attente se chevauchent. La différence est particulièrement importante dans les points d’entrée qui se connectent à plusieurs services intermédiaires.

Deux cas limites à garder à l’esprit. Promise.all rejette la requête dès que l’un des éléments d’entrée échoue, il convient donc d’utiliser Promise.allSettled lorsque des résultats partiels sont acceptables. De plus, lancer des centaines d’appels en même temps peut surcharger un pool de bases de données ou une API soumise à des limitations de fréquence ; il faut donc limiter la concurrence lorsque la liste est longue.

La performance réside dans l’architecture

Il est courant de voir des équipes débattre de choix au niveau micro, tels que des solutions classiques

for

Comparaison avec boucle

forEach

alors que le même point de terminaison lance cinq requêtes inutiles à la base de données. Ou bien on observe un petit outil soigneusement ajusté, tandis que l’API renvoie des en-têtes JSON énormes, ou encore chaque requête attend 800 millisecondes en raison d’une requête lente.

Dans les services réels, les temps de réponse sont généralement déterminés par :

  • la manière d’accéder à la base de données
  • la latence réseau
  • des requêtes inefficaces ou non indexées
  • une sérialisation excessive
  • la dépendance à des API externes
  • des opérations qui n’ont absolument pas besoin d’être effectuées
  • la pression mémoire
  • un cache manquant ou mal conçu

Les gains les plus importants proviennent généralement de changements architecturaux, et non de la syntaxe. Si vous souhaitez une méthode structurée pour prioriser, consultez notre framework ordonné par priorité pour l’optimisation des performances des API Node.js.

L’observabilité fait partie du produit

Un serveur qui ne peut pas vous indiquer ce qu’il a fait est très difficile à gérer. La journalisation constitue la première étape, mais une ligne comme celle-ci est presque inutile en environnement de production :

console.log("Something went wrong");

Le journalisation structurée enregistre le nom de l’événement ainsi que les identifiants nécessaires pour mener une enquête :

logger.error({
  event: "payment_failed",
  orderId,
  userId,
  error: error.message
});

Puisque chaque entrée est un objet plutôt que du texte libre, les journaux peuvent être filtrés, agrégés et consultés : par exemple, tous les événements payment_failed d’un utilisateur.

Les journaux seuls laissent cependant des lacunes. Les systèmes de production s’appuient généralement sur trois signaux complémentaires :

Logs
  ↓
What happened?

Metrics
  ↓
How often / how much?

Traces
  ↓
Where did the request spend its time?

Les journaux vous indiquent ce qui s’est passé, les métriques vous montrent avec quelle fréquence et dans quelle mesure, tandis que les traces révèlent où une requête a passé son temps au sein des différents services. Lorsque quelqu’un signale que « l’application est lente », ces indicateurs vous permettent de répondre en vous appuyant sur des preuves plutôt que sur des suppositions.

Le runtime ne conçoit pas l’accès aux données

Le choix de Node.js ne détermine pas le type de base de données. Il fonctionne bien avec PostgreSQL, MySQL, MongoDB, Redis, SQLite, Elasticsearch et de nombreuses autres. Ce qui est bien plus important, c’est la manière dont le code interagit avec la base de données choisie. Ce point d’entrée semble tout à fait raisonnable :

app.get("/products", async (req, res) => {
  const products = await db.query(
    "SELECT * FROM products"
  );
res.json(products);
});

Dans un environnement de production, cela soulève immédiatement une série de questions :

  • Combien de lignes contient products aujourd’hui, et combien l’année prochaine ?
  • Y a-t-il une pagination ?
  • La requête est-elle prise en charge par un index ?
  • Cette réponse pourrait-elle atteindre 50 MB ?
  • Comment se comporte-t-elle face à un trafic concurrentiel ?
  • Le pool de connexions est-il dimensionné avec des limites raisonnables ?
  • Un erreur brute de la base de données pourrait-elle être transmise au client ?

Node.js ne répondra à aucune de ces questions à votre place. Ces décisions relèvent de celui qui conçoit le service.

Node.js en tant que composant parmi d’autres

Node.js est largement utilisé dans les environnements cloud en partie parce que son modèle de processus léger s’adapte bien aux conteneurs et aux architectures orientées services. Dans de tels systèmes, il s’agit généralement d’une composante parmi plusieurs : une couche API associée à des files d’attente, des travailleurs, des caches, des bases de données et d’autres services, chacun ayant sa propre responsabilité. Il n’est pas nécessaire qu’il constitue l’ensemble du système, et un véritable backend est rarement simplement « une API Express ».

Où Node.js a besoin d’aide

Aucun environnement d’exécution ne convient à tous les types de charges de travail. Node.js est le plus performant lorsque les opérations sont dominées par des entrées/sorties, tandis que les tâches gourmandes en CPU exigent une conception plus réfléchie. Exemples :

  • Le traitement d’images à grande échelle
  • La compression vidéo
  • Le calcul scientifique intensif
  • Certaines charges de travail de machine learning
  • Des opérations cryptographiques lourdes
  • Des transformations de données de très grande taille

Rien de tout cela n’exclut Node.js de ce type de systèmes. Cela signifie que le processus chargé de gérer les requêtes HTTP ne doit pas être celui qui effectue le travail lourd. Les options possibles sont :

  • des threads de travail au sein du même processus
  • des processus de travail en arrière-plan distincts
  • des files d’attente entre l’API et les travailleurs
  • des services dédiés à la partie coûteuse
  • d’autres langages ou environnements de exécution mieux adaptés au calcul

L’objectif n’est pas de forcer tous les problèmes à être résolus avec Node.js, mais de l’utiliser là où son modèle d’exécution convient.

Se demander « pourquoi », et non seulement « comment »

Au début, la question est de savoir comment construire une API avec Node.js. Avec de l’expérience, on se demande pourquoi l’API doit être construite d’une certaine manière, ce qui amène à un autre ensemble de questions :

  • Faut-il que cette opération soit synchrone, ou doit-elle passer par une file d’attente ?
  • Faut-il mettre en cache la réponse ?
  • Ce service possède-t-il ces données ?
  • Cet endpoint doit-il dépendre de trois autres services, et que se passe-t-il si l’un d’eux tombe en panne ?
  • Où placer les tentatives de réessai ?
  • Qu’est-ce qui doit être enregistré et qu’est-ce qui doit être mesuré ?
  • Comment se comportera-t-il sous charge ?

Aucun de ces points n’est spécifique à Node.js. Ce sont des questions d’ingénierie logicielle, et Node.js fournit les outils pour mettre en œuvre les solutions.

Le checklist de production en bref

  • La boucle d’événements rend Node.js excellent pour gérer simultanément les attentes réseau et disque, mais tout travail CPU synchrone bloque toutes les requêtes sur ce thread.
  • Éloignez les calculs lourds du flux des requêtes en utilisant des threads de travail, des files d’attente ou des services distincts.
  • Considérez les scénarios de défaillance et les délais d’expiration explicites comme faisant partie de la conception ; une attente infinie constitue une erreur.
  • Exécutez des appels en amont indépendants en parallèle à l’aide de Promise.all, et limitez la concurrence lorsque vous distribuez les tâches sur de larges échelles.
  • Les problèmes de performance proviennent généralement des requêtes, de la taille des données transmises et des sauts réseau, et non de la syntaxe des boucles.
  • Les journaux structurés, les métriques et les traces transforment les plaintes en environnement de production en éléments concrets pouvant servir de preuves.
  • Les frameworks et les environnements d’exécution ne sont que des outils ; l’architecture, les limites et l’accès aux données restent de la responsabilité du développeur.