Rock The Web with Node.js

The Node Package Manager

Packager son projet

(*) dans l'ecosystème JavaScript en général

moment = manipulation de dates, underscore/lodash = manipulation des tableaux/collections, mongodb-native = driver MongoDB, js-yaml = parser YAML...

Dans une société, il est courant de réaliser des librairies pour des briques logicielles internes (connexion au DAS...), ou pour mutualiser un savoir-faire métier

Même si votre application n'est pas destinée à être réutilisée, la packager est une nécessité

NPM apporte des conventions (package.json) et des outils pour la distribution (registry central + client de packaging)

Exemples de structure

2 points communs: package.json et node_modules sont toujours à la racine

Il est courant de mettre le code du client et du serveur dans le même package : en NodeJS, le serveur static et le même que le serveur d'API/pages web dynamiques

NPM gère les dépendances coté serveur majoritairement, mais diversifie de plus en plus son écosystème, malgré tout d'autres outils permettent de gérer les dépendances coté client (bower, browserify, webpack et d'autres encore...)

Le versionning, avec git par exemple, sera aussi à la racine

Attention ! En l'absence d'un .npmignore, NPM utilisera le .gitignore éventuel

Ce ne sont que des exemples : il n'y a pas de dogme, et on trouve ne nombreuses variantes, souvient liée aux outils de build ou au serveur

Le fichier package.json

name et version sont les deux seuls champs obligatoires les plus importants de votre descripteur

name doit être en minuscule, sans lettres exotiques, raisonnablement court et descriptif. Il sera dans des require() et fera partie d'une url !

version doit respecter le format semver

description, keywords permettront aux développeurs qui cherchent des librairies sur le registry NPM de trouver la vôtre

author, constributors, license relètent l'état de la communauté autour de votre projet

repository, homepage, bugs sont des url affichées sur le site du registry NPM pour simplifier l'accès à votre code

dependencies, devDependencies, peerDependencies, bundledDependencies et optionalDependencies décrivent les dépendances de votre projet. Nous y reviendrons juste après

Dans le cas d'une librairie, main indique quel est le module qui sera chargé lors d'un require('nom-du-projet');

bin déclare les éventuel exécutables, indispensable pour un projet de type CLI

scripts permet de customiser les phases de packaging et d'ajouter ses propres phases

engines, engineStrict, cpu, os permettent de déclarer des restrictions de portabilité

Le fichier package-lock.json

ça évite les cas où un projet ne fonctionne plus car une librairie a introduit (par erreur) un breaking change

Déclarer ses dépendances

Il existe des des opérateurs <, <=, >, >=, NPM prendra la version la plus haute disponible

Il y a aussi des jokers *, x

Enfin l'opérateur chapeau ~x.y.z : la version x.y la plus haute (z au minimum)

Un article détaillé sur le comportement de ~ et ^

Déclarer ses dépendances

Publier sur le registry

Cette vidéo explique bien le fonctionnement

Contrairement à l'écosystème Java, il n'y a qu'un seul dépôt central pour les packages NodeJS. Il existe des mirroirs qui sont en train de disparaitres avec la fiabilisation du dépôt central, ne les utilisez pas.

Il est possible de créer des dépôts privés (NPM Entreprise), ou de payer un espace privé sur npmjs.com (a venir)

Attention ! vous ne devez (pouvez) pas republiez une version déjà existante !

D'autres clients (bower...) utilisent aussi ce dépôt

Le client NPM

La récupération d'une dépendance revient à télécharger le code, et réaliser un npm install dans le dossier de manière transitive

Les commandes npm test/start/restart/stop exécutent les scripts preX, X et postX lorsqu'ils existent

La commande npm run-script X exécute les scripts preX, X et postX de la même manière

Certaines phases (postinstall, prepublish...) peuvent être customisées avec les scripts du descripteur

Il n'y a pas cycle de vie ni de phases ordonnancées

Les scripts sont dépendants de la plateforme

Il peut y avoir une compilation de code C/C++ lors de l'installation d'une dépendance

A la fin de l'installation du package si celui-ci contient des executables (package.json/bin), ils sont enregistrés en global au niveau système si npm est lancé avec l'option -g

NPM n'est pas un build-system : il ne permet pas de déclarer et d'ordonnancer des tâches de mettre en oeuvre des configuration par environnement, et d'être un outil de compilation/packaging portable. Il propose des mécanismes minimalistes pour le packaging, dans l'unique optique de la publication

Behaviour Driven Development !

Mocha : le test runner

describe() et it() sont des fonctions globales, définies par mocha. Il en existe d'autre que nous verrons plus loins

Mocha propose l'interface BDD (par défaut), celle de l'exemple. Il propose aussi l'interface TDD, où describe() = suite() et it() = test()

Nous verrons plus loin en quoi l'interface TDD est moins intéressante

Mocha fonctionne en 2 phases : la détection des tests :

  1. il exécute tous les contenus de describe pour avoir la liste des it()
  2. il exécute les it() un à un

assert est un module NodeJS que nous n'utiliseront pas à cause de ses faibles capacités de reporting

Mocha est totallement décoréllé de NodeJS, et s'utilise très bien dans un navigateur

Il existe différents reporters, essayer le reporter nyan !

Chai : les assertions

La combinaison Mocha mode BDD + Chai mode expect donne suffisamment de lisibilité au code pour qu'il reflète des spécifications fonctionnelles ! Vous n'aurez plus besoin de documenter vos assertions et vos cas de tests, car ils doivent être suffisamment parlants

Tout comme Mocha, Chai est totalement décorrélé de NodeJS, et s'utilise très bien dans un navigateur, il a malgré tout une API inconsistante

Le style d'assertion should est mal supporté sur IE car il instrumente les objets testés

Le style d'assertion assert est vraiment trop has-been :)

Une assertion Chai est une chaine de mots dont certains sont sans effet (to, be, been, is, that, and, has, have, with, at, of, same), et d'autres déclenchent une validation (instanceof(), lengthOf(), include()...), qui si elle échoue lèvera une exception

Le message d'exception reprend les mots précédents pour être suffisamment parlant : expected [ 1, 2, 3 ] to have a length of 4 but got 3

Les assertions disponibles

Liste complète disponible sur la documentation officielle

Question de syntaxe, vous pouvez préférer
expect(x).to.be.equal(y)

Quelle que soit l'assertion suivante, not aura pour effet d'attendre son contraire

Dans les tests d'égalité, on trouve aussi
expect(x).to.be.true

Si le chemin vers la propriété contient des '.' ou des '[]', il faut utiliser deep

expect(x).to.have.property(name, value)
permet de tester l'existance et la valeur. On évitera cette forme, car si value est undefined, impossible d'être certains que la propriété existe bel et bien

contain et include sont strictement synonymes

Les tests sur les exceptions portent sur l'exécution d'une fonction !

hooks et exclusions

Ces "hooks" sont liés au describe() englobant. Ils révèlent le principal intérêt des describe() : grouper les pré-requis et le nettoyage de plusieurs tests

Il peut y avoir plusieurs fois le même "hook" dans un même describe() : ils seront exécutés dans l'ordre de déclaration

Attention : ces "hooks" sont exécutés dans la 2ème phase, en même temps que les it()!

Pause

Tests en actions

  1. Vous allez tester le module tp/fs/fs_utils.js. Dans tp/fs utilisez npm init pour générer un descripteur package.json
  2. Toujours dans tp/fs, installez mocha et chai: npm install --save-dev mocha chai
  3. Dans un module tp/fs/test/fs_utils.js réalisez les tests suivants :
    1. FS utils getDirContent should return current folder content with absolute paths
    2. FS utils getDirContent should fail when reading an unknown folder
    3. FS utils getDirStat should return alphabetically ordered current folder
    4. FS utils getDirStat should fail when reading an unknown folder

    Il est plus pratique d'installer mocha de manière globale avec npm install -g mocha dans le contexte de cet exercice, sinon on le mettra en devDependencies du projet

Client Http Request

Request

Ce module est l'un des plus anciens (mai 2010) et plus utilisé de la communauté NodeJS, car il palie à la faible utilisabilité du client Http par défaut.

Le body est soit une chaîne de caractère, soit du JSON, soit un buffer (binaire) en fonction des différentes options passée lors de la requête.

Configuration des requêtes

Les options possibles dépendent du verbe utilisé : l'option body n'a pas de sens pour un GET.

L'option json permet d'ajouter les en-têtes content: application/json et accept: application/json, réalise la sérialisation du corps éventuel de la requête et la désérialisation du corps de la réponse.

Pour indiquer que la requête ne DOIT PAS utiliser de proxy, malgré la présence de variable d'environnement, il faut spécifier null ou false dans l'option proxy.

D'une manière générale, il est plus prudent de toujours spécifier l'option proxy, et de mettre vous même la valeur dans un fichier de configuration.

Streaming et formulaires

Dans ces exemples, on ne gère pas les cas d'erreurs.

Les stream en lecture ont un évènement spécial response lorsque les en-têtes de la réponse sont disponibles. L'évènement error doit être écouté pour gérer les erreurs de réception.

Lorsqu'on envoi un formulaire, le bon content-type est positionné.

L'objet FormData qui modélise le formulaire est accessible en retour de la méthode request.post(...).form();. Rappelez vous que l'envoi est asynchrone : le formulaire peut être modifié immédiatement après l'appel à post().

On peut aussi envoyer un formulaire multipart/related avec l'option multipart de request.post().

La librairie request

  1. Créer un nouveau fichier tps/request/request.js qui appelle l'API http://api.icndb.com/jokes/random et logguer la joke retournée.
    1. Attention au proxy
  2. Modifier la requête pour remplacer le prénom de la fact par votre prénom (voir la doc de l'API : http://www.icndb.com/api/)
Pause

La librairie express

express

  • Utilisation de http.Server avec les améliorations suivantes :
    1. Routage des url par chemin et verbe HTTP
    2. Parsage des paramètres d'entrée
    3. Gestion des fichiers statiques
    4. Templating HTML
    5. Et c'est tout !
  • Mécanisme de middlewares pour ajouter des fonctionnalités

Le router permet d'associer un traitement spécifique (une fonction) à une url et un verbe bien particulier.

La requête d'entrée est parsée (JSON) et éventuellement validée avec un middleware.

La constitution des réponses (en-têtes, cache HTTP...) est facilitée, et des middlewares existent pour le contenu statique (fichier, dossiers).

Le mécanisme de templating permet d'utiliser des templates handlebars (par exemple) pour constituer la réponse avec des placeholders.

Une librairie volontairement simple et réduite en terme de fonctionnalité, mais les middlewares permettent d'ajouter des fonctionnalités (gzip, authent...)

La structure en middlewares rend son utilisation simple à comprendre et facile à faire évoluer

Anatomie du serveur

  • const express = require('express');
    const app = express();
    
    app.get('/:name', (req, res) => {
      res.send('Hello ' + req.params.name);
    });
    
    app.listen(3000, () => {
      console.log('Example app listening on port 3000!');
    });
  • Les routes peuvent contenir des paramètres
  • req étend http.IncomingMessage
  • res étend http.ServerResponse

Les paramètres de chemins peuvent être facultatif (suffixé par ?).

req encapsule l'objet IncomingMessage de Node, et propose ses propres propriétés comme les paramètres de requêtes extraits (query), le corps de la requête parsé (body)

resp est un objet réponse permettant de modifier les entêtes de réponse (header(), type(), le code HTTP (status()), les cookies (cookie()), les redirections...

Les middlewares

  • Liste de fonctions à exécuter pour chaque requête, dans l'ordre où elles sont définies
  • Par exemple:
    (req, res, next) => {
      next();
    };
  • Un middleware peut:
    • modifier la requête ou la réponse
    • répondre immédiatement au client et stopper le traitement de la requête
    • émettre une erreur
  • Plein de middlewares ! http://expressjs.com/en/resources/middleware.html

next() n'est pas obligatoire

manipuler les variables req et res

Appeler res.end() ou res.send() pour mettre fin au traitement

Appeler next() pour passer au middleware suivant, ou next(err) pour passer au middleware d'erreur

Le middleware d'erreur

  • appelé en cas d'erreur (appel à next() avec une erreur en paramètre)
  • par exemple:
    (err,req, res, next) => {
      res.status(500).send('an error occured');
    };
  • le paramètre next est obligatoire (même s'il n'est pas utilisé)

Utilisation d'un middleware

  • app.use([path,] function [, function...]):
    app.use((req, res, next) => {
      console.log('start request %s %s', req.method, req.originalUrl);
      next();
    });
  • app.get(path, function [, function...]):
    app.get('/hello', (req, res, next) => {
      res.send('Hello world!')
    });
  • existe pour toutes les methodes: post(), put()...

Le path est optionnel pour use(), plusieurs middlewares peuvent être passés en paramètre

Express !

  1. Créez un serveur express dans un module tps/express/server.js.
  2. Ajouter les fonctionnalités suivantes avec les tests associés (dans un fichier tps/express/test/server.js)
    1. Le serveur démarre et renvoit une 404 sur n'importe quelle url
    2. Le serveur répond 'Hello XXX' quand on appelle l'URL '/hello/XXX', l'utilisateur pouvant remplacer XXX par le prénom de son choix
    3. Le serveur répond une erreur 400 si le prénom passé en paramètre fait moins de 3 caractères. La réponse sera en JSON et le message d'erreur sera dans une propriété 'message'
    4. Logguer toutes les requêtes en utilisant morgan (pas de test à faire)
  3. Bonus: Utiliser un moteur de template pour que l'URL '/hello/XXX' retourne une page au format HTML

Pour créez rapidement son fichier package.json : > npm init.

Ajouter rapidement des modules externes : > npm install --save(-dev) moduleA moduleB.

Le fichier source doit exporter le serveur sans le démarrer : c'est le test qui appelera start() et stop().

Pour invoquer une url du serveur depuis les tests, utilisez la librairie request.

Pensez a tester les codes de retour HTTP, et les entêtes importants. La réponse parsée est disponible depuis le test avec res.body.

Mettre morgan en premier pour bien logguer toutes les requêtes

Dodge callback hell with async

"Pyramid of Doom" ??

fs.readdir(source, (err, files) => {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach((filename, fileIndex) => {
      console.log(filename)
      gm(source + filename).size((err, values) => {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach((width, widthIndex) => {
            height = Math.round(width / aspect)
            console.log('resizing '+filename+'to '+height+'x'+height)
            this.resize(width, height).write(destination+'w'+width+'_'+filename,
              (err) => {
                if (err) console.log('Error writing file: ' + err)
              })
          }.bind(this))
        }
      })
    })
  }
})

Exemple proposé par callbackhell.com

Présentation de async

Asynchronisme et tableaux

  • Appliquer un traitement asynchrone à un tableau
    async.map(['file1','file2','file3'], (file, next) => {
      // appliqué de manière asynchrone sur tous les éléments du tableau
      // la signature de next est next(err, result)
      fs.readFile(file, next);
    }, (err, results) => {
      // err est la première erreur renvoyée
      // results est le tableau (ordonné) des résultats intermédiaires
    });
  • Tous les traitements sont déclenchés en parallèlemapLimitmapSeries
  • Le callback de fin est déclenché... à la fin
  • Le premier échec déclenche le callback de fin, et ignore les résultats en cours

mapLimit(arr, n, ...) lance les N première tâches et attend d'en avoir terminé avant d'en relancer d'autres

mapSeries() lance les tâches les unes à la suite des autres, dans l'ordre. C'est équivalent à mapLimit(arr, 1, ...)

Les autres fonctions :

  • each(), eachLimit(), eachSeries() qui invoque une fonction asynchrone sur chaque élément
  • filter(), filterSeries() qui ne conserve que les élements passant le test asynchrone
  • reject(), rejectSeries() qui conserve les élements ne passant pas le test asynchrone
  • reduce(), reduceRight() identique à Array.reduce() en mode asynchrone
  • sortBy() qui extrait de manière asynchrone une valeur servant pour le tri qui intervient dans un 2ème temps
  • detect(), detectSeries() renvoie le premier élément passant le test asynchrone (attention, non ordonné !)
  • some() renvoie true si au moins un élément passe le test asynchrone (attention, non ordonné !)
  • every() renvoie true si tous les éléments passe le test asynchrone
  • concat(), concatSeries() renvoie la concaténation des résultats de la fonction asynchrone appliquée à chaque élément

Asynchronisme et functions

  • Gérer un flow de fonctions asynchrones
    async.parallel([
      (done) => {
        // la signature de done est done(err, result)
        fs.readFile('in.txt', done);
      }, (done) => {
        fs.writeFile('out.txt', 'finished !', done);
      }
    ], (err, results) => {
        // err est la première erreur renvoyée
        // results est le tableau (ordonné) des resultats intermédiaires
    });
  • Tous les traitements sont déclenchés en parallèleparallelLimitseries
  • Le callback de fin est déclenché à la fin,
    ou suite au premier échec

il est aussi possible de spécifier un objet et pas un tableau :

async.parallel({
  one: (done) => {
    // la signature de done est done(err, result)
    fs.readFile('in.txt', done);
  }, two: (done) => {
    fs.writeFile('out.txt', 'finished !', done);
  }
], (err, results) => {
    // err est la première erreur renvoyée
    // results reprends les clé de l'objet initial: {one: '', two: ''}
});
Attention néanmoins, il n'y a pas d'ordre garanti...

Asynchronisme et functions

  • whilst(), doWhilst(): executer une function asynchrone tant que le test synchrone est vrai
  • until(), doUntil(): executer une function asynchrone jusqu'a ce que le test synchrone soit vrai
  • seq(), compose(), waterfall(): passe les résultats de l'étape N à l'étape N+1
  • queue(), cargo(): pool d'exécution asynchrone

queue(), priorityQueue() permet d'exécuter des tâches en parallèle jusqu'à une limite de concurrence donnée

Ces pools d'exécution tournent en tâche de fond jusqu'à ce qu'on les stoppe

Encore plein d'autres patterns proposés plus ou moins complexes

Utiliser async

  1. Maintenant que tps/fs/fs_utils.js est testé, nous allons le refactoriser
  2. Refactorisez l'implémentation de getDirStat en utilisant async
  3. TODO test avec des fonctions, 20 minutes
Pause

Les promesses

Une promesse ?

  • Classe présente dans Javascript (ES6, NodeJS >= 6.5.0)
  • Le constructeur : new Promise((resolve, reject) => { ... });
    • resolve() doit être appelé en cas de succès (accepte en paramètre le résultat du traitement)
    • reject() doit être appelé en cas d'erreur (accepte en paramètre l'erreur)
  • Le prototype contient les méthodes :
    • then() définit une fonction appelée lors que la promesse est résolue
    • catch() définit une fonction appelée lors que la promesse est rejetée

Une promesse ? L'exemple

  • Création de la promesse
    const request = require('request');
    const getHello = () => {
    return new Promise((resolve, reject) => {
      request('http://google.com/', (err, res) {
        if(err) {
          reject(err);
          return;
        }
        resolve(res);
      });
    });

Une promesse ? L'exemple

  • Utilisation de la promesse
    getHello().then((res) => {
      console.log(res.statusCode);
    })
    .catch((err) => {
      console.log(err);
    })
    

Les états de la promesse

Le chaînage

  • Exécution en série
    doSomething().then(function(result) {
      // doSomethingElse retourne une promesse
      return doSomethingElse(result);
    })
    .then(function(newResult) {
      // doThirdThing retourne une promesse
      return doThirdThing(newResult);
    })
    .then(function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    })
    .catch(failureCallback);
              
  • Chaînage possible si la fonction dans then() retourne une promesse
  • Equivalent pour les promesses de async.series()
  • source : MDN

Promesses : autres outils

  • Promise.all
    • Accepte en paramètre un tableau de promesses
    • Permet d'exécuter plusieurs promesses en parallèle
    • Retourne une promesse, rejetée à la première erreur, ou résolue quand toutes les promesses en entrée sont résolues
    • Equivalent pour les promesses de async.parallel()
  • A partir de NodeJS 8 : util.promisify() et util.callbackify() pour convertir des fonctions "callback-style" en fonction "promise-style" et inversement

util.promisify() nécessite que le callback soit le dernier paramètre de la fonction

Async / Await

async

  • A partir de NodeJS 8
  • async est un nouveau mot-clé à placer devant une déclaration de fonction
  • La fonction va alors retourner une promesse qui sera
    • rejetée si une erreur survient
    • résolue avec le résultat retourné par la fonction
  • Exemple:
    const doHello = async (name) => {
      if(! name) {
        throw 'name is missing';
      }
      return `Hello ${name}`
    };

await

  • await est un nouveau mot-clé à placer devant une promesse
    • si elle est résolue, son résultat est retourné
    • si elle est réjetée, une erreur survient (équivalent du throw)
  • await ne peut être utilisé que dans une fonction async
  • Exemple:
    const doHelloTwice = async () => {
      const hello1 = await doHello('bob');
      const hello2 = await doHello('alice');
      return `${hello1}, ${hello2}`;
    };

async/await : les avantages

  • Meilleure gestion des erreurs
    • Les erreurs synchrones et asynchrones sont gérées de la même façon
  • Code simplifié et plus lisible

Utiliser async/await

  1. Maintenant que tps/fs/fs_utils.js est testé, nous allons le refactoriser
  2. Refactorisez l'implémentation de getDirContent en utilisant async/await
  3. Mettre à jour les tests :
    1. pour le test de succès : voir la documentation de mocha
    2. pour le test d'erreur, utiliser le plugin chai-as-promised
  4. Bonus : refactorisez l'implémentation de getDirStat en utilisant async/await

Récap du troisième jour

  • Node Package Manager
  • TDD avec Mocha et Chai
  • Client évolué avec Request
  • Serveur évolué avec Express
  • Algorithmie asynchrone avec Async

And now, let's ROCK'N ROLL !

Crédits photos