Applications node.js à 12 facteurs (partie 1) - Une base de code saine
Au début des années 2010, les experts en déploiement cloud qui ont fondé Heroku (l’un des fournisseurs de PaaS le plus utilisé) ont publié le livre 12 Factor App. S’il est difficile de mettre une date de parution exacte, c’est parce que l’ouvrage est vivant et en évolution permanente. Les principes de base restent les mêmes et sont redoutablement efficaces pour vous aider à créer des applications prêtes à profiter de l’élasticité des plateformes PaaS et CaaS. En bonus, elles aident grandement au succès de n’importe quelle application destinée au web.
Comment appliquer ces règles dans le cadre particulier d’une application node.js ? C’est ce que l’on va creuser, facteur par facteur, dans cette série d’articles.
I. Base de code
Une base de code suivie avec un système de contrôle de version, plusieurs déploiements
Loin de moi l’idée de repasser sur des basiques, mais votre code doit absolument vivre au sein d’un système de gestion de versions. J’utilise presque exclusivement Git, mais vous pourrez trouver votre bonheur avec Mercurial ou même Subversion.
Dans ce chapitre, 12 Factor App nous invite à considérer l’environnement de développement (c’est-à-dire la machine du développeur) comme une cible de déploiement à part entière.
Mon conseil sur ce point, c’est de faire en sorte que l’intégration d’un nouveau développeur se limite à :
- git clone <url du dépôt>
- npm install
- npm test
- npm start
- affichage du lien de l'application en console
Tout ceci devant bien sûr s’accompagner d’un README. Vous pouvez même adopter une démarche de README Driven Development.
Cela veut souvent dire qu’il est nécessaire de ne pas dépendre obligatoirement d’un serveur de base de données local, d’un schéma de base de données locale, de fichiers ou mots de passe particuliers pour lancer les tests ou faire tourner l’application. On parle souvent dans ces cas-là de Developer Experience (DX). Deux semaines pour monter un projet en local, vous aimez ? ;)
Le meilleur moyen pour atteindre cette facilité de prise en main, c’est d’automatiser l’installation des dépendances. C’est justement le sujet du chapitre suivant.
II. Dépendances
Déclarez explicitement et isolez vos dépendances
Pour ce chapitre, nous sommes plutôt gâtés par l’écosystème node.js. Avec la direction qu’a prise originellement npm de n’installer par défaut les dépendances que localement à votre projet, de les déclarer dans un fichier normalisé et la possibilité de compiler à la volée certains addons binaires, le travail est mâché ! Bon... Ce n'est pas vraiment par hasard : Isaac Schlueter, l'auteur de npm, avait dû lire 12 factor apps.
À noter que vous pouvez également utiliser Yarn, sorti l’année dernière, qui propose de figer les versions des dépendances installées pour assurer au mieux la répétabilité des installations de modules. npm5 le fait désormais également par défaut. Si les 2 sont fonctionnellement équivalent, Yarn met l’accent sur la rapidité des installations communes ou répétées alors que npm a le net avantage d’être installé avec toutes les versions de node.js.
Notez bien que npm vous permet de faire la différence entre vos dépendances de développement (framework de test, bibliothèques de stub, couverture de code, outil de build) et vos dépendances de production (celles qui seront utilisées une fois l’application déployée et lancée).
L’utilisation privilégiée des installation locales des modules vous permettra d’automatiser plus facilement votre Intégration Continue et votre Déploiement Continu. Alors que de nombreux paquets disponibles sur npm sont en réalité des programmes en ligne de commande (grunt-cli, babel-cli, ember-cli, angular-cli, etc.), sachez cependant qu’il n’est pas nécessaire de les avoir installés globalement (npm install --global) sur votre intégration continue. Si vous les installez localement à votre projet (devDependencies), ils seront disponibles de 4 manières (pour babel par exemple) :
- en lançant ./node_modules/.bin/babel --help
- en lançant npm run env babel -- --help
- en l’utilisant depuis script défini dans package.json
- en lançant npx babel --help (intégré depuis npm@5.2)
Évitez autant que possible les modules qui nécessitent une compilation car elle compte implicitement sur la présence d’une chaîne de compilation (gcc, cl, etc.) et de certains headers de développements qui ne sont pour leur part pas spécifiquement fournis par npm. Dans l’univers node.js, en ce qui concerne les dépendances externes à votre code, vous ne devez donc compter que sur l’exécutable node.js, l’exécutable npm et les modules que vous pourrez installer avec ce dernier.
III. Configuration
Stockez la configuration dans l’environnement
Nous entrons un peu plus dans le vif du sujet ici. C’est un point crucial si vous voulez déployer facilement vos applications avec les outils de déploiement cloud disponibles aujourd’hui (Docker a largement adopté cette contrainte). En substance : les variables d’environnement offrent la moindre adhérence entre votre code et la configuration relative à l’environnement de déploiement. Toutes les plateformes d'exécution permettent de passer des couples clé-valeur en chaînes de caractère par des variables d’environnement. En vous limitant à cette interface simple, vous vous économiserez la réflexion spécifique à chaque plateforme. Enfin, je trouve que ce fonctionnement rend explicite que la configuration doit être injectée par la plateforme d'exécution pour un déploiement donné, et non tirée par l’application.
Un des bénéfices de cette approche est que les changements de configuration ne nécessitent pas de changement de code, seulement d’un redémarrage du programme. Finie la gestion dans Git d’un fichier de configuration pour chaque environnement ciblé et pour chacun des développeurs en y laissant de surcroît traîner des mots de passe !
Pour node.js, lire les variables d’environnement se fait très simplement via la variable globale process.env['CLEF_DE_CONFIGURATION'].
Il existe beaucoup de modules pour aider à la gestion de la configuration. Sont très répandus nconf, dotenv et envalid par exemple. Beaucoup ont le défaut de faire trop de choses en plus de ce qui nous intéresse ici : arguments de ligne de commande, fichier de configuration, fichier de configuration en fonction du dossier courant, base de données de configurations, etc. C’est en réalité très facile d’utiliser directement process.env ou de coder votre propre couche d’accès et validation. C’est ce que j’ai fait par exemple avec envie, qui valide vos entrées et génère également une documentation à la volée (mais bon, du coup, maintenant vous pouvez l’utiliser).
const Envie = require('envie')
const Joi = require('joi')
const envie = Envie({
PORT: Joi
.number()
.min(0)
.default(3000)
.description('Port on which the HTTP server will listen'),
DATABASE_URL: Joi
.string()
.uri(({ scheme: ['postgres', 'mysql', 'sqlite'] }))
.optional()
.description('Connection string of the main database'),
LOG_LEVEL: Joi
.string()
.only('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('debug')
.description('Level of verbosity for the logs')
})
// When you need to access your configuration
server.listen(envie.get('PORT'))
DatabaseService.use(envie.get('DATABASE_URL'))
Vous avez peut-être remarqué dans cet exemple que j’ai mis des valeurs par défaut. Lorsque vous ajoutez des clés de configuration à votre application qui ne sont pas optionnelles, alors vous devez absolument prévoir une valeur par défaut. La bonne valeur par défaut est celle qui simplifiera les déploiements qui sont les plus fréquents. Les déploiements les plus fréquents sont ceux qui visent un poste de développement ! Grosso modo, prévoyez vos valeurs par défaut pour qu’un nouveau développeur puissent faire npm install && npm start.
IV. Services Externes
Traitez les services externes comme des ressources attachées
Dans ce chapitre, il est expliqué qu’un service externe avec lequel on communique via le réseau est à traiter comme une ressource liée, c’est-à-dire une ressource qui n’est pas explicitement décrite dans votre projet mais dont l’interface est connue de votre code. 12 Factor App traite ici indifféremment les bases de données, les API publiques, les files de messages ou les serveurs de mail. Toutes les ressources auxquelles vous accédez doivent être spécifiées au code via la configuration, typiquement grâce à une URL qui explique au passage quelle interface (protocole) utiliser.
Par exemple, pour une base de données MongoDB, votre code ne doit pas faire la distinction entre un serveur que vous lancez dans votre infrastructure et un service en SaaS. L’accès à chacun d’eux est décrit par une URL (parfois appelée “chaîne de connexion” ou connection string). Il se trouve que le client mongodb classique reconnaît les URL du type : mongodb://user:password@hostname/database.
Le même principe est applicable à de nombreux types de bases de données et forcément aux API web. Il est très facile de décliner ce principe à d'autres ressources en déconstruisant une url avec les modules natifs url et querystring.
const Url = require('url')
const Ftp = require('basic-ftp')
const updateFileUrl = process.env['UPDATE_FILE_URL']
// -> ftp://someuser:somepwd@my.ftp.com/~/some/file/location.csv
async function getUpdateFile() {
const updateFileSpec = Url.parse(updateFileUrl)
console.log(updateFileSpec)
const {
auth, // someuser:somepwd
hostname, // my.ftp.com
port, // null
pathname // /~/some/file/location.csv
} = updateFileSpec
const [ username, password ] = auth.split(':')
const client = new Ftp.Client()
await Ftp.connect(client, hostname, port || 21)
await Ftp.login(client, username, password)
/// etc...
}
Comme vous le voyez dans cet exemple, une simple URL FTP contient déjà tous les éléments nécessaires pour récupérer un fichier distant.
Il en reste huit !
Voici qui couvre les 4 premiers grands points des applications à 12 facteurs dans le contexte spécifique de node.js. Comme vous le constatez, ces concepts ayant fait leur chemin au sein de la communauté, la plupart des outils sont déjà disponibles et largement adoptés. Il ne tient qu’à vous de les utiliser à bon escient, en gardant à l’esprit que les raccourcis que vous pourrez prendre sur la gestion des dépendances, la gestion de version ou bien celle de la configuration pourront vous faire perdre du temps in fine lors des déploiements avec les outils natifs de plateformes cloud.
Dans les prochains articles nous détaillerons les autres “facteurs” à respecter pour préparer votre application node.js au cloud !