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é

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 ^

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 !

Lab : le test runner

Il est fréquent de définir des variable pour describe() et it() pour éviter le préfix lab.

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

Il y a 2 phases lors de l'exécution des tests :
  1. invocation des fonctions des describe pour avoir la liste des it() (déclaration des tests)
  2. invocation séquentielle des fonctions des it() (réalisation des tests)

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

Lab ou Mocha ?

Mocha utilise des variables globales pour describe(), it()..., une syntaxe raccourcie pour skip et only, et le callback done est facultatif.

Istanbul est un bon outil de couverture de code utilisable avec Mocha.

Si votre projet requiert du code JS coté server et client, mocha est un bon choix pour harmoniser les tests des deux cotés.

Si votre projet utilise Hapi, Lab est plus approprié car il gère mieux les éventuelles erreurs.

Chai : les assertions

La combinaison Lab 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 lab et chai: npm install --save-dev lab 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
    avec une couverture de test de plus de 90%
  4. Lancement avec lab -r html -o coverage.html pour voir la couverture

Il est plus pratique d'installer lab de manière globale avec npm install -g lab 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 forumulaires

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().

TODO mise en oeuvre request

Pause

Le framework Web Hapi

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, multi-part) et éventuellement validée avec Joi.

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

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

Le mécanisme de plugin permet d'augmenter son serveur en lui ajoutant des capacités particulières (authentification, logging, partage de session...)

Contrairement à Express qui se veut simple et maléable, Hapi propose une structure bien définie pour aider les équipes volumineuses à développer des serveurs applicatifs importants.

Anatomie du serveur

Les paramètres de chemins peuvent être facultatif (suffixé par ?) ou en plusieurs parties ({user*2} matchera john/doe).

request 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é (payload)

reply() renvoit un objet réponse permettant de modifier les entêtes de réponse (header(), type(), etag(), charset(), encoding(), ttl()), le code HTTP (code()), les cookies (state()), les redirections...

Validation des paramètes

Joi est un validateur puissant permettant de tester des chaînes, nombres, booléens, tableaux, objets... avec des validation très poussées.

Les utilitaires proposés par Boom sont également très utiles pour la génération de page d'erreurs intelligibles.

Architecture de plugins

L'organisation en plugin est à la base de la structuration des applications avec Hapi.

Le plugin peut être un simple fichier, un dossier avec fichier index.js et package.json, un module externe complet.

Le premire paramètre de register est l'instance du serveur lui même, le second est un objet d'options totalement libre, le troisième est une fonction à invoquer lorsque le plugin est chargé, avec une éventuelle erreur en paramètre s'il y a eu un problème.

Les principaux plugins

Il y a plus de 75 plugins sur la page officielle du site d'Hapi, sans compter les nombreux autres disponibles directement sur NPM.

Hapi server !

  1. Créez un serveur Hapi dans un module tps/hapi-server/server.js.
  2. Créez un module de test avec dans tps/hapi-server/test/server.js. Ce module doit importer le serveur du fichier éponyme et tester :
    1. Hapi server should be started > vérifiez que le serveur démarre et renvoi une 404 sur n'importe quelle url
    2. Hapi server hello API should say hello John > le serveur renvoi le text 'Hi JohnX !' à l'url GET '/hello/JohnX' X étant un nombre aléatoire
    3. Hapi server hello API should fail on too short name > renvoi un code 400 avec une erreur intelligible si le nom n'a pas entre 3 et 10 caractères
    4. Hapi server hello view should render xml content > réponse <msg>Hello JohnX</msg> pour l'url si le header 'accept' contient 'xml'
  3. Transformez votre API en un plugin tps/hapi-server/routes/hello.js

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 méthode server.inject(path, function(resp) {...});.

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

Faites la validation avec Joi, et pour le fichier xml, utiliser un template handlebars.

Le principe d'adapter le format de réponse du serveur en fonction de ce que demande la requête client s'appelle la négociation de contenu (content negociation)

Dodge callback hell with async

"Pyramid of Doom" ??

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

Exemple proposé par callbackhell.com

Présentation de async

Asynchronisme et tableaux

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

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

async.parallel({
  one: function(done) {
    // la signature de done est done(err, result)
    fs.readFile('in.txt', done);
  }, two: function(done){
    fs.writeFile('out.txt', 'finished !', done);
  }
], function(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

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

Récap du troisième jour

And now, let's ROCK'N ROLL !

Crédits photos