Créer un framework Web avec Node.js – Partie 1

Première partie : la structure de base

Auteurs

| Julien Alric | Didier Mouronval |

Cet article a été publié avec l'aimable autorisation de Julien Alric. L'article original (Créer un framework web avec Node.js - 1er partie) peut être vu sur le blog de Julien Alric.

Commentez

- Titre : Créer un framework Web avec Node.js – Partie 1

- Auteurs : Julien Alric, Didier Mouronval

- Parution : 25 octobre 2013

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2013 Julien Alric. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- Présentation

- I. Getting started

- II. L'arborescence

- III. Les serveurs virtuels

- IV. HTTPS

- V. $, ou le cœur du framework

- VI. Les chemins

- VII. Les routes

- VIII. Point performances n°1

- IX. L'URL rewriting

- X. Le moteur de templates

- XI. Le contexte d'exécution

- XI-A. La requête

- XI-B. Les cookies

- XI-C. Le client

- XI-D. Les entêtes HTTP

- XII. L'internationalisation

- XIII. Les logs

- XIV. Les fichiers statiques

- XV. Prochaines étapes

- Remerciements

Présentation

Avec Node.js, il est facile de réaliser un serveur Web. Comme illustré dans la documentation, quelques lignes de code suffisent pour renvoyer un superbe « Hello world » à chaque connexion.

var http = require('http');

http.createServer(function (request, response) {
response.writeHead(200, {'Content-Type': 'text/plain'});
response.end('Hello World\n');
}).listen(8080);

console.log('Server running at http://127.0.0.1:8080/');

Si par contre vous voulez faire quelque chose de plus poussé, cela devient vite plus compliqué. Vous devrez apprendre à utiliser un framework comme Express, Meteor, Mojito ou Derby (voir une liste assez exhaustive), ou bien coder votre projet « from scratch » en développant toute la logique bas niveau de votre application. C'est ce que nous allons apprendre à faire dans ce tutoriel.

Cette série d'articles a pour objectif de présenter une à une toutes les briques qui constituent un tel projet.

I - Getting started

Pour commencer, on va se créer un petit espace de travail sous Windows. Vous ne devriez pas rencontrer de difficultés à tout transposer sous Linux ou Mac OS. Notre espace de travail contiendra les répertoires home et system, ainsi que le fichier server.bat, qui lui contiendra ce code :

set NODE_ENV=prod
start system/chrome/chrome.exe --app=http://localhost:8080 --app-window-size=800,600
"system/node/node.exe" system/server.js
:: - sarting the server in background
:: start system/node/node.exe system/server.js
pause

Dans system je vais créer les répertoires node et chrome. Le premier contiendra une copie du répertoire nodejs issu de votre installation et le second un build récent de Chrome téléchargé à partir de cette adresse : http://bit.ly/1afjojy. Vous n'êtes pas obligé de faire cette manipulation, elle a pour seul intérêt de rendre tout l'environnement indépendant de la configuration du système.

Voici la même version du fichier batch mais lié cette fois au système :

set NODE_ENV=prod
start "Chrome" chrome --app=http://localhost:8080 --app-window-size=800,600
node system/server.js
pause

OK, maintenant, il ne reste plus qu'à créer un fichier server.js qui contiendra le code « Hello world » vu au-dessus, du site Nodejs.org. Double cliquez maintenant sur le fichier server.bat et votre serveur Node devrait se lancer, ainsi qu'une fenêtre Chrome en mode application.

II - L'arborescence

Nous allons à présent définir à quoi va ressembler l'arborescence de notre framework. Le répertoire home contiendra l'ensemble de nos différents projets. Par habitude, je crée en général un dossier de la forme domain.com, dans lequel je place des dossiers correspondant aux sous projets / domaines (www, blog, api, …), et je respecte la même structure que dans le sites-available d'Apache (on s'y retrouve mieux quand on a plusieurs dizaines de projets). Ici, on va faire plus simple et je vais nommer mon répertoire de projet website.
Dans website, je vais créer trois dossiers : app, src et web. Le premier contiendra toutes les données relatives au site, mais n'étant ni du code ni des ressources à renvoyer au client. On y trouvera entre autre les fichiers de configuration, ceux de traduction, les logs, etc. Le dossier src contiendra le code métier, c'est-à-dire tous nos contrôleurs et nos modèles. Quant au dossier web, il contiendra toutes les ressources statiques : images, fichiers JavaScript client, feuilles CSS, et fichiers HTML.

Placer les fichiers HTML dans le dossier web peut surprendre, dans une architecture MVC on ne place jamais les vues dans le répertoire public. Voyons les raisons qui motivent ici notre choix :

- les vues sont des ressources à priori statiques, qui ne contiennent pas de code et peuvent être modifiées par les contrôleurs. Prenons les images. Je pourrais envisager des URI de la forme /[filtre]/image.jpg qui déclencheraient l'appel d'un contrôleur en charge d'appliquer dynamiquement le filtre de mon URI. Il n'y a donc pas de différence fondamentale entre un template et une image ;

- habituellement, cette arborescence poserait problème, car avec Apache, appeler directement le chemin du template entraînerait son envoi en brut au client. Ici, ce n'est pas le cas, ce n'est donc plus un véritable problème et nous pouvons bénéficier d'une structure plus cohérente.

III - Les serveurs virtuels

Comme nous voulons bien faire les choses, il serait intéressant de proposer avec ce framework un site de démonstration qui contiendrait quelques pages ainsi qu'une documentation bien fournie. Aussi, nous voudrions que notre serveur puisse router le visiteur vers notre site ou le site de démonstration en fonction du nom de domaine ou du port interrogé. Bref, il serait bien que notre framework puisse facilement gérer des serveurs virtuels (ou virtual hosts en anglais).

Pour faire cela, nous allons créer un dossier config que nous allons placer à la racine de notre espace de travail et nous allons y placer le fichier vhost.json. Le format JSON sera le format par défaut de nos fichiers de configuration. Voici son contenu :

[
{
"port":8080,
"protocol":"http",
"hosts":{
"localhost:8080":{
"name":"Website",
"root":"C:/home/framework/home/website/web/index.js",
"env":"dev"
},
"www.website.com:8080":{
"name":"Website",
"root":"C:/home/framework/home/website/web/index.js",
"env":"prod"
},
"default":{
"name":"Default",
"root":"C:/home/framework/home/default/web/index.js",
"env":"prod"
}
}
},
{
"port":8081,
"protocol":"http",
"hosts":{
"localhost:8081":{
"name":"Demo",
"root":"C:/home/framework/home/demo/web/index.js",
"env":"prod"
},
"default":{
"name":"Default",
"root":"C:/home/framework/home/default/web/index.js",
"env":"dev"
}
}
}
]

Il est temps maintenant de modifier le code de notre serveur et de rediriger les requêtes vers les racines de nos projets en fonction du domaine et du port interrogé.

/** 1 **/
var vhost = require('../config/vhost.json');

/** 2 **/
httpServer = function (server) {
var http = require('http');

http.createServer(function (req, res) {
/** 3 **/
var host = req.headers.host;

if (typeof server.hosts[host] !== 'undefined') {
var app = require(server.hosts[host].root);
} else {
var app = require(server.hosts['default'].root);
}

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(server.port);
}

for (i in vhost) {
var server = vhost[i];
var method = server.protocol+'Server';

/** 4 **/
global[method](server);
}

Il y a plusieurs choses à dire concernant ce petit morceau de code.

- Le require nous permet de récupérer directement dans un objet notre configuration en JSON. Nous aurions pourtant pu adopter d'autres stratégies, quelque chose comme ceci par exemple :

var fs = require('fs');
var file = __dirname + '../config/vhost.json';

fs.readFile(file, 'utf8', function (err, data) {
if (err) {
console.log('Error: ' + err);
return;
}
vhost = JSON.parse(data);
});

- Ici, cette méthode nous poserait des problèmes, parce qu'elle est asynchrone. Nous atteindrions probablement la boucle d'en bas avant même d'avoir récupéré le contenu du fichier de configuration des vhosts. Ce problème se résout facilement en appelant la version synchrone de readFile : readFileSync. À partir de là, cette solution à le léger avantage d'être plus facilement testable. Pour autant j'ai préféré ici utiliser require par souci de concision. Mais pensez à bien vérifier que vos fichiers JSON soient valides avant de les utiliser (avec ça par exemple).

- Autre problème de synchronisme, si ici nous avions appelé createServer dans la boucle for en bas, la fonction de rappel aurait été appelée avec un i correspondant au dernier élément de notre objet vhost. On résout ici ce problème en encapsulant createServer dans une closure à qui on transmet le contexte courant de la boucle. C'est un pattern assez récurrent avec Node, à tel point qu'il existe un module Async qui vous permet de simplifier l'écriture de cas de ce type plus complexes.

- req.headers contient les différentes entêtes envoyées par le client. Nous pourrions faire ici un test sur la présence du header Host, mais ce n'est pas vraiment la peine, ce dernier étant obligatoire depuis la version 1.1 de la norme HTTP.

- Nous voyons ici l'objet global. C'est l'équivalent Node du window du navigateur. Nous aurons l'occasion d'en reparler.

Vous aurez aussi certainement remarqué la présence du nom du site et de l'environnement dans le fichier des hôtes virtuels. L'utilité de définir une notion d'environnement à ce niveau-là est de permettre l'usage pour un site de deux environnements dans un même processus Node. Cela peut être pratique. Ce sera ensuite à vous de voir si vous préférez que l'environnement soit défini au niveau du serveur virtuel ou au niveau du serveur physique via la variable d'environnement NODE_ENV définie dans notre fichier batch. Au niveau du framework, on vérifiera d'abord les vhosts, puis le système, et à défaut l'environnement sera défini à prod.

Placez maintenant un console.log('something'); dans chacun de vos fichiers index.js. Relancez le serveur, les hôtes virtuels devraient normalement bien fonctionner.

IV - HTTPS

Pour en finir avec la partie purement serveur, modifions le code de notre serveur pour supporter le protocole HTTPS :

// Load virtual hosts configuration
var vhost = require('../config/vhost.json');

// HTTP server
httpServer = function (server) {
var http = require('http');

http.createServer(function (req, res) {
var host = req.headers.host;

if (typeof server.hosts[host] !== 'undefined') {
var app = require(server.hosts[host].root);
} else {
var app = require(server.hosts['default'].root);
}

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
}).listen(server.port);
}

// HTTPS server
httpsServeur = function (server) {
var https = require('https');
var fs = require('fs');

if (typeof server.https.key !== 'undefined') {
var options = {
key: fs.readFileSync(server.https.key),
cert: fs.readFileSync(server.https.cert)
};
} else {
var options = {
pfx: fs.readFileSync(server.https.pfx)
};
}

https.createServer(options, function (req, res) {
var host = req.headers.host;

if (typeof server.hosts[host] !== 'undefined') {
var app = require(server.hosts[host].root);
} else {
var app = require(server.hosts['default'].root);
}

res.writeHead(200, {'Content-Type': 'text/plain'});
res.end("hello world\n");
}).listen(server.port);
}

// Loop for virtual hosts
for (i in vhost) {
var server = vhost[i];
var method = server.protocol+'Server';

global[method](server);
}

Vous pouvez ensuite modifier votre fichier vhosts en conséquence si vous souhaitez proposer ce type de connexion.

[...]
{
"port":443,
"protocol":"https",
"https": {
"key": "path/to/key",
"cert": "path/to/cert"
},
[...]

V - $, ou le cœur du framework

Manipuler le contexte global c'est généralement mal, sauf à savoir précisément ce que l'on fait.
Nous allons créer un dossier node_modules dans system, dans lequel nous allons placer le fichier principal de notre framework : framework.js. Nous verrons plus tard tout ce que ce fichier va contenir. Nous allons simplement rajouter tout en haut de notre fichier server.js la ligne :

global.$ = require('framework');

Nous pourrions bien sûr faire un var $ = require('/path/to/framework'); à plein d'endroits, mais comme $ sera très souvent sollicité, autant le rendre directement accessible depuis partout. Nous verrons plus tard comment nous pouvons protéger $ afin d'éviter que d'autres modules l'écrasent ou le modifient.

VI - Les chemins

Une des premières choses à faire maintenant est d'indiquer à notre framework les chemins de nos différents dossiers. Il est préférable en effet de ne pas avoir toute une arborescence gravée dans le marbre avec des chemins précisés en dur dans le code. Vous pourriez par exemple vouloir placer vos fichiers de template dans src, renommer images en img ou que sais-je.

Pour faire ceci, nous allons créer un fichier app.js à la racine de notre projet, voici son contenu à ce stade :

$.define("ROOT", __dirname + '/..', exports);

var paths = {
html:        this.ROOT + '/web/html',
images:      this.ROOT + '/web/images',
css:         this.ROOT + '/web/css',
config:      this.ROOT + '/app/config',
language:    this.ROOT + '/app/language',
logs:        this.ROOT + '/app/logs',
controllers: this.ROOT + '/src/controllers',
models:      this.ROOT + '/src/models'
}

Et nous plaçons notre fonction define dans framework.js

exports.define = function(property, value, scope) {
Object.defineProperty(scope, property, {
value: value,
enumerable: true
});
}

Nous avons maintenant une fonction $.define qui nous permet d'associer simplement des constantes à un contexte. Vous pouvez par exemple faire $.define('FOO', 'bar', global); pour ajouter une constante au contexte principal. Vous êtes ainsi sûr quelle ne pourra pas être effacée par la suite (lire la doc de defineProperty pour plus d'infos). Pour information, exports correspond pour Node au contexte du module courant. Nous pourrions d'ailleurs ici utiliser le mot clé const dont la portée est limitée au module courant. Mon choix définitif n'est pas arrêté à ce stade.

VII - Les routes

Bon, nous avons déjà bien avancé, mais il serait peut-être temps que notre serveur connaisse d'autres URI que la racine. Pour ce faire, nous allons créer un fichier de configuration routes.json qui contiendra cinq routes. L'index, une page de contact, une page hello pour voir comment passer des variables dans les URL, une page d'erreur 404 et la route du favicon. Voici le contenu de ce fichier :

{
"index":{
"type":"html",
"format":"/$",
"mime":"text/html",
"method":"GET",
"ressource":"index.html",
"controller":"index.js",
"url":"/"
},
"favicon":{
"type":"image",
"format":"/favicon\\.ico$",
"mime":"image/x-icon",
"method":"GET",
"ressource":"favicon.ico",
"url":"/favicon.ico"
},
"contact":{
"type":"html",
"format":"/contact$",
"mime":"text/html",
"method":"GET,POST",
"ressource":"contact.html",
"controller":"contact.js",
"url":"/contact.html"
},
"hello":{
"type":"html",
"format":"/hello/([-a-zA-Z]+)$",
"mime":"text/html",
"method":"GET",
"ressource":"hello.html",
"controller":"hello.js",
"url":"/hello/[name]",
"query":{
"name":"$1"
}
},
"error404":{
"type":"html",
"mime":"text/html",
"method":"GET,POST",
"ressource":"error404.html",
"controller":"error404.js"
}
}

Pour déterminer la bonne route à prendre, nous allons créer un module du nom de router.js. Voici le code de ce module :

// Router module
exports.get = function(routes, path) {

var ressource = {};

for (i in routes) {
if (typeof routes[i].format !== 'undefined') {
var reg = new RegExp(routes[i].format,"i");
var regs = path.match(reg)
if (regs) {
ressource.name = i;
ressource.file = routes[i].ressource;
ressource.ctrl = routes[i].controller;
break;
}
}

ressource.name = 'error404';
ressource.file = 'error404.html';
ressource.ctrl = 'error404.js';
}

return ressource;
}

Nous allons maintenant modifier notre fichier app.js afin de créer une fonction start qui sera appelée depuis le serveur. C'est dans cette fonction que nous appellerons le routeur. Notre nouveau fichier app.js :

// Define the root directory
$.define("ROOT", __dirname + '/..', exports);

// Project's paths
var paths = {
html:        this.ROOT + '/web/html',
images:      this.ROOT + '/web/images',
js:          this.ROOT + '/web/js',
css:         this.ROOT + '/web/css',
config:      this.ROOT + '/app/config',
crons:       this.ROOT + '/app/crons',
entities:    this.ROOT + '/app/entities',
language:    this.ROOT + '/app/language',
logs:        this.ROOT + '/app/logs',
test:        this.ROOT + '/app/test',
controllers: this.ROOT + '/src/controllers',
models:      this.ROOT + '/src/models'
}

// Load routes from routes config file
var routes = require(paths.config+'/routes.json');

// Entry point
exports.start = function(req, res, srv) {

var url = require('url');
var querystring = require('querystring');

var path   = url.parse(req.url).pathname;
var page   = $.require('router').get(routes, path);
console.log(page);

res.writeHead(200, {'Content-Type': 'text/html'});
res.end('Hello World Index');

}

Dans le fichier server.js :

app.run(req, res, server.hosts[host]);

Dans le fichier index.js :

var app = require('./app.js');

exports.run = function(req, res, srv) {
app.start(req, res, srv);
}

Dans le fichier framework.js :

exports.require = function(module) {
var module = require(module);

return module;
}

Je reviendrai plus tard sur la fonction $.require qui sera modifiée. Globalement vous pouvez considérer cette fonction comme un wrapper de require qui pourra être utilisé pour définir plusieurs stratégies de chargement. Ici par exemple, le require utilisé est lié au dossier node_modules de la racine du serveur, si nous avions appelé require('router'); depuis app.js, Node.js nous aurais renvoyé une erreur. Je n'avais pas non plus également envie de préciser le chemin complet, car la destination voulue est en dehors du projet. Ce dernier n'a pas à savoir où se situe le serveur sur notre système.

Modifions maintenant comme ceci la fonction start d'app.js :

exports.start = function(req, res, srv) {
var fs = require('fs');
var url = require('url');
var querystring = require('querystring');

var path   = url.parse(req.url).pathname;
var page   = $.require('router').get(routes, path);

if (typeof page.ctrl !== 'undefined') {
var controller = require(paths.controllers+'/'+page.ctrl);
}

if (fs.existsSync(paths.html+'/'+page.file)) {
var content = fs.readFileSync(paths.html+'/'+page.file);
}

res.writeHead(200, {'Content-Type': 'text/html'});
res.end(content);

}

Et créons les fichiers index.html et index.js que nous plaçons respectivement dans html et controllers. Mettons du texte dans index.html, un console.log dans index.js, et relançons le serveur.

VIII - Point performances n°1

Tout commence maintenant à bien prendre forme, c'est pas mal, mais niveau performance, on peut faire un peu mieux. En effet, ici le serveur va attendre d'avoir récupéré le contenu de notre template avant de continuer son exécution sans profiter du caractère totalement asynchrone de Node.js. Corrigeons ça en utilisant les versions asynchrones des fonctions exists et readFile :

fs.exists(paths.html+'/'+page.file, function (exists) {
if (exists) {
fs.readFile(paths.html+'/'+page.file, 'utf8', function (err, data) {
if (err) throw err;
var content = data;

res.writeHead(200, {'Content-Type': 'text/html'});
res.end(content);
});
}
});

C'est mieux, mais on peut faire encore mieux. Ici, le serveur lit le fichier index.html à chaque appel, pourquoi ne pas précharger toutes les ressources quelque part ? Toutes, peut-être pas, nous allons ajouter un paramètre preloading dans notre fichier routes.json. À l'appel de app.js dans l'index, nous appellerons une fonction load() d'app.js qui se chargera de charger toutes les ressources où on aura spécifié à true la valeur de preloading.

Bien sûr, le préchargement ne concerne que les ressources, les contrôleurs étant déjà mis en cache via la fonction require. On perd ici la possibilité de changer à chaud les templates, on verra que des solutions sont envisageables pour rendre cela possible sans impacter les performances.

// Load content of all ressources files when preloading true
exports.load = function() {
console.log('loading ressources...');

var loadFile = function(name, path, type) {
var option = (type !== 'image') ? 'utf8' : null;
fs.exists(path, function (exists) {
if (exists) {
fs.readFile(path, option, function (err, data) {
if (err) throw err;
file[type][name] = data;
});
}
});
}

for (i in routes) {
var filePath = paths.html+'/'+routes[i].ressource;
loadFile(i, filePath, routes[i].type);
}

console.log('ressources loaded');
return this;
}

Et on modifie bien sûr la fonction start en conséquence :

var url = require('url');
var querystring = require('querystring');

var path   = url.parse(req.url).pathname;
var page   = $.require('router').get(routes, path);

if (typeof page.ctrl !== 'undefined') {
var controller = require(paths.controllers+'/'+page.ctrl);
}

var display = function(content) {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(content);
}

if (typeof file[page.type][page.name] !== 'undefined') {
content = file.html[page.name];
display(content);
} else {
fs.exists(paths.html+'/'+page.file, function (exists) {
if (exists) {
fs.readFile(paths.html+'/'+page.file, 'utf8', function (err, data) {
if (err) throw err;
var content = data;

display(content);
});
}
});
}

IX - L'URL rewriting

Si notre système de routes fonctionne, il ne permet pas pour le moment de passer des paramètres dans les URL. Pour réaliser cela, il nous suffit d'apporter quelques petites modifications dans le fichier router.js :

// Router module
exports.get = function(routes, path) {

var ctx = {
name: 'error404',
file: 'error404.html',
ctrl: 'error404.js',
type: 'html',
query: {}
};

for (var i in routes) {
if (typeof routes[i].format !== 'undefined') {
var reg = new RegExp(routes[i].format,"i");
var regs = path.match(reg)
if (regs) {

if (typeof routes[i].query !== 'undefined') {
var j = 1;
for (var k in routes[i].query) {
ctx.query[k] = regs[j];
j++;
}
}

ctx.name = i;
ctx.type = routes[i].type;
ctx.file = $.isset(ctx.query.file) ?
routes[i].ressource.replace('[file]', ctx.query.file) : routes[i].ressource;
ctx.ctrl = $.isset(ctx.query.ctrl) ?
routes[i].controller.replace('[ctrl]', ctx.query.ctrl) : routes[i].controller;

for (var j in routes[i]) {
if (typeof ctx[j] === 'undefined') {
ctx[j] = routes[i][j];
}
}

break;
}
}
}

return ctx;
}

Maintenant, si le routeur rencontre une route contenant un champ query, les captures rencontrées dans le pattern de notre URL seront réinjectées dans les clés de query. De plus, nous allons ajouter deux clés spéciales, file et ctrl qui, si elles sont présentes, nous permettront de modifier dynamiquement la ressource ciblée ou le contrôleur. Nous verrons un exemple plus tard dans le cas du chargement de fichiers statiques.

X - Le moteur de templates

Nous allons maintenant voir comment manipuler nos templates à partir des contrôleurs pour dynamiser le rendu. Pour faire ceci, nous n'allons pas coder nous même un moteur de template, il en existe déjà beaucoup très bien réalisés, nous allons utiliser l'un d'entre eux, le célèbre mustache.js.

Premier template : index.html

Hello {{name}}

Notre contrôleur : index.js

exports.exec = function(res, content) {
var mustache = $.require('mustache');
var output = mustache.render(content, {'name':'Julien'});

res.writeHead(200, {'Content-Type': 'text/html'});
res.end(output);
}

Et la fonction start d'app.js

var url = require('url');
var querystring = require('querystring');

var path = url.parse(req.url).pathname;
var page = $.require('router').get(routes, path);

var createResponse = function(content) {
if (typeof page.ctrl !== 'undefined') {
var controller = require(paths.controllers+'/'+page.ctrl);
controller.exec(res, content);
} else {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(content);
}
}

if (typeof file[page.type][page.name] !== 'undefined') {
content = file.html[page.name];
createResponse(content);
} else {
fs.exists(paths.html+'/'+page.file, function (exists) {
if (exists) {
fs.readFile(paths.html+'/'+page.file, 'utf8', function (err, data) {
if (err) throw err;
var content = data;

createResponse(content);
});
} else {
createResponse(null);
}
});
}

Il ne vous reste plus qu'à relancer le serveur en ayant bien pris soin de placer mustache dans le dossier node_modules de votre dossier system.

XI - Le contexte d'exécution

Pour le moment, nous ne faisons que renvoyer des ressources, soumises éventuellement au traitement d'un contrôleur qui leur est associé. Nous allons à présent créer un ensemble d'objets associés à la requête qui vont nous permettre de faire pas mal de chose. Bien sûr, vous pourriez vous-même construire ces objets à partir principalement du paramètre requête de Node.js, mais c'est le rôle d'un framework que de vous faciliter cette tâche.

var params = $.require('params').parse(req, body);
var cookie = $.require('cookie').parse(req);
var header = $.require('header').parse(req);
var client = $.require('client').parse(req, header.acceptLanguage[0]);

XI-A - La requête

Une des premières choses que nous voudrions pouvoir faire serait de récupérer les paramètres GET ou POST transmis par l'utilisateur. Pour les paramètres GET, rien de plus facile, ils sont présents dans l'URL. Ceux en POST par contre sont transmis dans le corps du message ce qui peut être bloquant et devront donc être récupérés dans une procédure asynchrone bien placée (ce sera dans l'index.js du projet).

exports.run = function(req, res, srv) {
if (req.method == 'POST') {
var body = '';
req.on('data', function (data) {
body += data;
if(body.length > 1e6) {
body = '';
res.writeHead(413, {'Content-Type': 'text/plain'}).end();
req.connection.destroy();
}
});

req.on('end', function () {
app.start(req, res, srv, body);
});

} else {
app.start(req, res, srv);
}
}

Ensuite, nous allons créer un petit module params.js qui va se charger de nous formater tout ça. Pour récupérer la variable GET foo, il nous suffira de faire quelque chose comme var foo = params.get.foo;.

// Params module
exports.parse = function(req, body) {
var url = require('url');
var querystring = require('querystring');

var query = url.parse(req.url).query;
var params = {
get: querystring.parse(query)
}

if (req.method == 'POST') {
params.post = querystring.parse(body);
}

return params;
}

XI-B - Les cookies

Rien de plus simple, s'ils sont présents dans les entêtes HTTP les cookies sont accessibles dans req.headers.cookie.

// Cookie module
exports.parse = function(req) {
var cookie = {};

if (typeof req.headers.cookie !== 'undefined') {
var cookieList = req.headers.cookie.split(';');

for (i in cookieList) {
a = cookieList.i.split('=');
cookie[i[0]] = a[1];
}
}

return cookie;
}

XI-C - Le client

Pour le moment nous transmettrons à cet objet l'IP du visiteur et sa locale (déterminée à partir de l'entête accept-language envoyée par le navigateur).

// Client module
exports.parse = function(req, locale) {

var client = {
ip: req.connection.remoteAddress,
locale: locale
}

return client;
}

XI-D - Les entêtes HTTP

Ici on va récupérer tous les entêtes possibles (norme + les plus fréquents). Quand j'aurais un moment j'améliorerais ce morceau de code. Il serait plus élégant de boucler sur req.headers et de générer la clé en camelCase à partir d'une fonction de formatage déclarée dans $.

// Header module
exports.parse = function(req) {

var headers = {};

var temp = {
accept:             req.headers['accept'],
acceptCharset:      req.headers['accept-charset'],
acceptEncoding:     req.headers['accept-encoding'],
acceptLanguage:     req.headers['accept-language'],
acceptDatetime:     req.headers['accept-datetime'],
authorization:      req.headers['authorization'],
cacheControl:       req.headers['cache-control'],
connection:         req.headers['connection'],
cookie:             req.headers['cookie'],
contentLength:      req.headers['content-length'],
contentMD5:         req.headers['content-md5'],
contentType:        req.headers['content-type'],
date:               req.headers['date'],
expect:             req.headers['expect'],
host:               req.headers['host'],
ifMatch:            req.headers['if-match'],
ifModifiedSince:    req.headers['if-modified-since'],
ifNoneMatch:        req.headers['if-none-match'],
ifRange:            req.headers['if-range'],
ifUnmodifiedSince:  req.headers['if-unmodified-since'],
maxForwards:        req.headers['max-forwards'],
origin:             req.headers['origin'],
pragma:             req.headers['pragma'],
proxyAuthorization: req.headers['proxy-authorization'],
range:              req.headers['range'],
referer:            req.headers['referer'],
TE:                 req.headers['te'],
upgrade:            req.headers['upgrade'],
userAgent:          req.headers['user-agent'],
via:                req.headers['via'],
warning:            req.headers['warning'],
xRequestedWith:     req.headers['x-requested-with'],
DNT:                req.headers['dnt'],
xForwardedFor:      req.headers['x-forwarded-for'],
xForwardedProto:    req.headers['x-forwarded-proto'],
frontEndHttps:      req.headers['front-end-https'],
xATTDeviceId:       req.headers['x-att-deviceid'],
xWapProfile:        req.headers['x-wap-profile'],
proxyConnection:    req.headers['proxy-connection']
};

for (header in temp) {
if (typeof temp[header] !== 'undefined') {
headers[header] = temp[header];
}
}

if (typeof headers.accept !== 'undefined') {
headers.accept = headers.accept.split(',');
}

if (typeof headers.acceptCharset !== 'undefined') {
headers.acceptCharset = headers.acceptCharset.replace(/;q=[^,]+/gi, '').split(',');
}

if (typeof headers.acceptEncoding !== 'undefined') {
headers.acceptEncoding = headers.acceptEncoding.split(',');
}

if (typeof headers.acceptLanguage !== 'undefined') {
headers.acceptLanguage = headers.acceptLanguage.replace(/-/g, '_').replace(/;q=[^,]+/gi, '').split(',');
}

return headers;
}

Il ne reste ici plus qu'à tout envoyer au contrôleur. Je stocke le tout dans un objet context que je passe en troisième paramètre de la fonction exec() de mon contrôleur.

XII - L'internationalisation

Nous avons créé plus tôt un répertoire language, nous allons maintenant le remplir en y plaçant un sous répertoire pour chaque locale possible et c'est dans ces sous répertoires que nous allons placer nos fichiers de traduction. Nous allons placer le fichier main.json suivant dans le répertoire qui correspond à votre locale :

{
"hello":"Bonjour"
}

Puis, dans la fonction load() d'app.js nous allons ajouter le code suivant :

fs.readdir(paths.language, function(err, dirs) {
if (err) throw err;
for (var i in dirs) {
var dir = paths.language+'/'+dirs[i];
lang[dirs[i]] = {};
(function(i, dir) {
fs.readdir(dir, function(err, files) {
if (err) throw err;
for (var j in files) {
var file = dir+'/'+files[j];
lang[dirs[i]][files[j].slice(0, -5)] = require(file);
}
});
})(i, dir);
}
});

Dès lors, toutes nos traductions seront disponibles dans app.lang sous la forme suivante :
app.lang.locale.file.key, ici, app.lang.fr_FR.main.hello contient la chaîne « Bonjour ».

Nous modifions notre template index.html et passons à Mustache les traductions correspondant à notre locale :

<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Framework index</title>
</head>
<body>
{{lang.main.hello}} {{name}}
</body>
</html>

var mustache = $.require('mustache');
var output = mustache.render(content, {'lang': app.lang[ctx.client.locale], 'name':'Julien'});

XIII - Les logs

Nous allons adopter une gestion des logs à deux niveaux. La fonction app.log(message, [file], [server]) permettra d'enregistrer vos logs dans le répertoire dédié, par défaut dans le fichier system.txt, et la fonction $.log(message, [file]) vous permettra d'enregistrer vos logs au niveau du serveur. Voyons ces deux fonctions respectivement dans app.js et framework.js :

// Application logs
exports.log = function(message, file, server) {
var file = $.isset(file) ? paths.logs+'/'+file : paths.logs+'/system.txt';

fs.appendFile(file, message+"\n", function(err) {
if (err) throw err;
});

if (server === true) {
$.log(message, file);
}
}

exports.log = function(message, file) {
var file = $.isset(file) ? paths.logs+'/'+file : paths.logs+'/system.txt';

fs.appendFile(file, message+"\n", function(err) {
if (err) throw err;
});
}

Notez que la fonction app.log permet d'enregistrer les logs également au niveau du serveur si son paramètre server est défini à true.

XIV - Les fichiers statiques

Les images, par exemple, sont des ressources comme les autres. Aussi, vous pouvez charger une image en lui définissant une route comme ceci :

"img-logo":{
"type":"image",
"format":"images/logo\\.jpg$",
"mime":"image/jpeg",
"method":"GET",
"ressource":"logo.jpg",
"preloading":true,
"url":"image/logo.jpg"
}

Si par contre vous proposez un site d'hébergement de photos avec plusieurs milliers d'images, cette solution va très vite montrer ses limites. Aussi nous allons utiliser ce que nous avons défini plus haut dans router.js pour pouvoir passer des paramètres afin de définir des routes plus génériques :

"img-jpg":{
"type":"image",
"format":"images/([-_/a-z0-9]+)\\.jpg$",
"mime":"image/jpeg",
"method":"GET",
"ressource":"[file].jpg",
"url":"images/[file].jpg",
"query":{
"file":"$1"
}
}

Et nous allons bien sûr faire de même pour les fichier .png, .gif, .css ou .js. Ainsi notre framework proposera tout un ensemble de routes dédiées au chargement de fichiers statiques. Pour autant, la résolution de fichiers statiques peut être préférablement déléguée à un serveur Nginx. Cela dit, le gain réel sera faible surtout si vous utilisez un CDN. Ce choix dépendra de votre architecture et des conditions d'exploitation.

XV - Prochaines étapes

Arborescence, serveurs virtuels, HTTPS, chemins, routes, préchargement, templates, contexte d'exécution, internationalisation, logs, nous avons déjà abattu une bonne partie du travail. Mais il reste encore beaucoup de choses à voir. Dans les prochaines étapes nous nous intéresserons en détail à la mise en place de la gestion des données avec comme exemple MongoDB et nous verrons ensuite comment apporter une dimension temps réel à notre framework grâce au module socket.io.

L'écriture de cette série d'article se fait en parallèle avec la réalisation de ce framework (avec une petite avance pour le framework). Le framework final dont les sources seront disponibles sera probablement un peu différent de ce qui est présenté ici. Le but de ces articles est de présenter un à un les problèmes posés lors de la réalisation d'un framework, avec à chaque fois une solution possible. À bientôt pour la suite…

Remerciements

Cet article a été publié avec l'aimable autorisation de Julien Alric. L'article original (Créer un framework web avec Node.js - 1er partie) peut être vu sur le blog de Julien Alric.

Nous tenons à remercier XXX pour sa relecture attentive de cet article.

N'hésitez pas à commenter cet article sur le forum. Commentez