Logo INGIN - société de développement informatique spécialisé dans la refonte de site internet
Bouton de scroll vers le haut
Partager cet article

JavaScript : Problème de performance sur vos API Node JS / Express ?

JavaScript : Astuces pour une API Node JS / Express performante !

Javascript : les bonnes pratiques pour une API performante avec Node JS et Express JS

  • Comprendre les notions de "synchrone" et "asynchrone" et les contraintes fortes qui en découlent (code bloquant VS non bloquant)
  • Gérer correctement les exceptions et envoyer une réponse au consommateur le plus tôt possible surtout en cas d'erreur
  • Répondre avec un code HTTP pertinent en cas de succès et/ou erreur.
  • Effectuer des tests de performances et des tests de charge

Kit de démarrage d'une API en Node JS / Express JS

Exemple de code "Quick Start" qui vous permettra de créer rapidement votre 1er service (endpoint)

Le serveur

// server/server.js
const express = require("express");
    
const PORT = process.env.PORT || 8080;

const app = express();

app.get("/", function(req, res) {
    res.send("Hello World!");
});

const server = app.listen(PORT, () => console.log(`Serveur is running on http://localhost:${PORT}`));

Une simple route en "GET" est testable très facilement à l'aide de votre navigateur préféré. Vous noterez en l'occurence que cette route renvoie la chaine de caractère "Hello World!". En revanche, pour tester une route avec un autre verbe HTTP, à savoir, DELETE, PUT, PATCH et POST, il faudra vous munir d'un outil genre Postman ou pour les plus courageux une commande "curl" :)

Ce n'est pas plus compliqué que ça. En revanche, vous comprendrez que plus vous exposerez de routes, plus votre code ressemblera à un plat de spaghettis :D C'est la raison pour laquelle vous devez séparer votre code en plusieurs petits modules. Voyons désormais un exemple clairement plus abouti que le précédent.

La gestion des routes par des modules séparés

Exemple de kit de démarrage rapide plus abouti - il ne manque plus grand chose pour livrer une telle API en production. On pourrait imaginer un ou deux middlewares supplémentaires comme :

  • Un validateur de payload d'entrée du genre "Joi" et/ou "Validator",
  • Un logger facilitant la lecture ou l'exploitation par des outils adéquats comme Morgan, Winston ou Pino
/src
  |__ index.js
  |__ routes.js
  |__ /routes
        |__ /news
              |__ /controllers
                    |__ index.js
              |__ /services
                    |__ index.js
              |__ index.js
  
// src/index.js
const express = require("express");
const compression = require("compression");
const cors = require("cors");
const helmet = require("helmet");

const router = require("./routes");

module.exports = () => {
  const app = express();

  app.use(helmet()); // middleware permettant l'ajout d'entête
  app.use(compression()); // permet la compression des réponses de l'API
  app.use(cors()); // autorise les appels multi domaine (cross domain)
  app.use(express.json()); // permet à l'API de répondre au format JSON
  app.use(express.urlencoded({ extended: false })); // permet d'encoder et d'exploiter le body dans le cas d'un POST

  router(app);
  
  return app;
};
// src/routes.js
const { Router } = require("express");

const newsRoutes = require("./src/news/routes");

module.exports = (app) => {
  const router = Router({ mergeParams: true });

  router.use("/news", newsRoutes);

  app.use("/", router);
};
// src/routes/news/index.js
const { getNews } = require("./controllers");
    
const router = Router();

router.get("/news", getNews);

module.exports = router;
// src/routes/news/controllers/index.js
const { getNews } = require("../services");

module.exports = async (req, res) => {
    try {
        const data = await getNews();

        if (!data) return res.status(404).json({error: "No documents found"});
        
        return res.json(data);
    
    } catch (e) {
        return res.status(500).json(e.message);
    }
};
// src/routes/news/services/index.js
const getNews = async () => axios.get(`http://api.externe.fr/news`);

module.exports = {
  getNews,
};

La gestion des erreurs

Pour être certain que toutes les erreurs seront attrapées (catch), la bonne pratique consiste à mettre un bloc "try / catch" directement au niveau du contrôleur. Ainsi, toutes les exceptions  seront gérées et vos utilisateurs n'attendront jamais en vain une réponse qui n'arrivera jamais ou seulement lorsque le délai aura expirée (30s) (response timeout)

const express = require("express");
    
const PORT = process.env.PORT || 8080;

const app = express();

app.get("/", function(req, res) {
    try {
        res.send("Hello World!");
    } catch(e) {
        console.log("Error", e.message);
        res.send("Désolé, une erreur est survenue");
    }
});

const server = app.listen(PORT, () => console.log(`Serveur is running on http://localhost:${PORT}`));

Le point d'entrée : le contrôleur

Le point d'entrée se fait directement dans la fonction de callback passée au niveau de la déclaration de la route, ici "app.get("/", myRouteController)". C'est cette fonction qui sera exécutée à chaque appel de votre service web (endpoint) - il faut donc que cette fonction soit la plus performante possible - et le plus important, il ne faut surtout pas bloquer l'unique thread de Node JS. Oui, vous trouverez certainement tout et son contraire mais à l'heure où j'écris cet article, Node JS est bel et bien "mono thread" - c'est-à-dire qu'il ne peut, en aucun cas, exécuter 2 lignes de code en même temps. Certe, Node JS est très rapide, mais il se contente d'exécuter une ligne après l'autre.

Pour aller plus loin sur ce sujet, je vous invite à vous documenter sur la librairie "libuv" écrite en C et que Node JS utilise pour abstraire certaines tâches longues et fastidieuses comme par exemple les accès au système de fichier (file system ou fs), la gestion réseau ou encore l'encodage de mot de passe avec un système de hash que la librairie "crypto" nous met à disposition.

Il faut savoir que la librairie "libuv" utilise 4 threads et est utilisée pour abstraire les opérations d'entrée/sortie. On pourrait donc affirmer, à tort, que par extension, Node JS est bel bien multi-thread mais encore une fois, c'est une erreur ! En effet, Node JS est mono thread et se contente de déléguer certaines tâches à la librairie qui elle est multi-thread. Je vous invite à faire le test par vous-même

const express = require("express");
    
const PORT = process.env.PORT || 8080;

const app = express();

app.get("/", function(req,res) {
    (async () => {
        try {
            res.send("Hello World!");
        } catch(e) {
            console.log("Error", e.message);
            res.send("Désolé, une erreur est survenue");
        }
    })();
});

const server = app.listen(PORT, () => console.log(`Serveur is running on http://localhost:${PORT}`));

Modularisation et programmation fonctionnelle

Comme vous pouvez le constater, la plupart des fichiers que je vous montre font une vingtaines de lignes tout au plus. En programmation fonctionnelle, il est très important de bien séparer les logiques et les responsabilités de chaque fonction et/ou module afin de réduire au maximum la complexité de vos programmes. Pour plus de détail sur le paradigme de programmation fonctionnelle, c'est par ici !

Astuce pour optimiser les performances de vos services web (endpoints)

Outil de profilage et de "stress test" de votre API Node JS

Clinic JS

Si dans votre carrière de développeur web, vous êtes passé par la case "Front end developper" vous devez sans doute connaître l'outil mis à disposition par Google dans son fameux navigateur : Chrome, à savoir "Chrome devtool" ou plus communément appelé la console Chrome disponible avec le raccourci "F12" pour les utilisateurs de PC ;)

L'onglet "Network" de cette console vous donne beaucoup d'informations sur les scripts qui sont chargés et qui tournent dans le navigateur comme par exemple le nom du script, le code HTTP de réponse, le type, l'initiateur du script, la taille et surtout, son temps d’exécution. Et d'un seul coup d'oeil, vous pouvez voir l'ordre de chargement des scripts et leur temps d'exécution grâce au "Waterfall" disponible sur ce même onglet (Network).

Imaginez avoir accès aux mêmes fonctionnalités mais pour du code exécuté côté serveur - Et bien, c'est ce que propose Clinic JS qui est un outil 3 en 1 de profilage de code. Dont un en particulier qui vous permettra de détecter des anomalies comme par exemple un CPU qui tourne à plein régime qui engendre une surcharge de l'event loop et qui a un impact direct sur le bon fonctionnement du "garbage collector".  Je parle de "Clinic Doctor" qui vous donnera des graphiques détaillé sur l'historique de charge de l'event loop ainsi que le garbage collector. En revanche, celui qui vous donnera le superbe waterfall, c'est "Clinic Flame".

Tests de performances VS tests de charge

Nombreux sont les développeurs à faire l'amalgame entre les 2 notions.

Tests de performances

Les test de performances vous donnent une indication sur la vitesse d'exécution d'un programme - les facteurs pouvant altérer les performances sont évidemment, le nombre de ligne de code et, pour ne pas viser une notion en particulier, je vais citer : le paradigme choisi : "Programmation Objet" ou "Programmation Fonctionnelle". En effet, selon l'expérience du développeur, la programmation orientée objet, ou encore programmation déclarative est très souvent une catastrophe. En effet, le langage JavaScript est tellement permissif qu'on peut littéralement lui faire faire n'importe quoi ! Je vous recommande donc d'adopter le paradigme de programmation fonctionnelle pour passer au niveau supérieur et ainsi augmenter drastiquement la qualité de votre code.

Ce genre de test vous donne donc le temps moyen d'exécution de votre fonction "controller" qui représente exactement le temps d'attente d'un utilisateur pour recevoir une réponse de votre API.

Tests de charge

Les tests de charge vous donnent une indication sur la capacité du serveur à gérer les montées en puissance (montée en charge) ! Autrement dit, le rapport du test de charge vous donnera le nombre de requête maximum que le serveur pourra traiter dans un laps de temps restreint. Vous comprendrez que plus les tests de performances sont bons, plus votre test de charge est bon. 

Et bien, détrompez-vous - Il y a tout de même une exception à ce qui pourrait paraître une évidence => l'utilisation de fonction(s) bloquante(s) !

Fonction bloquante

Croyez-moi, ce genre d'erreur est tellement sournoise, qu'elle est à la portée de tous les développeurs confondus (junior, expérimenté et sénior) - on va espérer qu'un expert ne tombera pas dans le panneau - si on part de ce postula, alors je ne suis pas un expert malgré mes 16 ans d'expérience en JavaScript - Et non, j'ai fais cette erreur pas plus tard qu'il y a 3 semaines :(

En effet, imaginons que votre contrôleur s'exécute en 150 ms, ce qui est plutôt bon finalement (test de performance correct). Puis, ajoutez à cela le temps d'ouverture de la connexion et éventuellement quelque latences réseaux, ce qui va nous amener proche des 250 ms.

Si votre site compte une dizaine de visites éparpillées tout au long de la journée, alors tous vos utilisateurs auront été servi en moyenne en 250 ms et ils auront, à priori, tous été contents de leur expérience utilisateur sur votre site puisqu'ils n'auront attendu que 250 ms chacun.

Maintenant, imaginons que vous partagez le lien de votre page web sur les réseaux sociaux ou que vous lancez une campagne marketing pour votre site e-commerce en pleine période de solde, alors il y a de grandes chances pour que les 10 utilisateurs cherchent à accéder au site en même temps et là, c'est une toute autre histoire dans les coulisses de votre serveur. En effet, il va recevoir les 10 requêtes en même temps et si le premier arrivé est servi en 250 ms, alors le second sera servi en 500 ms -- puisque, je vous le rappel, Node JS est mono thread et ne peut donc traiter qu'une seule requête à la fois -- et le troisième en 750 ms et ainsi de suite. Le dernier de la file d'attente devra attendre que son prédécesseur (9 ème) ait reçu sa réponse, qui lui-même devra attendre que son prédécesseur (8 ème) ait reçu sa réponse. Bref, vous avez compris, le dernier arrivé devra attendre 2,5 secondes avant de voir votre page s'afficher. Tout ça à cause d'une malheureuse fonction bloquante - l'ironie dans tout ça, c'est que n'importe quel développeur Node JS qui se respecte sait qu'il ne faut surtout pas bloquer le thread de Node JS et moi le 1er, et pourtant, je suis tombé dans le panneau... :( 

Sachez également que cette erreur passe très souvent inaperçue car pour détécter cette anomalie, il faut un gros pic de charge en un temps très court (de l'ordre de 70 appels par seconde*)

* Tests effectués sur un pc Dell bas de gamme avec une base de données MongoDB hébergée au même endroit que le process Node JS qui sert l'API. Donc temps d'accès (connexion) et de lecture très rapide.

Voici un exemple de code qui passe les tests de performance avec succès mais qui commence à poser problème lors de test de charge :

app.get("/", async (req, res) => {
    try {
        const data = await bdd.find(); // requête mongo ou autre call (ex: axios.get)
        res.json(data);
    } catch(e) {
        console.log("Error", e.message);
        res.send("Désolé, une erreur est survenue");
    }
});

Ici, on gère bien les exceptions avec notre "try/catch" mais regardez l'appel à la base de donnée - cet appel est asynchrone et pourtant, on a besoin d'attendre la réponse pour être en mesure de renvoyer les données à notre utilisateur qui attend patiemment :) c'est la raison pour laquelle l'utilisation de "await" est cruciale ! On ne peut donc pas retirer ce "await". Et, attention, le préfixage de la fonction "controleur" par le mot clé "async" ne rend pas pour autant votre fonction asynchrone. Il est présent pour autoriser l'utilisation du mot clé "await" - Alors comment faire ?

Node JS - Thread bloqué par fonction bloquante

Event loop Node JS bloqué par une fonction bloquante (synchrone) - Benchmark Clinic Doctor

Fonction non bloquante

Voici la solution que j'ai trouvée pour éviter de bloquer le thread de Node :

app.get("/", (req, res) => {
    (async () => {
        try {
            const data = await bdd.find(); // requête mongo ou autre call (ex: axios.get)
            res.json(data);
        } catch(e) {
            console.log("Error", e.message);
            res.send("Désolé, une erreur est survenue");
        }
    )();
});

La différence entre les 2 façons de faire est de 4000 appels en 10 secondes pour la version bloquante et plus de 10.000 appels en 10 secondes pour la version non bloquante. Vous noterez la magnifique courbe de l'event loop ainsi que la courbe en dents de scies représentant la charge du garbage collector qui est super régulière !

Thread Node JS non bloqué

Event loop Node JS non bloqué (asynchrone) et courbe du "Garbage Collector" régulière - Benchmark Clinic Doctor

J'ai hâte de lire vos impressions / commentaires. N'hésitez pas à réagir via le formulaire ci-dessous.

Auteur : Gael Cadoret - Architecte IT chez 24s

Sources

Partager cet article
    Réagir à cet article
    Icon de profil de Gcadoret sur ingin.fr