Aller au contenu
  • Retrouve-moi sur X
  • LinkedIn
  • Github
  • Mes formations

Code Heroes

Deviens un vrai héros du code
  • Accueil
  • Mes formations

Mes formations


Markdown de A a Z : creez du contenu clair et professionnel

Markdown de A a Z : creez du contenu clair et professionnel

Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance.

Voir la formation

Mes formations

Formation Markdown de A a Z

Markdown de A a Z

Apprends à créer une documentation claire et professionnelle.

Les derniers articles
JavaScript : présentation des « iterator helpers »JavaScript : présentation des « iterator helpers »
5 novembre 2024Cela faisait longtemps que je n’avais pas écrit un article sur les fonctionnalités du langage JavaScript. Le dernier remonte à 2021 (outch, ça commence à dater !) et portait sur les itérateurs et les générateurs. Eh bien, ça tombe à pic, car les iterator helpers viennent tout juste de passer au stade 4 du processus de standardisation en octobre 2024, ce qui signifie qu’ils font officiellement partie de la norme ECMAScript ! Bref rappel sur les itérateurs et les générateurs Avant de plonger dans les iterator helpers, revenons rapidement sur ce que sont un itérateur et un générateur. Ces concepts sont essentiels pour bien comprendre comment fonctionnent les helpers ! Pour plus de détails, je vous invite à consulter mon article précédent, qui se penche en profondeur sur les itérateurs et les générateurs en JavaScript. Qu’est ce qu’un itérateur ? Un itérateur ou iterator est un objet qui permet de parcourir une collection d’éléments séquentiellement, comme un tableau ou une chaîne de caractères, ou même de générer des valeurs dynamiques à la demande, comme une suite de nombres. Chaque itérateur possède une méthode next qui, à chaque appel, renvoie un objet contenant : value : la valeur courante de la séquence, done : un booléen indiquant si l’itération est terminée (true) ou non (false). Pour qu’un objet soit itérable, il doit inclure une méthode spéciale, Symbol.iterator, qui retourne un itérateur. Grâce à cette méthode, JavaScript reconnaît l’objet comme itérable, ce qui permet de l’utiliser dans des boucles comme for...of ou avec le spread operator (...). Exemples d’itérateurs const arr = ; const iterator = arr(); console.log(iterator.next()); // { value: 10, done: false } console.log(iterator.next()); // { value: 20, done: false } console.log(iterator.next()); // { value: 30, done: false } console.log(iterator.next()); // { value: undefined, done: true } Ici, le tableau arr possède déjà la méthode Symbol.iterator, ce qui permet de créer un itérateur en appelant arr(). Chaque appel de next avance dans le tableau, jusqu’à ce que done soit égal à true et signale la fin de l’itération. Vous pouvez aussi utiliser directement for...of pour parcourir le tableau sans créer d’itérateur explicitement : for (const value of arr) { console.log(value); // Affiche 10, 20, 30 } Les itérateurs ne se limitent pas aux collections fixes comme les tableaux, ils peuvent également produire des séquences dynamiques ou même infinies. Cela permet de générer des valeurs à la demande, sans avoir besoin de les stocker en mémoire. Voici un exemple d’itérateur infini qui génère des nombres séquentiels : const infiniteNumbers = { current: 0, next() { return { value: this.current++, done: false }; } }; console.log(infiniteNumbers.next()); // { value: 0, done: false } console.log(infiniteNumbers.next()); // { value: 1, done: false } console.log(infiniteNumbers.next()); // { value: 2, done: false } Dans cet exemple, chaque appel à next génère un nombre de manière continue, sans fin. Comme done reste toujours false, l’itérateur peut produire des valeurs sans s’arrêter. Qu’est ce qu’un génerateur ? Un générateur, ou generator, ou encore fonction génératrice, est une fonction spéciale qui peut produire plusieurs valeurs au fil du temps, avec la particularité de pouvoir suspendre son exécution et la reprendre plus tard. Contrairement aux fonctions classiques, qui exécutent tout leur code d’une traite, un générateur peut s’interrompre à tout moment et reprendre exactement là où il s’était arrêté. Il existe plusieurs cas d’utilisation pour les générateurs, que je vous invite à découvrir dans mon précédent article, mais celui qui nous intéresse ici est leur capacité à créer des itérateurs. Les générateurs se définissent avec function* et utilisent l’instruction yield pour produire chaque valeur. Lorsqu’on appelle un générateur, il ne s’exécute pas immédiatement. Au lieu de cela, il retourne un itérateur que l’on peut contrôler avec la méthode next. Comment fonctionne un générateur ? Chaque appel à next sur l’itérateur exécute le générateur jusqu’au prochain yield, où il produit une valeur, puis se met en pause. À la fin du générateur, done devient true, indiquant que toutes les valeurs ont été produites. Exemples de générateurs function* simpleGenerator() { yield 'A'; yield 'B'; yield 'C'; } const generator = simpleGenerator(); console.log(generator.next()); // { value: 'A', done: false } console.log(generator.next()); // { value: 'B', done: false } console.log(generator.next()); // { value: 'C', done: false } console.log(generator.next()); // { value: undefined, done: true } Dans cet exemple, chaque yield permet de produire une valeur, et chaque appel de next reprend là où le générateur s’était arrêté, jusqu’à ce que done soit true. Les générateurs sont aussi utiles pour des séquences calculées au fil du temps. Par exemple, pour générer la suite de Fibonacci : function* fibonacci() { let = ; while (true) { yield a; = ; } } const fib = fibonacci(); console.log(fib.next().value); // 0 console.log(fib.next().value); // 1 console.log(fib.next().value); // 1 console.log(fib.next().value); // 2 console.log(fib.next().value); // 3 Présentation des iterator helpers Les iterator helpers apportent un ensemble de méthodes pratiques pour la manipulation des itérateurs en JavaScript. Ils permettent de manipuler des séquences d’éléments, le tout sans avoir à créer des structures intermédiaires en mémoire. Ces méthodes sont donc particulièrement efficaces pour travailler avec des séquences volumineuses ou infinies. Voici un aperçu des principaux iterator helpers et de leur utilité : filter(callback) : Crée une sous-séquence en ne conservant que les éléments qui répondent à une condition spécifiée. map(callback) : Applique une transformation à chaque élément de la séquence, générant une nouvelle séquence contenant les valeurs modifiées. take(n) : Récupère les n premiers éléments de la séquence. drop(n) : Ignore les n premiers éléments de la séquence. reduce(callback, initialValue) : Combine les valeurs d’une séquence pour obtenir une valeur finale unique, en appliquant une fonction d’agrégation. forEach(callback) : Exécute une fonction pour chaque élément de la séquence, sans renvoyer de nouvelle séquence. some(callback) : Renvoie true si au moins un élément de la séquence satisfait la condition spécifiée, sinon false. every(callback) : Renvoie true uniquement si tous les éléments de la séquence satisfont la condition donnée, sinon false. find(callback) : Renvoie le premier élément de la séquence qui répond à la condition dans callback, ou undefined s’il n’y en a aucun. flatMap(callback) : Applique une transformation à chaque élément, tout en « aplatissant » les sous-séquences résultantes. toArray() : Convertit un itérateur en tableau, forçant ainsi l’évaluation complète de la séquence en mémoire. Avec ces iterator helpers, on peut enchaîner facilement des opérations complexes sur des séquences sans créer de copies intermédiaires et sans surcharger la mémoire. Cette approche, qui repose sur l’évaluation paresseuse (lazy evaluation), est idéale pour travailler avec des flux de données en continu ou des séquences infinies tout en conservant les ressources. Évaluation paresseuse (lazy evaluation) vs évaluation immédiate (eager evaluation) Avant de présenter les iterator helpers, il est essentiel de comprendre deux approches de traitement des données en JavaScript : l’évaluation paresseuse ou évaluation retardée (lazy evaluation) et l’évaluation immédiate (eager evaluation) Évaluation paresseuse : L’évaluation paresseuse calcule les valeurs uniquement au moment où elles sont nécessaires. Plutôt que de générer toute la séquence d’un coup, chaque valeur est produite à la demande, ce qui économise de la mémoire et permet de traiter des séquences longues ou infinies efficacement. Évaluation immédiate : À l’inverse, l’évaluation immédiate consiste à calculer toutes les valeurs d’un coup et à les stocker. C’est le cas, par exemple, lorsqu’on utilise map sur un tableau, où toutes les valeurs sont transformées et stockées dans un nouveau tableau. Cette méthode convient aux petites collections, mais devient inefficace pour de grands volumes de données ou des flux infinis. Les iterator helpers en action Pour illustrer les différents iterator helpers, prenons un exemple. Imaginons que nous disposons d’un flux de données continu qui provient de capteurs météo et qui envoie des relevés de température en temps réel. Ce flux représente des valeurs qu’on souhaite filtrer, transformer et analyser, sans devoir tout stocker en mémoire.  Pour simuler ce flux, créons un générateur temperatureStream qui produit des température aléatoire entre -20 et 50 °C : function* temperatureStream() { const min = -20; const max = 50; while (true) { yield Math.floor(Math.random() * (max - min + 1)) + min; // Génère une température aléatoire entre -20 et 50 } } Maintenant, voyons comment chaque helper peut être utilisé pour manipuler ce flux de données. filter(callback) Le helper filter nous permet de ne conserver que les éléments de la séquence qui répondent à une condition. Imaginons qu’on veuille récupérer uniquement les températures au-dessus de 25 °C. const highTemperaturesStream = temperatureStream() .filter(temperature => temperature > 25); for (const temperature of highTemperaturesStream) { console.log(`Température élevée : ${temperature}°C`); if (Math.random() < 0.1) break; // On arrête après quelques relevés pour l'exemple } map(callback) map est parfait pour transformer les valeurs. Supposons qu’on veuille afficher les températures en Fahrenheit plutôt qu’en Celsius. const fahrenheitTemperaturesStream = temperatureStream() .filter(temperature => temperature > 25) .map(temperature => (temperature * 9 / 5) + 32); for (const temperatureFahrenheit of fahrenheitTemperaturesStream) { console.log(`Température en Fahrenheit : ${temperatureFahrenheit.toFixed(2)}°F`); if (Math.random() < 0.1) break; } take(n) Avec take, on peut limiter la séquence aux n premiers éléments, ce qui est idéal pour tester ou échantillonner un flux infini. Prenons, par exemple, les 5 premières températures élevées en Fahrenheit. const firstFiveHighTempsFahrenheit = temperatureStream() .filter(temperature => temperature > 25) .map(temperature => (temperature * 9 / 5) + 32) .take(5); for (const temperatureFahrenheit of firstFiveHighTempsFahrenheit) { console.log(`Température (F) : ${temperatureFahrenheit.toFixed(2)}°F`); } drop(n) drop nous permet d’ignorer les n premiers éléments de la séquence et de commencer à analyser les relevés suivants. Imaginons qu’on veuille ignorer les trois premières lectures pour éviter des valeurs de calibrage. const calibratedTemperaturesStream = temperatureStream() .drop(3) // Ignore les 3 premières lectures .take(5); // Prend les 5 lectures suivantes pour l'analyse for (const temperature of calibratedTemperaturesStream) { console.log(`Lecture après calibrage : ${temperature}°C`); } reduce(callback, initialValue) reduce est idéal pour calculer une valeur unique à partir d’une séquence, comme une moyenne ou une somme. Calculons, par exemple, la température moyenne des 10 premières lectures de températures. const averageHighTemperature = temperatureStream() .take(10) .reduce((total, temperature, index) => { return index === 9 ? (total + temperature) / 10 : total + temperature; }, 0); console.log(`Température moyenne : ${averageHighTemperature.toFixed(2)}°C`); forEach(callback) Le helper forEach exécute une fonction pour chaque élément de la séquence sans rien renvoyer, ce qui le rend idéal pour effectuer des actions ou des effets de bord. Par exemple, supposons qu’on souhaite simplement afficher chaque relevé de température, sans affecter le flux.  temperatureStream() .take(5) .forEach(temperature => { console.log(`Température relevée : ${temperature}°C`); }); some(callback) some renvoie true si au moins un élément de la séquence satisfait la condition spécifiée. Par exemple, voyons si au moins une des 10 premières température est en-dessous de 0°C. const hasFreezingTemperature = temperatureStream() .take(10) .some((temperature) => temperature < 0); console.log(`Présence de températures négatives : ${hasFreezingTemperature}`); every(callback) every vérifie si tous les éléments d’une séquence répondent à une condition. Par exemple, regardons si les 10 premières températures sont toutes positives. const allTemperaturesPositive = temperatureStream() .take(10) .every(temperature => temperature >= 0); console.log(`Toutes les températures sont positives : ${allTemperaturesPositive}`); find(callback) find renvoie le premier élément qui répond à une condition donnée, ou undefined si aucun n’est trouvé. Trouvons la première température qui dépasse 40°C. const firstExtremeTemperature = temperatureStream() .find(temperature => temperature > 40); console.log(`Première température extrême : ${firstExtremeTemperature}°C`); flatMap(callback) Le helper flatMap est idéal lorsque chaque élément de la séquence doit être transformé en plusieurs sous-éléments qui seront ensuite « aplatis » en une seule séquence continue. Cela est particulièrement utile pour éviter les structures imbriquées et simplifier la manipulation des données. Imaginons que chaque relevé de température contient des mesures pour différentes périodes de la journée. On souhaite convertir chacune de ces températures de Celsius en Fahrenheit, puis les traiter comme une seule séquence. function* temperatureStreamWithDailyPeriods() { while (true) { yield { morning: Math.floor(Math.random() * 30), // Température du matin en °C afternoon: Math.floor(Math.random() * 30), // Température de l'après-midi en °C evening: Math.floor(Math.random() * 30) // Température du soir en °C }; } } const fahrenheitTemperatures = temperatureStreamWithDailyPeriods() .flatMap(({ morning, afternoon, evening }) => { // Convertit chaque relevé de Celsius en Fahrenheit return [ (morning * 9) / 5 + 32, (afternoon * 9) / 5 + 32, (evening * 9) / 5 + 32 ]; }); for (const temperature of fahrenheitTemperatures) { console.log(`Température en Fahrenheit : ${temperature.toFixed(2)}°F`); if (Math.random() < 0.1) break; // On arrête après quelques relevés pour l'exemple } toArray() toArray convertit un itérateur en tableau, forçant ainsi l’évaluation complète de la séquence en mémoire. Cette méthode est utile pour obtenir toutes les valeurs sous forme de tableau, mais elle ne doit pas être utilisée sur des séquences infinies et doit être employée avec précaution sur de grandes séquences. Par exemple, créons un tableau contenant les 10 premières températures. const temperatureArray = temperatureStream() .take(10) .toArray(); console.log(`Échantillon de températures :`, temperatureArray); Différence avec les méthodes de tableau Différence avec les méthodes de tableau Les iterator helpers, comme map, filter ou encore find, se distinguent des méthodes de tableau correspondantes par leur évaluation paresseuse. Plutôt que de traiter toutes les valeurs d’un coup, comme le font les méthodes de tableau, les iterator helpers calculent chaque valeur uniquement lorsqu’elle est nécessaire. Cela fonctionne un peu comme un « pipeline » où chaque transformation est appliquée successivement à une valeur avant de passer à la suivante. Avec cette approche, il n’y a pas de copies intermédiaires entre chaque étape. Par exemple, lorsqu’on enchaîne map et filter, chaque transformation est appliquée successivement sur une valeur unique à chaque étape. Ce mode de traitement « à la demande » améliore les performances et réduit l’utilisation de la mémoire, ce qui est particulièrement avantageux pour les grandes collections ou les flux de données. Utilité des iterator helpers pour le parcours de structures de données Nous avons vu que les iterator helpers sont particulièrement utiles pour manipuler des flux de données, grâce à leur évaluation paresseuse. Mais ils sont aussi très puissants lorsqu’il s’agit de parcourir des structures de données complexes, comme les arbres, les listes chainées ou encore les graphes. Prenons un exemple concret. Supposons que nous ayons un arbre binaire qui stocke des valeurs numériques, et nous souhaitons parcourir cet arbre pour filtrer, transformer ou agréger ses valeurs. En définissant un itérateur sur notre arbre, nous pouvons utiliser les iterator helpers pour effectuer ces opérations de manière élégante et efficace. Voici comment nous pourrions implémenter un arbre binaire avec un itérateur en JavaScript : class TreeNode { constructor(value, left = null, right = null) { this.value = value; this.left = left; this.right = right; } *() { if (this.left) { yield* this.left; } yield this.value; if (this.right) { yield* this.right; } } } // Construction d'un arbre binaire const tree = new TreeNode(10, new TreeNode(5, new TreeNode(2), new TreeNode(7) ), new TreeNode(15, new TreeNode(12), new TreeNode(20) ) ); Dans cet exemple, nous avons défini un itérateur Symbol.iterator qui effectue un parcours en ordre infixe (in-order traversal). Pour utiliser les iterator helpers sur notre arbre, nous pouvons créer un itérateur à partir de celui-ci en utilisant Iterator.from. L’utilisation de Iterator.from est nécessaire ici car les iterator helpers (filter, map, reduce, etc.) font partie des nouvelles méthodes d’itération en JavaScript et ne sont disponibles que sur les instances d’un objet Iterator. Les structures de données comme les tableaux ou les objets que nous créons, comme ici, peuvent implémenter le protocole d’itération en utilisant Symbol.iterator, mais cela ne leur donne pas automatiquement accès aux helpers. Bref, voyons quelques exemples d’utilisation. Filtrer les valeurs supérieures à 10 Nous voulons extraire toutes les valeurs de l’arbre qui sont strictement supérieures à 10. En utilisant l’iterator helper filter, nous pouvons parcourir l’arbre en appliquant ce critère à chaque valeur, et stocker le résultat dans un tableau. const highValues = Iterator.from(tree) .filter(value => value > 10) .toArray(); console.log(highValues); // Calculer la somme des valeurs Imaginons maintenant que nous voulons calculer la somme de toutes les valeurs de l’arbre. Le helper reduce est parfait pour cela. Il accumule les valeurs en appliquant une fonction d’agrégation. const sum = Iterator.from(tree) .reduce((acc, value) => acc + value, 0); console.log(sum); // 71 Vérifier si toutes les valeurs sont positives Enfin, vérifions si toutes les valeurs de l’arbre sont positives. Pour cela, nous utilisons le helper every, qui renvoie true si chaque valeur satisfait le prédicat, et false dans le cas contraire. const allPositive = Iterator.from(tree) .every(value => value > 0); console.log(allPositive); // true Ces exemples illustrent la puissance des iterator helpers lorsqu’ils sont combinés à des structures de données personnalisées, comme notre arbre binaire. Pour finir… Nous avons exploré les iterator helpers et les nombreuses possibilités qu’ils apportent en JavaScript. Ces nouveaux outils enrichissent le langage en offrant des méthodes performantes pour manipuler les séquences de données de façon élégante et efficace. Grâce à l’évaluation paresseuse (lazy evaluation), on peut enchaîner les transformations sans créer de copies intermédiaires en mémoire, ce qui est particulièrement précieux pour les applications exigeantes en ressources ou le traitement de grandes quantités de données à la demande. Il est également intéressant de noter que les streams dans Node.js peuvent être manipulés comme des itérateurs asynchrones. Cela signifie que vous pouvez utiliser des constructions telles que for await...of pour consommer des streams de manière plus intuitive et efficace. Sachez également que les iterator helpers sont disponibles, bien qu’encore au stade expérimental au moment de la rédaction de cet article, pour les streams dans Node.js. Mais ça mérite peut-être un autre article sur les streams Node.js. On arrive donc à la fin de cet article, et c’était un vrai plaisir de replonger dans l’écriture d’un article sur JavaScript après tout ce temps. N’hésitez pas à le partager avec vos collègues, ami(e)s, ou toute personne qui pourrait trouver ces informations utiles. Merci ! Mes formations Markdown de A a Z : creez du contenu clair et professionnel Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance. [...] Lire la suite…
À la découverte de…AdonisJSÀ la découverte de…AdonisJS
14 janvier 2024L’écosystème de Node.js regorge de frameworks et de bibliothèques. Il y a environ trois ans, je vous ai présenté Fastify, une alternative sérieuse à Express.js. Aujourd’hui, je souhaite attirer votre attention sur AdonisJS, un framework lancé fin 2015. Bien qu’il ne soit pas nouveau, AdonisJS reste souvent méconnu, au profit d’autres frameworks, malgré ses nombreux atouts. Cet article a donc pour but de vous faire découvrir AdonisJS et tenter de susciter votre envie d’en apprendre davantage à son sujet, dans l’espoir de lui accorder la reconnaissance qu’il mérite dans l’univers de Node.js. Remarque : Cet article n’est pas un tutoriel sur AdonisJS mais une présentation de celui-ci. Je partagerai des ressources utiles pour approfondir vos connaissances à la fin de l’article. Petit tour d’horizon des frameworks Node.js Dans l’écosystème de Node.js, on distingue deux grandes familles de frameworks, chacune répondant à des besoins et des approches de développement différents. Les frameworks “low-scope” Les frameworks « low-scope » offrent une toile vierge pour les développeurs. Ils se caractérisent par leur structure de base minimaliste, principalement un système de routage et des middlewares. Cette approche minimaliste offre une grande liberté dans la construction d’applications, permettant aux développeurs de choisir et d’intégrer des bibliothèques selon leurs besoins spécifiques. Cependant, cette liberté a un coût : le besoin de sélectionner et d’intégrer manuellement des bibliothèques pour des fonctionnalités avancées telles que l’accès aux données ou l’authentification des utilisateurs. Cela peut entraîner une augmentation du temps de développement et un besoin accru de prise de décision technique. Parmi les frameworks “low-scope” les plus populaires, nous retrouvons : Express.js Fastify Hono Koa Hapi Les frameworks “high-scope” À l’opposé, les frameworks « high-scope » comme Nest.js proposent une solution plus complète dès le départ. Ils sont souvent livrés avec une gamme de fonctionnalités intégrées, telles que l’accès aux données (généralement via un ORM), l’authentification ou encore la validation des données réduisant ainsi la configuration initiale et les décisions techniques à prendre. Ces frameworks sont conçus pour optimiser le développement en adoptant des pratiques et des structures établies, permettant ainsi aux développeurs de se concentrer sur le cœur de l’application. Le revers de la médaille est moins de flexibilité et une courbe d’apprentissage potentiellement plus abrupte en raison de l’architecture et des conventions spécifiques du framework. Qu’en est-il d’AdonisJS ? AdonisJS se distingue en offrant une fusion unique entre ces deux mondes. Il fournit un noyau solide et des fonctionnalités de base comme un framework « low-scope », tout en permettant une extension facile avec des modules officiels qui enrichissent le framework, le rapprochant ainsi des frameworks « high-scope ». Ce mélange offre aux développeurs la liberté de choisir les modules nécessaires pour leur projet, sans les contraintes d’une structure trop rigide. AdonisJS : un Framework complet et modulaire AdonisJS est un framework Node.js open source, écrit en TypeScript. Lancé en octobre 2015, il a été créé et maintenu principalement par Harminder Virk, rejoint ensuite par trois autres développeurs formant l’équipe principale : Romain Lanz – Développeur Full-Stack Michaël Zasso – Ingénieur Logiciel en recherche scientifique Julien Ripouteau – Développeur Full-Stack Avec plus d’une centaine de contributeurs, AdonisJS bénéficie également d’une riche communauté de développeurs. Petit flashback AdonisJS a parcouru un long chemin depuis sa première version en octobre 2015. Il a évolué à travers plusieurs versions majeures, chacune apportant des améliorations et de nouvelles fonctionnalités. En décembre 2015, la version 2.0 a été lancée, suivie par la 3.0 en juin 2016, et la 4.0 en juin 2017. Avec la sortie de la version 5.0 en mars 2020, AdonisJS a renforcé son architecture et a introduit une prise en charge complète de TypeScript. La version 6 est quant à elle prévue pour le 24 janvier 2024. Cette nouvelle version arrive avec son lot de nouveauté, parmi lesquelles : une adoption complète des modules ECMAScript (ESM); L’obligation de Node.js version 20; L’utilisation de Vite comme bundler par défaut; Un nouveau module de validation des données (VineJS) Dans la suite de l’article, nous allons nous concentrer sur cette version 6. La Philosophie modulaire d’AdonisJS L’architecture modulaire d’AdonisJS lui permet de fournir les fonctionnalités de base, communes aux frameworks “low-scope”, tout en offrant la possibilité d’intégrer une gamme de modules officiels pour des fonctionnalités supplémentaires. Cette flexibilité signifie que les développeurs peuvent adapter le framework à leurs besoins spécifiques, faisant d’AdonisJS un outil polyvalent adapté à divers types de projets. Le cœur d’AdonisJS Le cœur d’AdonisJS propose les fonctionnalités fondamentales suivantes : Un système de routage et de contrôleurs; Un système de middleware; Un système de gestion de configurations et de variables d’environnement; Un système d’émetteurs d’événements; Un système de chiffrement et de hachage; Un système de logging (basé sur Pino); Un système d’injection de dépendances. AdonisJS offre une liberté similaire aux frameworks « low-scope » dans le sens où il permet de construire des applications en intégrant des modules externes. Cette flexibilité est importante à souligner, car il existe un malentendu commun selon lequel l’utilisation des modules officiels d’AdonisJS, comme Lucid, est obligatoire. En réalité, les développeurs ont la liberté de choisir parmi d’autres solutions telles que Prisma ou TypeORM. Cependant, même si AdonisJS permet de développer des applications à la façon des frameworks “low-scope”, cette méthode peut ne pas être la plus efficace. L’écosystème Node.js a tendance à être un peu chaotique, avec une propension à réinventer la roue plutôt que de s’appuyer sur un framework “high-scope” bien établi. Cela contraste fortement avec l’écosystème PHP, où l’adoption de frameworks “high-scope” comme Laravel ou Symfony est monnaie courante et ne pose pas de problème. Les modules officiels La force d’AdonisJS réside dans la diversité et la richesse de ses modules officiels. Voici un aperçu de certains d’entre eux : lucid : ORM pour les interactions avec les bases de données ; auth : Gestion de l’authentification des utilisateurs ; ally : Authentification sociale pour des plateformes telles que Github, Twitter, et Google ; bouncer : Système de gestion des permissions et des rôles ; drive : Gestion du stockage de fichiers, avec support de divers services (S3, GCS) ; ace : Création d’interfaces de ligne de commande ; vineJS : Bibliothèque pour la validation des données ; japa : Framework de tests ; shield : Protection contre les vulnérabilités web (XSS, CSRF, etc.) ; transmit : Module SSE (Server-Sent Events) pour la diffusion en temps réel ; i18n : Intégration de l’internationalisation et de la localisation. Ces modules permettent aux développeurs d’adapter AdonisJS à leurs besoins, que ce soit pour des applications web simples ou des backends d’API complexes. Les modules communautaires L’écosystème AdonisJS se distingue par ses nombreux modules créés par la communauté. Ces modules couvrent différents domaines, tels que la sécurité, le monitoring, l’authentification, l’autorisation, le déploiement, les outils de développement, etc. Pour explorer ces modules, je vous invite à visiter le site packages.adonisjs.com. Enfin un vrai système intégré AdonisJS se distingue parmi les frameworks Node.js grâce à son système intégré qui facilite l’extension et la personnalisation de ses modules. L’un des exemples de cette extensibilité est le module Lucid, qui peut étendre les fonctionnalités d’autres composants du framework. Par exemple Lors de l’installation de Lucid, de nouvelles règles sont ajoutées au système de validation de données, d’AdonisJS. AdonisJS intègre également une interface de ligne de commande (CLI) via son module Ace. L’ajout de nouveaux modules à AdonisJS enrichit automatiquement la CLI avec de nouvelles commandes, consolidant les fonctionnalités dans une seule interface. Cette méthode contraste avec celle de certains autres frameworks, comme Nest.js, où il est courant d’avoir des CLI distinctes pour chaque module installé, comme TypeORM ou Prisma. L’approche unifiée d’AdonisJS favorise une expérience de développement plus structurée, où les outils et commandes sont centralisés, simplifiant la gestion et l’utilisation du framework. Un plugin VScode est également fournit avec AdonisJS proposant ainsi une expérience de développement agréable et peu commune dans l’écosystème Node.js. Quelques exemples de code L’exploration complète d’AdonisJS dépasse le cadre d’un seul article, étant donné la profondeur et l’étendue de ce framework. Cet article se veut une invitation à découvrir AdonisJS plutôt qu’un guide détaillé. Néanmoins, pour vous donner un avant-goût de sa puissance et de sa facilité d’utilisation, je vais vous proposer ici quelques exemples de code. Le routing Pour montrer comment fonctionne le routing, prenons l’exemple d’un contrôleur pour gérer des tâches. La création d’un nouveau contrôleur pour une ressource est simplifiée grâce à la commande suivante : node ace make:controller tasks --resource Les contrôleurs sont stockés dans le répertoire ./app/controllers. Chaque contrôleur est représenté sous forme d’une classe TypeScript standard : import { HttpContext } from '@adonisjs/core/http' export default class TasksController { /** * Retourne la liste de toutes les tâches ou effectue une pagination */ async index({}: HttpContext) {} /** * Affiche le formulaire pour créer une nouvelle tâche. * * Pas nécessaire si vous créez un serveur API. */ async create({}: HttpContext) {} /** * Gère la soumission du formulaire pour créer une nouvelle tâche */ async store({ request }: HttpContext) {} /** * Affiche une seule tâche par son id. */ async show({ params }: HttpContext) {} /** * Affiche le formulaire pour éditer une tâche existante par son id. * * Pas nécessaire si vous créez un serveur API. */ async edit({ params }: HttpContext) {} /** * Gère la soumission du formulaire pour mettre à jour une tâche spécifique par son id */ async update({ params, request }: HttpContext) {} /** * Gère la soumission du formulaire pour supprimer une tâche spécifique par son id. */ async destroy({ params }: HttpContext) {} } AdonisJS utilise par convention ces différents noms de méthode, mais rien ne vous empêche d’utiliser les noms que vous souhaitez pour mieux s’adapter à vos besoins spécifiques. Ensuite, il est important de lier ce contrôleur à une route. AdonisJS permet d’importer le contrôleur en utilisant l’alias #controllers. Les alias sont définis en utilisant la fonctionnalité d’imports de sous-chemins de Node.js. import router from '@adonisjs/core/services/router' import TasksController from '#controllers/tasks_controller' router.get('tasks', ) La méthode router.get spécifie qu’il s’agit d’une route GET. Le premier argument tasks est le chemin de l’URL et le deuxième argument indique le contrôleur et la méthode à utiliser pour répondre aux requêtes sur cette route. Si votre contrôleur utilise les noms de méthodes standards, vous pouvez simplifier l’enregistrement de toutes les routes associées en une seule ligne de code comme suit : import router from '@adonisjs/core/services/router' import TasksController from '#controllers/tasks_controller' router.resource('tasks', TasksController) Nous pouvons maintenant vérifier que toutes nos routes sont bien enregistrer via la commande suivante : node ace list:routes La validation des données La validation des données est un aspect crucial dans le développement d’applications web. L’équipe d’AdonisJS a créé une bibliothèque de validation de données agnostique du framework appelée VineJS qui s’intègre parfaitement à AdonisJS. VineJS est l’une des bibliothèques de validation les plus rapides dans l’écosystème Node.js. Vous n’êtes bien entendu pas obligé d’utiliser VineJS et opter pour d’autres librairies comme Zod par exemple. Voyons comment VineJS fonctionne. Créons un validateur pour une tâche : import vine from '@vinejs/vine' export const createTaskValidator = vine.compile( vine.object({ title: vine.string().trim().minLength(3).maxLength(50), description: vine.string().trim().escape(), isCompleted: vine.boolean(), }) ) Intégrons à présent notre validateur dans le contrôleur pour assurer la conformité des données soumises lors de la création d’une tâche : import type { HttpContext } from '@adonisjs/core/http' import { createTaskValidator } from '#validators/task_validator' export default class TasksController { async store({ request }: HttpContext) { const payload = await request.validateUsing(createTaskValidator) // traitement du payload... } } La variable payload est correctement inférée et contient les données validées issues de la requête. En cas de non-conformité, AdonisJS gère automatiquement les erreurs de validation, les convertissant en une réponse HTTP appropriée : { "errors": } L’accès aux données Dans AdonisJS, l’accès et la manipulation des données s’appuient sur Lucid, son ORM. Pour illustrer l’utilisation de Lucid, nous allons reprendre notre exemple de tâche. Créons un modèle représentant une tâche avec les champs id, title, description, et isCompleted dans une base de données : import { BaseModel, column } from '@adonisjs/lucid/orm' export default class Task extends BaseModel { @column({ isPrimary: true }) declare id: number @column() declare title: string @column() declare description: string @column() declare isCompleted: boolean } Utilisons ensuite ce modèle dans notre contrôleur pour ajouter une tâche en base de données : import type { HttpContext } from '@adonisjs/core/http' import { createTaskValidator } from '#validators/task_validator' import Task from '#models/user_model' export default class TasksController { async store({ request }: HttpContext) { const payload = await request.validateUsing(createTaskValidator) const createdTask = await Task.create(payload) return createdTask } } Lucid fournit bien entendu les opérations CRUD classique (Create, Read, Update, Delete) via plusieurs méthodes : Create : Utilise Model.create pour insérer de nouvelles données; Read : Model.find ou Model.findBy pour récupérer une entrée spécifique, et Model.all pour obtenir toutes les entrées; Update : Utilise Model.query().where().update() ou met à jour une instance de modèle existante puis appelle save(); Delete : Utilise Model.query().where().delete() ou appelle delete() sur une instance de modèle. Des ressources pour aller plus loin Pour celles et ceux qui souhaitent approfondir leurs connaissances, voici quelques ressources précieuses : Adocasts : Un large éventail de vidéos abordant une multitude de sujets relatifs à AdonisJS, idéal pour les personnes préférant l’apprentissage visuel; Adonis mastery : Semblable à Adocasts, cette plateforme offre une richesse de contenus vidéo pour maîtriser AdonisJS; Chaîne YouTube de Romain Lanz : Les vidéos de Romain Lanz, membre clé de l’équipe d’AdonisJS, sont hyper intéressantes avec des exemples concrets. Vous pouvez également le suivre en live sur Twitch; Documentation officielle d’AdonisJS : Pour une compréhension approfondie, rien ne vaut la documentation officielle. Ces ressources constituent un excellent point de départ pour explorer en profondeur les fonctionnalités et les capacités d’AdonisJS. Pour finir… On vient de voir un framework qui selon moi est le plus abouti et propose la meilleure expérience développeur de l’écosystème Node.js et mérite amplement d’être plus connu. J’espère qu’au travers de cet article, j’aurais attisé la curiosité de quelques un d’entre vous. J’aimerais profiter de cet article pour vous encourager à rejoindre la communauté AdonisJS, quel que soit votre niveau d’expertise. Participer à un projet open source est une excellente façon d’apprendre et de contribuer à un projet significatif. Il est bon de rappeler que ces projets sont souvent menés par des bénévoles. Leur engagement mérite notre reconnaissance et notre soutien. En contribuant, vous ne développez pas seulement vos compétences, mais participez également à un effort collectif pour offrir des solutions open source de qualité à la communauté. Mes formations Markdown de A a Z : creez du contenu clair et professionnel Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance. [...] Lire la suite…
Clarifiez les décisions techniques avec les ADRsClarifiez les décisions techniques avec les ADRs
31 mai 2023Vous connaissez cette frustration de travailler sur un projet et de ne pas savoir pourquoi une décision technique a été prise ou de subir les conséquences d’une décision mal éclairée ? Eh bien, bonne nouvelle, les architectures decision record (ADR) sont là pour nous aider à documenter ces décisions cruciales. Je vais vous montrer comment les ADR peuvent transformer la documentation et la communication de vos décisions clés, tout en améliorant la collaboration au sein de votre équipe. Qu’est-ce qu’un ADR ? Un ADR, ou Architecture Decision Record, est un court document texte qui décrit une décision technique importante prise lors du développement d’un projet. Il s’agit de capturer les raisons qui ont conduit à cette décision, les alternatives envisagées, et les conséquences possibles de la décision. Il n’existe pas de format unique pour les ADR. Les équipes peuvent adapter la structure d’un ADR en fonction de leurs besoins et de leurs préférences. Voici par exemple une structure d’ADR : Numéro (ou date) : Identifiant unique de l’ADR (par exemple, ADR-001) ou la date de création ; Titre : Un bref résumé de la décision ; Auteur : Le ou les auteurs de l’ADR, généralement les personnes impliquées dans la prise de décision ; Statut : Le statut actuel de l’ADR (par exemple, proposé, accepté, rejeté, abandonné, remplacé, etc.) ; Contexte : Description de la situation ou du problème qui a conduit à la prise de décision ; Décision : La décision elle-même, clairement énoncée ; Conséquences : Les conséquences positives et négatives de la décision, ainsi que les implications pour le projet ; Références : Liens vers des ressources externes, telles que la documentation, les articles de blog ou les discussions pertinentes pour la décision. Prenons un exemple. Supposons que l’équipe de développement travaille sur une nouvelle application web et doit choisir un framework pour la réaliser. Trois options ont été identifiées comme possibles : React, Angular et Vue.js. Chaque framework présente ses propres avantages et inconvénients, et l’équipe doit évaluer chacun d’eux en fonction de critères tels que la facilité d’apprentissage, la flexibilité, la performance et la popularité. Nous pouvons écrire un ADR au format Markdown comme suit : # ADR-001 : Choix du framework pour notre application web - **Auteur** : John Doe - **Date** : 2023-05-05 - **Statut** : Accepté ## Contexte Notre équipe travaille sur une nouvelle application web destinée à faciliter la gestion des projets internes. Pour développer cette application, il est nécessaire de choisir un framework parmi les trois options suivantes : React, Angular et Vue.js. ### Critères de sélection 1. **Facilité d'apprentissage** : Temps et effort requis pour apprendre le framework et être productif 2. **Flexibilité** : Possibilité d'adapter le framework à nos besoins et contraintes spécifiques 3. **Performance** : Rapidité et efficacité du framework en termes de temps de chargement et d'exécution 4. **Popularité** : Taille et activité de la communauté de développeurs, ainsi que la disponibilité des ressources (documentation, tutoriels, etc.) ## Décision Après avoir examiné les options en tenant compte de critères tels que la facilité d'apprentissage, la flexibilité, la performance et la popularité, nous avons décidé d'utiliser **React** pour notre application web. ### Pourquoi React ? 1. **Grande communauté** : React bénéficie d'une vaste communauté de développeurs, ce qui signifie un meilleur soutien et plus de ressources disponibles. 2. **Flexibilité** : React offre une grande flexibilité pour structurer et organiser notre application selon nos besoins spécifiques. 3. **Performance** : React est reconnu pour ses performances élevées grâce à sa méthode de rendu optimisée, le DOM virtuel. 4. **Écosystème riche** : Un grand nombre de bibliothèques et d'outils complémentaires sont disponibles pour étendre et améliorer les fonctionnalités de React. ## Conséquences En choisissant React, voici les conséquences pour notre projet : - **Temps d'apprentissage** : Les membres de l'équipe qui ne sont pas familiers avec React devront investir du temps pour apprendre cette technologie. - **Meilleure collaboration** : Grâce à la popularité de React, il sera plus facile de recruter de nouveaux développeurs et d'obtenir de l'aide en cas de besoin. - **Maintenance simplifiée** : La grande communauté et la documentation abondante de React facilitent la maintenance et la résolution des problèmes. Pour aider les membres de l'équipe à se familiariser avec React, nous organiserons des sessions de formation et des ateliers. De plus, nous mettrons en place un processus de revue de code pour garantir que les meilleures pratiques sont respectées. ## Références - (https://reactjs.org/docs/getting-started.html) - (https://stateofjs.com/2022/frameworks/) Pourquoi utiliser des ADR ? Les ADR offrent de nombreux avantages pour les équipes de développement lorsqu’il s’agit de documenter les décisions techniques importantes. Voici quelques-unes des raisons pour lesquelles il est bénéfique d’utiliser des ADR. Transparence et communication Les ADR fournissent un moyen clair et structuré de communiquer les décisions techniques au sein de l’équipe et avec les parties prenantes externes. En documentant les raisons derrière une décision et les alternatives envisagées, les membres de l’équipe et les parties prenantes peuvent comprendre les motivations et les facteurs qui ont influencé la prise de décision. Traçabilité des décisions Les ADR permettent de garder une trace des décisions techniques prises au fil du temps. Cela facilite la compréhension de l’évolution du projet et de son architecture, ainsi que l’identification des décisions qui peuvent nécessiter une réévaluation ou une mise à jour. Apprentissage et amélioration En documentant les décisions, les ADR encouragent la réflexion et l’analyse approfondie des choix techniques. Cela favorise un apprentissage continu au sein de l’équipe et peut conduire à des améliorations dans la prise de décision et les processus de développement. Onboarding des nouveaux membres Lorsqu’un nouveau membre rejoint l’équipe, les ADR servent de ressource précieuse pour comprendre l’histoire et la logique derrière les choix techniques du projet. Cela facilite leur intégration et leur permet de mieux comprendre et contribuer au projet. Responsabilisation Les ADR aident à responsabiliser les membres de l’équipe en documentant les décisions et en identifiant les personnes impliquées dans la prise de ces décisions. Cela favorise une culture de responsabilité et d’engagement envers les choix techniques. En résumé, l’utilisation des ADR favorise une meilleure communication, une plus grande transparence et une prise de décision plus éclairée dans le développement de projets. Les ADR contribuent également à améliorer l’apprentissage au sein de l’équipe, à faciliter l’intégration des nouveaux membres et à encourager la responsabilisation. Bonnes pratiques et conseils Dans ce chapitre, nous aborderons les meilleures pratiques pour stocker et gérer les ADR, le format de rédaction à adopter et d’autres conseils pour tirer le meilleur parti de ces documents. Où stocker les ADR ? Les ADR sont généralement stockés dans le dépôt du projet, dans un répertoire dédié (par exemple, adr/ ou docs/adr/). Cela permet de les garder à proximité du code source et facilite leur consultation par les membres de l’équipe. Format de rédaction Le format Markdown est couramment utilisé pour la rédaction des ADR en raison de sa simplicité et de sa facilité de mise en forme. Les fichiers Markdown peuvent être facilement visualisés et édités dans la plupart des éditeurs de texte et des IDE, et peuvent être rendus sous forme de pages HTML pour une consultation plus agréable. Conseils pour la rédaction des ADR Voici quelques conseils pour rédiger des ADR de qualité : Utilisez un identifiant unique et des titres descriptifs : Chaque ADR doit avoir un identifiant unique (par exemple, ADR-001) ou une date, et un titre descriptif pour faciliter le suivi et les références croisées entre les ADR. Rédigez les ADR en temps réel ou peu de temps après la prise de décision : Documentez les décisions au fur et à mesure qu’elles sont prises ou peu de temps après, afin d’éviter la perte d’informations cruciales et de garantir l’exactitude des ADR. Soyez concis et clair : Les ADR doivent être courts et faciles à comprendre. Évitez les longs paragraphes et les phrases complexes. Utilisez des listes à puces, des tableaux et des en-têtes pour structurer le contenu et faciliter la lecture. Utilisez un langage neutre et factuel : Les ADR doivent présenter les faits et les arguments de manière objective, sans prendre parti. Évitez les jugements de valeur et les opinions personnelles. Documentez les alternatives et les conséquences : Lorsque vous présentez une décision, assurez-vous de documenter également les alternatives envisagées, les raisons pour lesquelles elles ont été écartées et les conséquences positives et négatives de la décision. Les ADR sont immuables, créez-en un nouveau en cas de changement de décision : Si une décision change ou évolue, créez un nouvel ADR pour documenter ce changement et référencez l’ADR précédent. Les ADR doivent être considérés comme immuables, c’est-à-dire qu’une fois acceptés, ils ne doivent pas être modifiés, excepté, bien entendu, pour changer leurs statuts. Utilisez des références : N’hésitez pas à inclure des liens vers des articles, des documentations ou d’autres ressources pertinentes pour étayer votre argumentation et faciliter la compréhension du lecteur. Assurez une revue régulière des ADR : Planifiez des revues régulières des ADR pour vous assurer que les décisions prises restent pertinentes et pour identifier les éventuelles mises à jour ou compléments nécessaires. Partagez les ADR avec les parties prenantes concernées : Les ADR doivent être accessibles à tous les membres de l’équipe et, si nécessaire, aux parties prenantes externes concernées. Cela favorise une meilleure compréhension des décisions et une communication plus transparente. En appliquant ces bonnes pratiques et en stockant vos ADR de manière appropriée, vous pourrez tirer pleinement parti des avantages qu’ils offrent en matière de communication, de traçabilité et d’amélioration continue au sein de votre équipe de développement. Petit aparté à propos des statuts  Comme précisé précédemment, une fois qu’un ADR est rédigé et accepté, il est considéré comme « immuable », c’est-à-dire qu’il ne doit pas être modifié ou réécrit. Cette règle est essentielle pour maintenir la traçabilité des décisions techniques prises tout au long du projet. Si, plus tard, cette décision est révisée ou remplacée par une nouvelle décision, au lieu de modifier l’ADR initial, un nouvel ADR est créé pour documenter ce changement. Le statut de l’ADR initial peut alors être mis à jour pour indiquer qu’il a été « remplacé » ou « déprécié ». Ce processus permet de garder un historique clair et précis des décisions techniques prises, tout en montrant comment elles ont évolué au fil du temps. De cette façon, même si une décision change, la justification et le contexte de la décision originale restent préservés pour référence future. Voici quelques exemples de statuts que vous pourriez trouver dans un ADR : Proposé : La décision a été formulée mais n’a pas encore été adoptée ou rejetée. Elle est en cours de discussion ou d’examen. Accepté : La décision a été approuvée et est actuellement mise en œuvre. Rejeté : La décision a été refusée. Les raisons du rejet sont généralement documentées dans l’ADR. Abandonné : La décision avait été acceptée à un moment donné, mais a depuis été abandonnée. Cela peut arriver si une nouvelle technologie plus appropriée apparaît, ou si la décision initiale s’avère ne pas convenir à l’architecture du système en cours de développement. Remplacé : La décision a été remplacée par une nouvelle décision, souvent documentée dans un nouvel ADR. Déprécié : La décision technique documentée n’est plus considérée comme pertinente ou appropriée, en raison de l’évolution du projet ou de nouvelles informations disponibles. Il est important de souligner que, bien que les ADR soient immuables, cela ne signifie pas que les décisions qu’ils documentent ne peuvent pas être modifiées. Les ADR sont conçus pour fournir une documentation et une justification des décisions prises à un moment donné, mais ils ne sont pas censés restreindre la capacité de l’équipe à réviser ces décisions à l’avenir. En d’autres termes, l’immutabilité des ADR concerne la documentation des décisions, et non les décisions elles-mêmes. Pour finir… Les ADR sont un outil essentiel pour documenter et communiquer les décisions techniques importantes prises lors du développement d’un projet. Ils permettent de garder une trace claire des raisonnements et des choix effectués, facilitant ainsi la compréhension et la collaboration au sein de l’équipe. En adoptant les bonnes pratiques de rédaction et en mettant en place une gestion efficace des ADR, vous contribuerez à améliorer la qualité de votre documentation technique et à renforcer la communication entre les membres de l’équipe. N’hésitez pas à adapter la structure et le format des ADR aux besoins spécifiques de votre projet et de votre équipe. L’essentiel est de trouver une approche qui vous convienne et qui vous permette de tirer pleinement parti des avantages offerts par les ADR. Alors, commencez dès aujourd’hui à documenter vos décisions et à construire un historique précieux pour le futur de votre projet.   Mes formations Markdown de A a Z : creez du contenu clair et professionnel Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance. [...] Lire la suite…
TypeScript : Programmation de typesTypeScript : Programmation de types
5 mai 2023Salut tout le monde ! Ça faisait un bail ! Aujourd’hui, on va parler de TypeScript, et à moins d’avoir vécu reclus dans une grotte ces dernières années, il est fort probable que vous ayez déjà eu l’occasion d’utiliser TypeScript, qui s’est imposé comme un élément incontournable du développement web moderne. Mais malgré sa popularité, on remarque que les capacités de son système de types avancés restent encore largement sous-estimées par la plupart des développeurs. Pourtant, la création de types personnalisés est extrêmement puissante et permet d’améliorer la qualité et l’efficacité de nos développements, elle est même considérée comme un langage de programmation à part entière, une sorte de langage programmation dans le langage de programmation. Bref, si vous voulez profiter pleinement de tout le potentiel de TypeScript, installez-vous confortablement et c’est parti ! Petite mise en bouche Plongeons directement au cœur du sujet sans perdre de temps avec des futilités. Vous êtes probablement familiers avec les types utilitaires proposés par TypeScript. ça ne vous dit rien ? Pourtant je suis persuadé que vous les avez déjà utilisés. Nous allons en explorer quelques-uns ensemble. Partial<T> Le type utilitaire Partial<T> rend toutes les propriétés du type T optionnelles. Par exemple, imaginons que nous avons une interface représentant un utilisateur : interface User { id: number; firstName: string; lastName: string; email: string; dateOfBirth: Date; address: string; } Maintenant, disons que nous voulons créer une fonction pour mettre à jour les informations d’un utilisateur, mais nous ne voulons pas forcer à fournir toutes les propriétés. Au lieu de cela, nous voulons permettre de mettre à jour seulement certaines propriétés. Nous pouvons utiliser le type utilitaire Partial<T> pour y parvenir : function updateUser(id: number, updatedInfo: Partial<User>): void { // Logique pour mettre à jour l'utilisateur avec les informations fournies } Maintenant, lors de l’appel de la fonction updateUser, nous pouvons fournir uniquement les propriétés que nous voulons mettre à jour : updateUser(1, { firstName: 'John' }); // Mettre à jour uniquement le prénom updateUser(1, { lastName: 'Doe', age: 30 }); // Mettre à jour le nom de famille et l'âge Pick<T, Keys> Le type utilitaire Pick<T, Keys> permet de créer un nouveau type en sélectionnant un ensemble spécifique de propriétés à partir du type T. Cela peut être utile lorsque vous souhaitez travailler avec un sous-ensemble de propriétés d’un objet ou d’une interface, sans avoir besoin de toutes les propriétés. Prenons l’interface représentant un utilisateur que nous avons utilisé précédemment : interface User { id: number; firstName: string; lastName: string; email: string; dateOfBirth: Date; address: string; } Supposons maintenant que nous souhaitons créer une fonction pour afficher un aperçu des informations d’un utilisateur, sans révéler toutes les informations personnelles. Nous pouvons utiliser le type utilitaire Pick<T, Keys> pour cela : function getUserPreview(id: number): Pick<User, 'firstName' | 'lastName' | 'email'> { // Logique pour récupérer un utilisateur const user = userService.getById(id); return { firstName: user.firstname, lastName: user.lastName, email: user.email, }; } La fonction getUserPreview retourne un objet contenant uniquement les propriétés firstName, lastName et email de l’interface User. Le type utilitaire Pick<T, Keys> nous permet de sélectionner précisément les propriétés que nous voulons extraire d’un type donné. Dans cet exemple, nous extrayons les propriétés firstName, lastName et email du type User. Omit<T, Keys> Le type utilitaire Omit<T, Keys> en TypeScript permet de créer un nouveau type en excluant un ensemble spécifique de propriétés à partir d’un type existant. Oui c’est l’inverse de Pick<T, Keys>. On peut donc totalement réecrire l’exemple précédent mais cette fois-ci avec Omit<T, Keys>: function getUserPreview(id: number): Omit<User, 'id' | 'dateOfBirth' | 'address'> { // Logique pour récupérer un utilisateur const user = userService.getById(id); return { firstName: user.firstname, lastName: user.lastName, email: user.email, }; } Parameters<T> Le type utilitaire Parameters<T> permet de déduire le type des paramètres d’une fonction en se basant sur son type. Plus précisément, Parameters<T> extrait le type des paramètres de la fonction à partir du type de la fonction T. Si T est une fonction, alors Parameters<T> renvoie un tuple contenant le type de chaque paramètre de la fonction. C’est toujours plus explicite avec un exemple : function sum(a: number, b: number) { return a + b; } type SumParams = Parameters<typeof sum>; // SumParams est équivalent à Dans cet exemple, nous avons défini une fonction sum qui prend deux paramètres de type number. Ensuite, nous avons utilisé Parameters<typeof sum> pour extraire le type des paramètres de la fonction sum, qui est . Et encore pleins d’autres ! Évidemment, il existe une foule d’autres types utilitaires en TypeScript qui peuvent rendre la création de types plus simples. Par exemple, Awaited<T> permet de connaître le type de la valeur renvoyée par une promesse, ReturnType<T> permet de déduire le type de la valeur renvoyée par une fonction, et NonNullable<T> permet de créer un nouveau type en excluant les valeurs null et undefined d’un type donné T. Bref, si vous voulez découvrir tous les types utilitaires disponibles, il suffit de jeter un œil à la documentation officielle de TypeScript. Vous vous rappelez quand je vous ai dit que l’écriture des types en TypeScript ressemble à celle d’un vrai langage de programmation ? Eh bien, c’est exactement ce que nous allons explorer ensemble et voir ce qui se cache derrière la création de types utilitaires. Un langage de programmation dans le langage de programmation Le système de type de TypeScript permet d’écrire de vrais algorithmes – certains disent même qu’il est Turing-complet ! Mais bon, on s’en fiche un peu, hein ? Ce qui nous intéresse, c’est de savoir comment écrire des fonctions, des conditions, assigner des variables, même comment écrire des boucles, tout ça avec ce système de types. Les fonctions Il est possible d’écrire des fonctions qui agissent sur les types grâce à la syntaxe des génériques : type Fn<A extends string, B extends string = 'world'> = ; /* ↑ ↑ ↑ ↑ ↑ Nom Param type Valeur Corps et retour de la fonction par défaut */ Décortiquons chaque élément : Fn  est le nom de la fonction ; A et B sont les paramètres ; extends string permet de définir le type du paramètre; = 'world' permet de définir quant à lui une valeur par défaut est le corps et le retour de la fonction ; Prenons un exemple pour illustrer tout ça. Créons une fonction agissant sur les types permettant de concaténer les types de deux tableaux : type Concat<T extends unknown[], U extends unknown[]> = ; Le type Concat prend deux types génériques T et U, qui sont tous deux des tableaux de types inconnus (unknown[]). Le type résultant  est un tuple qui concatène les éléments de T et U. Les opérateurs de décomposition (...T et ...U) sont utilisés pour extraire les éléments de chaque tableau, puis les combiner ensemble. Voici un exemple d’utilisation de ce type Concat : type A = ; type B = ; type C = Concat<A, B>; // C est de type Les branchements conditionnels Nous pouvons utiliser le mot-clé extends pour vérifier si un type est assignable ou substituable à un autre type, et l’opérateur ternaire ? pour choisir entre deux types en fonction de cette vérification. Par exemple : type C = A extends B ? TrueExpression : FalseExpression; Ici, si A peut être assigné ou substitué à B, alors C prendra la valeur de TrueExpression . Sinon C prendra la valeur de FalseExpression. Illustrons tout ça avec un exemple concret : interface Admin { role: 'admin'; manageUsers: boolean; } interface User { role: 'user'; manageUsers: false; } type AppUser = Admin | User; type AccessLevel<T> = T extends Admin ? 'Accès total' : 'Accès limité'; const adminUser: Admin = { role: 'admin', manageUsers: true }; const regularUser: User = { role: 'user', manageUsers: false }; type AdminAccessLevel = AccessLevel<Admin>; // 'Accès total' type UserAccessLevel = AccessLevel<User>; // 'Accès limité' Dans cet exemple, nous avons deux interfaces, Admin et User, et un type AppUser qui est l’union de ces deux interfaces. Nous avons ensuite le type AccessLevel<T>, qui utilise un branchement conditionnel pour déterminer le niveau d’accès à accorder en fonction du type d’utilisateur fourni. Si T est de type Admin, alors AccessLevel<T> sera de type 'Accès total'. Sinon, il sera de type 'Accès limité'. Nous créons ensuite deux objets de type Admin et User, et utilisons le type AccessLevel pour déterminer le niveau d’accès approprié pour chaque type d’utilisateur. L’assignation de variables Nous avons souvent besoin d’assigner le résultat d’une expression à une variable pour l’utiliser ultérieurement. Il est tout à fait possible de réaliser la même chose avec le système de types en utilisant les mots clés extends et infer. Pour illustrer cela, réécrivons le type utilitaire ReturnType<T> qui permet de récupérer le type de retour d’une fonction : type MyReturnType<T extends Function> = T extends (...args: any[]) => infer R ? R : never; Je vais décomposer ce type pour vous aider à mieux le comprendre : T extends Function : Ceci vérifie si le type générique T est une fonction. T extends (...args: any[]) => infer R : Cette partie vérifie si T est une fonction avec un nombre indéterminé d’arguments (représentés par ...args: any[]) dont les types sont inconnus. Le mot-clé infer est utilisé pour déduire le type de retour R de cette fonction. ? R : never : Ceci est une expression conditionnelle qui renvoie R (le type de retour déduit) si T est effectivement une fonction avec un type de retour déductible. Sinon, il renvoie never, ce qui signifie qu’il n’y a pas de type de retour valide. Voici un exemple concret d’utilisation : type MyReturnType<T extends Function> = T extends (...args: any[]) => infer R ? R : never; function sum(a: number, b: number) { return a + b; } type SumResult = MyReturnType<typeof sum>; // SumResult est de type number Les boucles Il existe diverses méthodes pour utiliser les boucles lorsque nous manipulons des types, et chacune dépend de la structure de données sur laquelle nous opérons, qu’il s’agisse d’objets, de tuples ou d’unions. Parcourir les types objets avec les types mappés Lorsque nous travaillons avec des types d’objets, nous pouvons utiliser les types mappés pour parcourir leurs propriétés. Ils permettent de créer un nouveau type en appliquant une transformation aux propriétés d’un type existant. Prenons un exemple avec le type OrNull<T> : type OrNull<T> = { : T | null; }; type T1 = OrNull<{ a: number; b: string }>; // { a: number | null; b: string | null } Dans cet exemple, nous utilisons un type mappé pour parcourir les propriétés de T et appliquer une transformation. Le type OrNull<T> prend un type T en entrée et crée un nouveau type avec les mêmes propriétés que T, mais dont les valeurs sont de type T | null. L’expression signifie que pour chaque clé K dans le type T, l’objet résultant aura une propriété avec cette clé. T représente le type de la valeur associée à la clé K dans le type T. Ainsi, lorsque nous utilisons OrNull<{ a: number; b: string }>, nous obtenons le type { a: number | null; b: string | null }. type T1 = OrNull<{ a: number; b: string }>; // { a: number | null; b: string | null } Les types mappés sont puissants car ils préservent la relation entre la clé et la valeur pour chaque propriété de l’objet. De cette façon, nous pouvons appliquer des transformations complexes sur les types d’objets tout en conservant la structure de l’objet d’origine. Examinons un autre exemple. Supposons que nous souhaitions créer un type utilitaire qui filtre les propriétés d’un certain type dans un objet. Voici comment cela pourrait être fait avec un type mappé : type FilterProp<T, F> = { extends F ? K : never]: F; } Le type générique FilterProp<T, F> prend en entrée deux types : T, qui est un objet, et F, qui est le type que nous voulons filtrer. Le type mappé parcourt les propriétés de T et vérifie si le type de la valeur de chaque propriété (T) est un sous-type de F. Si c’est le cas, la propriété est incluse dans le nouvel objet avec le type F. Sinon, la propriété est exclue du nouvel objet en utilisant le type never. Voici un exemple pour illustrer comment utiliser ce type : type User = { id: number; name: string; age: number; email: string; }; type FilteredUser = FilterProp<User, number>; // FilteredUser: { id: number; age: number } Dans cet exemple, nous avons un type User avec quatre propriétés. En utilisant le type utilitaire FilterProp<User, number>, nous créons un nouveau type FilteredUser qui ne contient que les propriétés de type number de l’objet User. Parcourir les tuples avec les types conditionnels récursifs Lorsque nous travaillons avec des tuples, nous pouvons utiliser la récursion pour itérer sur les éléments du tuple. Les types conditionnels récursifs sont une fonctionnalité puissante de TypeScript qui nous permet d’appliquer des transformations complexes sur les tuples. Voici un exemple avec le type Includes<Item, List> qui vérifie si un élément Item est présent dans le tuple List : type Includes<Item, List> = List extends ? Head extends Item ? true : Includes<Item, Tail> // Récurse avec le reste de la liste : false; Voici une explication étape par étape : List extends : Ici, nous vérifions si List est un tuple non vide. Si c’est le cas, nous utilisons infer pour extraire la tête (Head) et la queue (Tail) du tuple ; Head extends Item : Nous vérifions si l’élément Head est du même type que Item. Si c’est le cas, nous avons trouvé l’élément recherché, donc nous renvoyons true. Sinon, nous passons à l’étape suivante ; Includes<Item, Tail> : Si Head n’est pas du même type que Item, nous continuons la récursion avec la queue Tail du tuple pour vérifier si Item est présent parmi les éléments restants. Maintenant, prenons les exemples suivants : type A = Includes<1, >; // true type B = Includes<4, >; // false Dans le premier exemple, nous utilisons le type Includes pour vérifier si l’élément 1 est présent dans le tuple . Le résultat est true, car l’élément 1 est effectivement présent dans le tuple. Dans le second exemple, nous utilisons le type Includes pour vérifier si l’élément 4 est présent dans le tuple . Le résultat est false, car l’élément 4 n’est pas présent dans le tuple. Voici un autre exemple avec le type OnlyStrings<List> : type OnlyStrings<List> = List extends ? Head extends string ? : OnlyStrings<Tail> : []; type A = OnlyStrings<>; // Dans cet exemple, le type OnlyStrings<List> extrait uniquement les éléments de type string du tuple List. Encore une fois, nous utilisons la récursion pour parcourir les éléments du tuple et construire un nouveau tuple avec les éléments qui sont de type string. Parcourir les unions avec les branchements conditionnels L’utilisation de branchements conditionnels avec le mot-clé extends permet de mapper chaque élément de l’union.  Pour illustrer cette technique, prenons un exemple où nous souhaitons transformer une union de types en un objet. type UnionToObject<Key extends string, U> = U extends any ? { : U} : never; Le type générique UnionToObject prend deux paramètres : Key, qui doit être une chaîne de caractères, et U, qui est une union. Ainsi, le type UnionToObject vérifie si U est un sous-type de any (ce qui est toujours vrai) dans ce cas, un objet avec des propriétés calculées à partir du type Key et des valeurs de type U est renvoyé. La syntaxe : U  indique que pour chaque clé K dans le type Key, l’objet résultant aura une propriété avec cette clé et une valeur de type U. Prenons un exemple concret pour illustrer l’utilisation de ce type. Supposons que nous avons des utilisateurs avec des rôles spécifiques tels qu’ »admin », « moderator » et « member ». Nous souhaitons créer un objet associant une propriété role à chaque rôle utilisateur. Tout d’abord, définissons le type de rôle utilisateur : type UserRole = "admin" | "moderator" | "member"; Ensuite, utilisons notre type utilitaire UnionToObject pour créer un type représentant un objet avec une propriété role contenant les rôles utilisateur : type UserWithRole = UnionToObject<"role", UserRole>; Le type UserWithRole est maintenant une union d’objets qui ressemble à ceci : // UserWithRole: { role: "admin" } | { role: "moderator" } | { role: "member" } Grâce à ce type, nous pouvons créer des objets pour différents utilisateurs avec leur rôle respectif. Par exemple : const user1: UserWithRole = { role: "admin", }; const user2: UserWithRole = { role: "moderator", }; const user3: UserWithRole = { role: "member", }; Bonus : Les types de littéraux de gabarits En guise de bonus, je vais vous parler des types de littéraux de gabarits. Qu’est-ce que c’est ? En réalité, il s’agit simplement de l’application des chaînes de caractères de gabarit (template strings) aux types. Concrètement, à quoi cela sert-il ? Pour répondre à cette question, prenons un exemple. Imaginons que nous voulons nous assurer que la syntaxe des propriétés d’un type représentant des dimensions est correcte : interface Dimensions { width: `${number}px`; height: `${number}px`; } const dimensions : Dimensions = { width: '200px', height: '100em', // Type '"100em"' is not assignable to type '`${number}px`'. } Dans cet exemple, nous utilisons des types de littéraux de gabarits pour définir les propriétés width et height de l’interface Dimensions. Cela nous permet de nous assurer que les valeurs de ces propriétés sont des chaînes de caractères contenant un nombre suivi de « px ». Lorsque nous essayons d’assigner une valeur incorrecte à la propriété height, TypeScript détecte et nous signale l’erreur. Pour finir… La création de types personnalisés est extrêmement puissante et permet d’améliorer la qualité et l’efficacité de nos développements, nous venons de voir dans cet article les outils pour l’écriture de ceux-ci. Maintenant, place à l’action ! Rendez-vous sur le dépôt Github « type-challenges » pour vous lancer dans des défis allant du plus facile au plus corsé. Vous allez pouvoir appliquer tout ce que vous avez appris. On se retrouve bientôt pour un nouvel article, mais en attendant, souvenez-vous : la curiosité est la meilleure des qualités quand on est développeur !     Mes formations Markdown de A a Z : creez du contenu clair et professionnel Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance. [...] Lire la suite…
Logging Node.js avec PinoLogging Node.js avec Pino
29 août 2022L’ajout de logs dans une application est essentiel. Ceux-ci permettent entre autres d’identifier les erreurs survenues dans celle-ci, de créer des pistes d’audit, de fournir des informations sur l’application ou encore sur le comportement des utilisateurs, bref la liste peut être encore longue. Et vous savez quoi ? Ben, aujourd’hui on ne va pas du tout parler de la bonne utilisation des logs, mais plutôt partir à la découverte d’une librairie qui va nous faciliter la création de ceux-ci 😜. Le bon vieux module console La façon, la plus courante de logguer son application Node.js consiste à utiliser les méthodes du module console. Celui-ci fournit plusieurs méthodes : console.log : Affiche des messages informatifs sur la sortie standard (stdout) ; console.info et console.debug : Il s’agit d’alias de console.log ; console.error : Affiche des messages d’erreur sur la sortie standard d’erreur (stderr) ; console.warn : Il s’agit d’un alias de console.error ; console.trace : Affiche des messages informatifs avec la trace d’exécution sur la sortie standard d’erreur (stderr). Voyons un petit exemple : function consoleMethods() { console.log('log message') console.info('info message') console.debug('debug message') console.error('error message') console.warn('warn message') console.trace('trace message') } consoleMethods() Les messages suivants s’affichent dans la console : log message info message debug message error message warn message Trace: trace message at consoleMethods (file:///D:/dev/perso/ts/labs/src/main.js:7:11) at file:///D:/dev/perso/ts/labs/src/main.js:10:1 at ModuleJob.run (node:internal/modules/esm/module_job:195:25) at async Promise.all (index 0) at async ESMLoader.import (node:internal/modules/esm/loader:337:24) at async loadESM (node:internal/process/esm_loader:88:5) at async handleMainPromise (node:internal/modules/run_main:61:12) La persistance des logs est importante pour une future analyse de ceux-ci. On peut donc par exemple les enregistrer dans un fichier via une redirection : node src\main.js > app.log On se retrouve donc avec un fichier app.log, dont voici le contenu : log message info message debug message On remarque qu’il nous manque les logs des méthodes error, warn et trace. Comme je l’ai dit, ces méthodes affichent les messages sur la sortie standard d’erreur (stderr). Nous pouvons donc rediriger cette sortie dans un autre fichier error.log : node src\main.js > app.log 2> error.log Notez que le 2 avant le chevron > représente la sortie d’erreur standard. On a donc un fichier error.log avec le contenu suivant : error message warn message Trace: trace message at consoleMethods (file:///D:/dev/perso/ts/labs/src/main.js:7:11) at file:///D:/dev/perso/ts/labs/src/main.js:10:1 at ModuleJob.run (node:internal/modules/esm/module_job:195:25) at async Promise.all (index 0) at async ESMLoader.import (node:internal/modules/esm/loader:337:24) at async loadESM (node:internal/process/esm_loader:88:5) at async handleMainPromise (node:internal/modules/run_main:61:12) Bien que le module console soit utile, celui-ci n’est pas adapté pour la production et présente de nombreuses lacunes :  Gestion du niveau de gravité des messages ; Formatage des logs (par exemple au format JSON); Horodatages ; Gestion de plusieurs destinations des logs (fichiers, serveur de logs ou base de données) etc. C’est pourquoi il devient indispensable de se diriger vers une librairie nous offrant ces fonctionnalités. Il en existe plusieurs plus ou moins connues dans l’écosystème Node.js : Winston ; Bunyan ; Loglevel ; Pino. C’est cette dernière qui nous intéresse dans cet article. Pino c’est quoi ? Pino est une librairie de logging inspiré de Bunyan (du moins au début) qui a vu le jour en 2016. Information totalement inutile, mais son nom n’a pas toujours été Pino puisqu’elle s’appelait au début Sermon. L’un de ses développeurs est Matteo Collina que vous devez sûrement connaître si vous vous intéressez à l’écosystème Node.js puisqu’il est membre du comité technique de Node.js et l’un des créateurs de Fastify (et de plus de 400 autres librairies, rien que ça). Le but premier de Pino est de fournir une librairie de logging axé sur les performances. On n’y pense pas toujours, mais les logs peuvent avoir un impact sur les performances de nos applications. Pour la petite anecdote, l’un de mes clients avait des problèmes de performance sur son service Node.js et l’un des problèmes était la librairie de logs fait maison qui a chaque appel à la méthode de log, divisée par deux les performances de l’application. Bref, ne perdons pas de temps et voyons un petit exemple d’utilisation de Pino. Commençons tout d’abord par son installation : npm install pino Créons un fichier main.js : import pino from 'pino' const logger = pino() logger.info('hello world') Lançons ensuite notre application : node main.js Et tadam ! Le message suivant s’affiche dans votre terminal : {"level":30,"time":1661595397712,"pid":33412,"hostname":"DESKTOP-MF8Q884","msg":"hello world"} Petit tour d’horizon Il est temps de faire un petit tour d’horizon de Pino et découvrir les fonctionnalités que celui-ci nous offre. Niveau de gravité Tous les messages de logs n’ont pas le même niveau d’importance, c’est pourquoi nous avons besoin de niveau de gravité pour ceux-ci. Par défaut il existe 7 niveaux gravité possédant chacun une valeur numérique et une méthode associée : trace (valeur 10) : Généralement utilisé pour le débogage détaillé de l’application. Vous pouvez l’utiliser pour afficher les différentes instructions d’une fonction ou les étapes d’un algorithme par exemple ; debug (valeur 20) : Utilisé pour diagnostiquer les problèmes de l’application ou pour s’assurer que tout fonctionne correctement lors de l’exécution dans l’environnement de test ; info (valeur 30) : Utilisé pour informer des changements d’état de l’application ou de fournir des informations sur son utilisation (démarrage du serveur HTTP, connexion à la base de données réussie, etc.) ; warn (valeur 40) : Utilisé lorsqu’un problème mineur se produit sans mettre en péril le fonctionnement de l’application (connexion impossible à un service tiers, mais avec tentative de reconnexion, paramétrage manquant, mais gérer par l’application, etc.) ; error (valeur 50) : Utilisé lorsqu’un problème important se produit lors d’une opération, mais sans mettre en péril le fonctionnement de l’application (impossibilité de se connecter à un service tiers malgré les tentatives de reconnexion, erreur dans une transaction, etc.) ; fatal (valeur 60) : Utilisé pour indiquer une défaillance globale de l’application qui empêche celle-ci de fonctionner normalement (état incohérent, paramétrages critiques manquants, etc.) ; silent (valeur Infinity) : Utilisé pour désactiver l’ensemble des logs. Il est utile de ne pas enregistrer certains niveaux de logs suivant l’environnement d’exécution ou pour des raisons de performances. C’est pourquoi ces niveaux possèdent un ordre hiérarchique croissant permettant d’enregistrer ou non les logs suivant le niveau minimum choisi : Lors de l’initialisation de Pino, nous pouvons fournir l’option level permettant de définir le niveau de gravité minimum (par défaut il est défini sur info). Voici un exemple, avec le niveau définit à trace permettant d’afficher l’ensemble des niveaux : import pino from 'pino' const logger = pino({ level: 'trace' }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') On obtient le résultat suivant : {"level":10,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Utilisons maintenant la valeur warn: import pino from 'pino' const logger = pino({ level: 'warn' }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') Comme vous pouvez le voir, uniquement les messages concernant les niveaux warn, error et fatal sont affichés : {"level":40,"time":1661611944933,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661611944934,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661611944934,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Niveaux personnalisés Il est également possible d’ajouter des niveaux personnalisés via l’option customLevels. Il s’agit d’un objet ayant comme clé le nom du niveau et comme valeur, la valeur numérique de celui-ci : import pino from 'pino' const logger = pino({ level: 'trace', customLevels: { critical: 55, boom: 70 } }) // Il est toujours possible d'appeler les méthodes correspondant aux niveaux par défaut logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') logger.critical('critical message') logger.boom('BOOM !') On obtient le résultat suivant : {"level":10,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} {"level":55,"time":1661687086999,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"critical message"} {"level":70,"time":1661687086999,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"BOOM !"} Il est possible de n’utiliser que les niveaux personnalisés via l’option useOnlyCustomLevels. Les méthodes correspondant aux niveaux par défaut ne seront pas disponibles : import pino from 'pino' const logger = pino({ level: 'critical', // Nous devons définir le niveau minimum sur un niveau personnalisé customLevels: { critical: 55 }, useOnlyCustomLevels: true }) logger.critical('critical message') logger.fatal('fatal message') // TypeError: logger.fatal is not a function Masquage d’informations Parfois il est nécessaire de masquer certaines informations sensibles dans les logs. Pino offre cette possibilité via l’option redac : import pino from 'pino' const redact = { paths: , // On définit le chemin des clés à masquer censor: '** HIDDEN **' // La chaîne de caractère à afficher à la place de la valeur } const logger = pino({ redact }) logger.info({ body: { username: 'john', password: 'password' }, headers: { host: 'http://example.com', cookie: "We don't want this exposed in logs" } }) // {"level":30,"time":1661640925320,"pid":27672,"hostname":"DESKTOP-MF8Q884","body":{"username":"john","password":"** HIDDEN **"},"headers":{"host":"http://example.com","cookie":"** HIDDEN **"}} Il y a également la possibilité de supprimer les données des logs via l’option remove de l’objet redact : import pino from 'pino' const redact = { paths: , remove: true } const logger = pino({ redact }) logger.info({ body: { username: 'john', password: 'password' }, headers: { host: 'http://example.com', cookie: `We don't want this exposed in logs` } }) // {"level":30,"time":1661640627410,"pid":17068,"hostname":"DESKTOP-MF8Q884","body":{"username":"john"},"headers":{"host":"http://example.com"}} Logger enfant Pino a un concept de logger enfant permettant de spécialiser un logger pour un sous-composant de votre application, c’est-à-dire permettre d’avoir des propriétés dans les messages de logs propres au sous-composant. Imaginons que nous ayons un module mailer et que l’on souhaite afficher dans les logs que ceux-ci proviennent de ce module. Plutôt que d’ajouter cette information manuellement dans chacun des logs nous pouvons utiliser un logger enfant : import pino from 'pino' const logger = pino() const mailerLogger = logger.child({ module: 'mailer' // Affichera "module":"mailer" dans chaque message de log }) mailerLogger.info('message inside mailer') // {"level":30,"time":1661641414746,"pid":42620,"hostname":"DESKTOP-MF8Q884","module":"mailer","msg":"message inside mailer"} Streams (destinations) et transports Jusqu’à maintenant les logs étaient affichés sur la sortie standard. Comment faire pour enregistrer ceux-ci dans un fichier ou les envoyer vers une plateforme de centralisation de logs ? Pino offre deux possibilités, l’utilisation des streams (appelés destinations dans Pino) et les transports. Les streams (destinations) Les streams (destinations) sont des flux qui écrivent les données des logs qu’ils reçoivent vers une destination qui peut être par exemple un fichier. Il s’agit simplement de streams d’écriture au sens Node.js du terme. Par défaut la destination est la sortie standard (stdout). Voyons dès à présent comment écrire dans un fichier : import pino from 'pino' const logger = pino( { level: 'trace' }, pino.destination('./main.log')// On indique le chemin du fichier de log ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') Pino fournit la méthode statique destination permettant de créer un stream d’écriture beaucoup plus performant que celui présent dans le module stream de Node.js. On obtient donc le fichier main.log suivant : {"level":10,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Nous pouvons également écrire les logs vers plusieurs destinations à l’aide de la méthode statique multistream qui prend en paramètres un tableau de stream : import pino from 'pino' const streams = [ { stream: pino.destination(1) // La méthode destination peut également prendre en paramètre un nombre (1 : stdout, 2: stderr) }, { stream: pino.destination('./main.log') } ] const logger = pino( { level: 'trace' }, pino.multistream(streams) ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') Les logs sont ainsi affichés à la fois sur la sortie standard et enregistrés dans un fichier main.log. Il est également possible de personnaliser le niveau de gravité minimum pour chacune des destinations : import pino from 'pino' const streams = [ { level: 'info', stream: pino.destination(1) // La méthode destination peut également prendre en paramètre un nombre (1 : stdout, 2: stderr) }, { level: 'trace', stream: pino.destination('./main.log') } ] const logger = pino( { level: 'trace' // Doit être défini au niveau le plus bas des destinations }, pino.multistream(streams) ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') On obtient ainsi sur la sortie standard les messages suivant : {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} et dans le fichier main.log : {"level":10,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Bien que l’écriture des logs en utilisant les streams s’effectue de manière asynchrone, celle-ci est gérée par le processus principal et utilise donc la même boucle d’événements. La multiplication des destinations peut donc poser des soucis de performances, c’est là que les transports entrent en jeu. Les transports Le traitement des logs que ce soit leurs formatages, leurs transferts vers une destination distante ou l’utilisation de plusieurs destinations devrait, pour des raisons de performances, être séparé du processus principal. C’est justement ce que proposent les transports qui utilisent en interne les worker threads. Reprenons l’exemple d’écriture des logs dans un fichier, mais utilisons cette fois-ci un transport : import pino from 'pino' const transport = { target: 'pino/file', options: { destination: './main.log' } } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') On obtient bien le fichier main.log suivant : {"level":10,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Utilisons maintenant plusieurs transports pour afficher les logs sur la sortie standard et dans un fichier main.log : import pino from 'pino' const transport = { targets: [ { level: 'info', // Nous devons définir le niveau minimum de gravité pour chacune des cibles target: 'pino/file', options: { destination: 1 } // On affiche les lods sur la sortie standard }, { level: 'trace', target: 'pino/file', options: { destination: './main.log' } // On enregistre les logs dans un fichier main.log } ] } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') On obtient le même résultat qu’avec les destinations. On a les messages suivant sur sortie standard : {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} et dans le fichier main.log : {"level":10,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} Il existe de nombreux modules implémentant des transports permettant par exemple d’envoyer les logs sur des plateformes de centralisations : pino-elasticsearch :  Permets d’envoyer les logs sur une instance d’Elasticsearch ; pino-eventhub :  Permets d’envoyer les logs sur la plateforme Event Hub de Microsoft Azure ; pino-socket : Permets d’envoyer les logs vers une destination TCP ou UDP ; pino-mongodb : Permets de stocker les logs dans une base de données MongoDB ;  etc . Mais les transports offrent également la possibilité de formater les logs comme nous allons le voir. Format des messages Les messages de logs sont au format JSON (NDJSON pour être exact) donc si on logue un message qui est une chaîne de caractères et un autre un objet comme suit : import pino from 'pino' const logger = pino() logger.info('info message') logger.info({ hi: { value: 'Hello world !' }, name: 'John Doe' }) Nous obtenons le résultat suivant : {"level":30,"time":1661623090217,"pid":40276,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":30,"time":1661623090218,"pid":40276,"hostname":"DESKTOP-MF8Q884","hi":{"value":"Hello world !"},"name":"John Doe"} Les données contenues dans le message JSON sont les suivantes : level : Il s’agit de la valeur numérique du niveau de gravité du log ; time : Il s’agit du timestamp en millisecondes du log ; pid : Il s’agit de l’identifiant de processus de notre application Node.js ; hostname : Il s’agit du nom d’hôte de la machine sur laquelle l’application Node.js est exécutée ; msg : Il s’agit simplement de la chaîne de caractères passée à l’une des méthodes de log ; Lorsqu’un objet est passé en paramètre de l’une des méthodes de log, celui-ci est converti en chaîne JSON et ajouté au message du log. Les transports nous permettent entre autres de modifier le contenu d’un message de logs, c’est d’ailleurs la méthode recommandée. Il existe ainsi quelques transports développés par l’équipe de Pino ou la communauté permettant de transformer nos messages de logs vers un autre format comme Syslog par exemple. Mais nous pouvons également rendre les messages plus lisibles lors du développement ou même écrire notre propre transport. Message plus lisible lors du développement Le format JSON peut vite devenir lourd lors du développement c’est pourquoi on aimerait avoir des messages plus lisibles pour un être humain. Nous allons pour cela nous intéresser au module pino-pretty qui remplit parfaitement cette tâche. Vous pouvez également regarder, si vous êtes curieux, du côté de pino-colada (oui les développeurs ont beaucoup d’imagination…). Il existe trois façons d’utiliser pino-pretty. Utilisation de la CLI La première, celle qui est recommandée, est de transmettre la sortie du programme Node.js à la commande pino-pretty via l’opérateur pipe | : node src\main.js | npx pino-pretty On obtient ainsi de jolis messages de logs : Utilisation comme un transport La seconde est de l’utiliser comme transport au sein de Pino. Nous avons besoin pour cela d’installer pino-pretty : npm install -D pino-pretty Puis d’ajouter un transport comme option à notre logger : import pino from 'pino' const logger = pino({ level: 'trace', transport: { target: 'pino-pretty', options: { // Voir https://github.com/pinojs/pino-pretty#options } } }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') Utilisation comme un stream (destination) La dernière façon est de l’utiliser comme un stream. Nous avons également besoin d’installer pino-pretty : npm install -D pino-pretty Et d’utiliser celui-ci comme suit : import pino from 'pino' import pretty from 'pino-pretty' const stream = pretty({ // Voir https://github.com/pinojs/pino-pretty#options }) const logger = pino( { level: 'trace' }, stream ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') Créer son propre transport de formatage Nous allons créer notre propre transport permettant de formater un message de log. Pour cela nous aurons besoin de deux modules : pino-abstract-transport : Un module facilitant l’écriture des transports ; sonic-boom : Un module fournissant un stream d’écriture performant. Installons donc ces deux modules : npm install sonic-boom pino-abstract-transport Créons ensuite un fichier my-transport.js et écrivons le code basique d’un transport : import build from 'pino-abstract-transport' import SonicBoom from 'sonic-boom' import { once } from 'events' export default async function (opts) { // On crée notre destination (par défaut la sortie standard) const destination = new SonicBoom({ dest: opts.destination || 1, // On peut passer en option une destination (fichier ou autre). Par défaut c'est la sortie standard stdout sync: false }) // On attend que le stream soit prêt await once(destination, 'ready') return build( async function (source) { // obj est l'objet contenant les données d'un log for await (const obj of source) { // On transforme notre objet en chaîne de caractères et vers l'écrit dans la destination const toDrain = !destination.write(`${JSON.stringify(obj)}\n`) // Si le buffer du stream de destination est plain on attend que celui-ci se vide avant de continuer. if (toDrain) { await once(destination, 'drain') } } }, { async close() { // On ferme le stream de destination... destination.end() // et on attend la fermeture de celui-ci await once(destination, 'close') } } ) } Utilisons ensuite celui-ci : import pino from 'pino' const transport = { target: './my-transport.js' } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') /* {"level":10,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} */ Vous pouvez modifier comme vous le souhaitez le format du log à écrire sur le stream de destination. Une version très simplifié de pino-pretty pourrait par exemple ressembler à ceci : import build from 'pino-abstract-transport' import SonicBoom from 'sonic-boom' import { once } from 'events' export default async function (opts) { // On créer notre destination (par défaut la sortie standard) const destination = new SonicBoom({ dest: opts.destination || 1, sync: false }) const levels = opts.levels || { 60: 'FATAL', 50: 'ERROR', 40: 'WARN', 30: 'INFO', 20: 'DEBUG', 10: 'TRACE' } // On définit notre chaîne de sortie et les clés à changer par les valeurs du log const outputFormat = ' #level (#pid): #msg' const regex = /#time|#level|#pid|#msg/gi // On attend que le stream soit prêt await once(destination, 'ready') return build( async function (source) { // obj est l'objet contenant les données d'un log for await (const obj of source) { // On remplace les clés contenues dans la chaîne de sortie avec les valeurs contenues dans le log const ouputString = outputFormat.replace(regex, function (matched) { const key = matched.slice(1) let value = obj if (key === 'time') { value = new Date(value).toISOString() } if (key === 'level') { value = levels } return value }) const toDrain = !destination.write(`${ouputString}\n`) // Si le buffer du stream de destination est plain on attend que celui-ci se vide avant de continuer. if (toDrain) { await once(destination, 'drain') } } }, { async close() { // On ferme le stream de destination... destination.end() // et on attend la fermeture de celui-ci await once(destination, 'close') } } ) } Si nous testons notre nouveau transport, nous obtenons le résultat suivant : TRACE (23156): trace message DEBUG (23156): debug message INFO (23156): info message WARN (23156): warn message ERROR (23156): error message FATAL (23156): fatal message L’utilisation d’un transport est recommandée pour formater un log, puisque les modifications sont isolées du reste de l’instance du logger, elle ne concerne donc que le transport. Mais il est tout à faire possible de modifier les logs de manière globale comme nous allons le voir. Afficher le label du niveau Vous pouvez si vous le souhaitez, afficher le label du niveau à la place de sa valeur numérique. Pino accepte une option formatters qui est un objet contenant des fonctions permettant de formater les données du log. Parmi ces fonctions nous avons la fonction level qui permet de modifier le format du niveau : import pino from 'pino' const formatters = { level: (label, number) => { return { level: label // On renvoie le label du niveau plutôt que sa valeur numérique } } } const logger = pino({ formatters }) logger.info('info message') // {"level":"info","time":1661635988640,"pid":14980,"hostname":"DESKTOP-MF8Q884","msg":"info message"} Mais rien ne vous empêche également de changer la clé level par une autre clé : import pino from 'pino' const formatters = { level: (label, number) => { return { severity: label.toUpperCase() } } } const logger = pino({ formatters }) logger.info('info message') // {"severity":"INFO","time":1661636110335,"pid":18528,"hostname":"DESKTOP-MF8Q884","msg":"info message"} Modifier le format de la date Le format de la date peut également être modifié via l’option timestamp. Il est possible soit de passer un booléen pour afficher ou non la date, soit une fonction qui renvoie une chaîne de caractères qui sera concaténé au contenu du message du log : import pino from 'pino' const logger = pino({ /* On définit la clé (ici time) précédée d'une virgule puisque cette chaîne sera concaténée avec le reste du message du log suivie de sa valeur (ici la date au format ISO) */ timestamp: () => `,"time":"${new Date().toISOString()}"` }) logger.info('info message') // {"level":30,"time":"2022-08-27T21:48:01.608Z","pid":13380,"hostname":"DESKTOP-MF8Q884","msg":"info message"} Pino fournit également des fonctions pour le formatage de la date. L’exemple suivant, produit le même résultat : import pino from 'pino' const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }) logger.info('info message') // {"level":30,"time":"2022-08-27T21:48:01.608Z","pid":13380,"hostname":"DESKTOP-MF8Q884","msg":"info message"} Sérialisation d’objets Pino offre la possibilité de définir des fonctions permettant une sérialisation personnalisée d’objets via l’option serializers. Cette option est un objet dont les clés sont les propriétés des objets loggués pour lesquelles les fonctions de sérialisations associées devront être appliquées. Voyons un exemple : import pino from 'pino' const serializers = { hi: ({ value }) => ({ value: value.toUpperCase() }), // Pino fournit des fonctions de sérialisation -> https://github.com/pinojs/pino/blob/master/docs/api.md#pino-stdserializers err: pino.stdSerializers.err } const logger = pino({ serializers }) logger.info({ hi: { value: 'Hello world' } }) logger.error(new Error('oops !')) // {"level":30,"time":1661638225474,"pid":23828,"hostname":"DESKTOP-MF8Q884","hi":{"value":"HELLO WORLD"}} // {"level":50,"time":1661638225474,"pid":23828,"hostname":"DESKTOP-MF8Q884","err":{"type":"Error","message":"oops !","stack":"Error: oops !\n at file:///D:/dev/perso/ts/labs/src/main.js:21:14\n at ModuleJob.run (node:internal/modules/esm/module_job:195:25)\n at async Promise.all (index 0)\n at async ESMLoader.import (node:internal/modules/esm/loader:337:24)\n at async loadESM (node:internal/process/esm_loader:88:5)\n at async handleMainPromise (node:internal/modules/run_main:61:12)"},"msg":"oops !"} La première fonction de sérialisation permet de modifier la propriété hi de n’importe quel objet passé en paramètre d’une méthode de log. La seconde, fournie par Pino, permet de sérialiser les erreurs en ne gardant que son type, son message et sa stack d’exécution. D’autres fonctions de sérialisation sont fournies par Pino et permettent entre autres de traiter les objets correspondant aux requêtes et aux réponses HTTP. Utilisation dans un framework HTTP Pino s’intègre parfaitement avec les principaux frameworks HTTP. Il est par exemple de base intégré à Fastify : import fastify from 'fastify' const app = fastify({ logger: true }) app.get('/', async (request, reply) => { request.log.info('info message') return { hello: 'world' } }) try { await app.listen({ port: 3000 }) } catch (err) { app.log.error(err) process.exit(1) } /* {"level":30,"time":1661684825028,"pid":40836,"hostname":"DESKTOP-MF8Q884","msg":"Server listening at http://127.0.0.1:3000"} {"level":30,"time":1661684825031,"pid":40836,"hostname":"DESKTOP-MF8Q884","msg":"Server listening at http://:3000"} {"level":30,"time":1661684828050,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","req":{"method":"GET","url":"/","hostname":"localhost:3000","remoteAddress":"::1","remotePort":55769},"msg":"incoming request"} {"level":30,"time":1661684828051,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","msg":"info message"} {"level":30,"time":1661684828055,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","res":{"statusCode":200},"responseTime":4.117699861526489,"msg":"request completed"} */ Mais il existe également des modules pour l’intégrer facilement à Express, Restify ou encore Nest.js. Je vous invite donc à aller lire la documentation si vous voulez plus d’information. Benchmark Je vous ai dit en début d’article que Pino était l’une des librairies de log Node.js la plus performante de l’écosystème. Mais à quel point ? Je vous laisse juger par vous-même avec le benchmark de la documentation officielle. Pour finir… Pino propose de nombreuses fonctionnalités intéressantes et utilise un minimum de ressources. L’équipe derrière est la même que Fastify et dispose d’un excellent suivi de la part de l’équipe de développement puisque Pino possède des versions LTS assurant un support sur le long terme. Nous avons fait dans cet article, qu’un bref tour d’horizon de Pino, il n’est pas possible de tout couvrir dans un seul article, d’autres fonctionnalités existent c’est pourquoi je vous laisse le soin de poursuivre la découverte en allant lire la documentation officielle.   Mes formations Markdown de A a Z : creez du contenu clair et professionnel Apprends a ecrire de la documentation propre, lisible et professionnelle grace au Markdown, du debutant a avance. [...] Lire la suite…

Code Heroes

Deviens un vrai héros du code
Revenir en haut
© 2026 CodeHeroes — Tous droits réservés
Code Heroes
  • Retrouve-moi sur X
  • LinkedIn
  • Github
  • Mes formations
  • Mes formations
  • Politique de confidentialité