Applications node.js à 12 facteurs (partie 1) - Une base de code saine

le 22/01/2018 par Florent Jaby
Tags: Software Engineering

logo node.jsAu 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 à :

  1. git clone <url du dépôt>
  2. npm install
  3. npm test
  4. npm start
  5. 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) :

É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 !