package = modules + dépendances + tests
(*) 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)
lib/ # votre code (aussi libs)
node_modules/ # les dépendances gérées par NPM
test/ # le code de vos tests
.npmignore # les exclusions du package
README.md
package.json
app/ # code serveur uniquement
tests/
assets/ # Les assets coté serveur
config/ # les fichiers de configuration (aussi conf)
node_modules/ # les dépendances serveur gérées par NPM
public/ # code client
dist/ # le client minifié
test/
vendor/ # dépendances client, gérées par bower/webpack... (aussi libs)
package.json
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
package.json
{
"name": "nom-du-projet",
"version": "0.3.1",
"description": "Elle sera publiée",,
"author": "You ",
"license": "MIT",
"repository": "https://github.com/you/nom-du-projet.git",
"dependencies": {
"lodash": "~2.4.1"
},
"main": "./lib/entry_point.js",
"bin": {
"start-project": "./bin/start"
},
"scripts": {
"test": "lab test"
},
"engines": {"node": ">=0.10.3"}
}
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é
x.y.z
: exactement la version^x.y.z
: la version x la plus haute (y.z au minimum)git://github.com/user/project.git#commit
http://bitbucket.org/user/repo?format=tar.gz
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 ^
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
npm install
: récupération & compilation des dépendances, création des exécutablesnpm test
: lance l'exécution des tests présentsnpm start/restart/stop
: lance et arrête le projetnpm publish
: publie le projet sur le registry centralLa 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
var assert = require('assert');
var lab = exports.lab = require('lab').script();
// describe est un fonction qui définit un "groupe" de tests
lab.describe('Array#indexOf', function() {
// it est une fonction qui définit un test
lab.it('should return -1 when the value is not present', function(done){
assert.equal(-1, [1,2,3].indexOf(5));
assert.equal(-1, [1,2,3].indexOf(0));
done(); // si une exception est levée avant done(), le test échoue
});
lab.it('should return index when the value is present', function(done){
assert.equal(2, [1,2,3].indexOf(3));
assert.equal(0, [1,2,3].indexOf(1));
done(); // si done() est invoquée (sans erreur), le test est réussit
});
});
lab test/array.js -v
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()
describe
pour avoir la liste des it()
(déclaration des tests)it()
(réalisation des tests)assert
est un module NodeJS que nous n'utiliserons pas à cause de ses faibles capacités de reporting
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.
var lab = exports.lab = require('lab').script();
// notez l'inclusion de chai
var expect = require('chai').expect;
lab.describe('Array#indexOf', function() {
lab.it('should return -1 when the value is not present', function(done){
var array = [1, 2, 3];
// permet de faire la même chose...
expect(array.indexOf(5)).to.equal(-1);
//...et des assertions bien plus expressives !
expect(array).to.be.an.instanceof(Array).and.to.have.a.lengthOf(3);
expect(array).to.include(2);
expect(array).not.to.contain(4);
done();
});
});
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
expect(x)/*not.*/.to./*deep.*/equals(y)
expect(x).to.be.null
expect(x).to.exist
expect(x).to.be.empty
expect(x).to.have.length(y)
expect(x).to.have./*deep.*/property('model.name').that.equals(z)
expect(x).to.contain/*.keys*/(y)
expect(x).to.be.at.least(y).and.at.most(z)
expect(1).to.satisfy(function(num) {return num > 0;})
expect(function(){}).to.throw(/message/)
Liste complète disponible sur la documentation officielle
expect(x).to.be.equal(y)
Quelle que soit l'assertion suivante, not aura pour effet d'attendre son contraire
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 biencontain et include sont strictement synonymes
Les tests sur les exceptions portent sur l'exécution d'une fonction !
describe()
: lab.describe('Array', function() {
var array = []
lab.beforeEach(function(done) { // before(), after(), afterEach()
array = [1, 2, 3];
done();
});
lab.it('should splice() remove elements', {skip: true}, function(done){
expect(array.splice(1, 1)).to.deep.equals([1, 3]);
done();
})
})
it()
only
positionne skip
sur les autres it
du describe()
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()
!
npm init
pour générer un descripteur package.jsonnpm install --save-dev lab chai
lab -r html -o coverage.html
pour voir la couvertureIl 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
var request = require('request');
request('http://www.google.com', function (err, response, body) {
if (!err && response.statusCode === 200) {
console.log(body);
}
})
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.
request({
url: 'http://www.google.com/search',
method: 'GET',
qs: {
q: 'request'
},
proxy: 'http://proxy-internet.localnet:3128'
}, function (err, response, body) {
// ...
})
request.get()
, post()
etc...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.
request
/request.get
renvoient un ReadableStream: request('http://google.com/doodle.png').pipe(fs.createWriteStream('doodle.png'));
request.post
/request.put
, un WritableSteam: fs.createReadStream('file.json').pipe(request.put('http://mysite.com/obj.json'));
request.post('http://service.com/upload').form({key:'value'});
request.post({url:'http://service.com/upload',
formData: {
field1: 'my_value',
field3: fs.createReadStream('unicycle.jpg')
}});
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()
.
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.
var Hapi = require('hapi');
var server = new Hapi.Server();
server.connection({ port: 3000 });
server.route({
method: 'GET',
path: '/hello/{user}',
handler: function (request, reply) {
reply('Hello ' + encodeURIComponent(request.params.user) + '!');
}
});
server.start();
request
contient la requête parséereply()
est une factory pour constituer la réponseLes 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...
var Joi = require('joi');
server.route({
method: 'GET',
path: '/hello/{user}',
handler: function (request, reply) {
reply('Hello ' + encodeURIComponent(request.params.user) + '!');
},
config: {
validate: { params: { user: Joi.string().min(3).max(10) } }
}
});
params
), ceux de requête (query
), les entêtes (headers
) et le corps (payload
)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.
register
: exports.register = function(server, options, next) {
// Manipulation du serveur: par exemple ajout de routes.
// Lorsque les manipulations sont terminées, appel de next()
next();
};
exports.register.attributes = {
name: 'plugin-name',
version: '1.0.0' // facultatif
};
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.
server.register([{
register: require('myplugin'),
options: {}
}], function (err) {
// plugins chargés
});
Il y a plus de 75 plugins sur la page officielle du site d'Hapi, sans compter les nombreux autres disponibles directement sur NPM.
<msg>Hello JohnX</msg>
pour l'url si le header 'accept' contient 'xml'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)
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))
}
})
})
}
})
async.map(['file1','file2','file3'], function(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);
}, function(err, results){
// err est la première erreur renvoyée
// results est le tableau (ordonné) des résultats intermédiaires
});
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émentfilter()
, filterSeries()
qui ne conserve que les élements passant le test asynchronereject()
, rejectSeries()
qui conserve les élements ne passant pas le test asynchronereduce()
, reduceRight()
identique à Array.reduce() en mode asynchronesortBy()
qui extrait de manière asynchrone une valeur servant pour le tri qui intervient dans un 2ème tempsdetect()
, 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 asynchroneconcat()
, concatSeries()
renvoie la concaténation des résultats de la fonction asynchrone appliquée à chaque élémentasync.parallel([
function(done) {
// la signature de done est done(err, result)
fs.readFile('in.txt', done);
}, function(done){
fs.writeFile('out.txt', 'finished !', done);
}
], function(err, results) {
// err est la première erreur renvoyée
// results est le tableau (ordonné) des resultats intermédiaires
});
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...
whilst()
, doWhilst()
: executer une function asynchrone tant que le test synchrone est vraiuntil()
, doUntil()
: executer une function asynchrone jusqu'a ce que le test synchrone soit vraiseq()
, compose()
, waterfall()
: passe les résultats de l'étape N à l'étape N+1queue()
, cargo()
: pool d'exécution asynchronequeue()
, 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
getDirStat
en utilisant async