Un backend web clean en NodeJS

Photo by Fahrul Razi on Unsplash

Un backend web clean en NodeJS

Le but de ce "hands-on" est de réaliser un backend web complet en NodeJS de manière progressive avec des étapes simples, en essayant de produire du code moderne et de qualité et se poser les bonnes questions.

Cette matière à vocation pédagogique peut aisément servir de "boilerplate" pour des besoins variés de réalisation que ce soit pour accompagner un frontend ou une app mobile.

L'idée est de proposer un cadre simple et suffisamment évolutif pour servir de base à étendre avec vos propres besoins (bases / stores, files de message, …)

Etape 1

Code source

Projet initial

Créer le projet :

$ mkdir web-backend
$ cd web-backend
$ npm init
…
package name: (web-backend)
version: (1.0.0) 0.0.1
description: A clean web backend powered by NodeJS
entry point: (index.js) src/server.js
test command: 
git repository: 
keywords: 
license: (ISC) 
…

Pour propulser notre serveur web, plusieurs choix sont possibles :

  • Express : le framework le plus connu, historique, mais qui ne supporte pas les promesses et l'instruction async ce qui nous empêche d'utiliser du code moderne
  • NestJS : framework complet tout en un qui inclut beaucoup de briques et propose un cadre proche d'Angular, surdimensionné et trop structurant pour notre besoin
  • Fastify : assez proche d'express en terme de fonctionnalités et positionnement, plus moderne, assez ouvert, extensible et performant

Nous partirons sur Fastify qui a le mérite d'être complètement "promisifé" et efficace pour faire des choses simples.

Mais auparavant un peu de préparation pour installer des dépendances proprement…

Préparation du projet

Ce que l'on souhaite :

  • configurer npm pour sauvegarder par défaut les dépendances dans package.json
  • stocker les modules npm téléchargés dans un répertoire local tmp pour ne pas polluer le scope global

On en profite aussi pour créer notre .gitignore afin d'éviter de pousser dans le référentiel des sources des fichiers qui n'ont pas vocation à l'être (temporaires, personnels ou générés).

Copier ce contenu dans .gitignore (plus d'infos) :

# IDE settings
/.idea/
/.npm/
/.vscode/
/*.iml

# installed NPM modules
/node_modules/

# temp files
/tmp/
.DS_Store

# log files
npm-debug.log

# local files
*.local
.env

Fixer les versions de NodeJS et NPM

Pour cela on va utiliser l'excellent outil volta :

  • Installer volta
  • Configurer la version de NodeJS
      $ volta pin node@16.14.2
    
  • Configurer la version de NPM
      $ volta pin npm@8.5.5
    

Cela a pour effet d'ajouter un attribut volta dans package.json qui permet de définir les version de NodeJS et NPM à utiliser lorsqu'on exécute les commandes node et npm.

Configurer NPM

Créer un fichier .npmrc avec le contenu suivant :

cache=./.npm
engine-strict=true
save=true
save-exact=true
tag-version-prefix=""

Ajouter private: true au fichier package.json, pour éviter de publier par erreur dans la registry npm publique :

{
  "name": "web-backend",
  "version": "0.0.1",
  "private": true,
  "description": "A clean web backend powered by NodeJS",
…
}

Dépendances NPM

Installation de la dépendance fastify, notre dépendance principale qui permet de propulser le serveur web :

$ npm i fastify

Scripts NPM

Ajoutons une fonctionnalité de watch avec nodemon (plus d'infos), bien pratique pendant les phases de mise au point pour redémarrer notre futur serveur à chaud, ainsi que prettier (plus d'infos) pour normaliser le format de notre code source :

$ npm i -D nodemon prettier

On ajoute la configuration dans nodemon.json :

{
  "watch": [
    "package.json",
    "src",
    ".env"
  ],
  "ext": "js,json"
}

Déclarons les scripts NPM start et watch qui permettent de normaliser nos interactions avec le projet :

{
  …
    "scripts": {
    "prettier": "prettier --check .",
    "start": "node --experimental-specifier-resolution=node src/server",
    "test": "echo \"Error: no test specified\" && exit 1",
    "watch": "nodemon --exec npm start"
  },
  …
}

Pour vérifier le format des sources du projet, il suffira de lancer npm run prettier.

On souhaite utiliser une syntaxe moderne (ECMAScript) pour profiter des instructions import / export, il faut pour cela ajouter une option dans la commande node (plus d'infos), et déclarer le type de résolution des modules node avec module dans package.json :

{
  …
  "type": "module",
  …
}

Etape 2

Code source

Serveur "Hello"

Créer le source principal du serveur minimal src/server.js :

import Fastify from 'fastify'

const fastify = Fastify()

fastify.get('/', async () => ({ hello: 'world' }))

const start = async () => {
  try {
    await fastify.listen(3000)
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}

void start()

Démarrage de notre serveur :

$ npm run watch

Il est temps maintenant de tester le fonctionnement de notre serveur avec un client HTTP (cURL, Postman, ou encore httpie).

Par exemple avec httpie (plus d'infos) :

$ http :3000/
HTTP/1.1 200 OK
…
content-type: application/json; charset=utf-8

{
    "hello": "world"
}

C'est le moment de se congratuler avant de s'intéresser à l'itération suivante 😄

Etape 3

Code source

Ajouter une base MongoDb

Provisionner un serveur mongodb en local avec Docker en ajoutant la stack suivante dans docker-compose.yml :

version: '3'

services:

  mongo:
    image: mongo
    container_name: mongo
    ports:
      - "27017:27017"
    volumes:
      - mongodb-data:/data
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_ADMIN_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ADMIN_PASSWORD}
    network_mode: bridge
    restart: unless-stopped

  mongo-express:
    image: mongo-express
    container_name: mongo-express
    ports:
      - "8081:8081"
    links:
      - mongo
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_ADMIN_USERNAME}
      ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_ADMIN_PASSWORD}
    network_mode: bridge
    restart: unless-stopped
    depends_on:
      - mongo

volumes:
  mongodb-data:

Prérequis : installer docker et docker-compose

Puis démarrer les services :

$ docker-compose up -d

Provisionnement des données dans mongodb :

  • Se connecter à l'UI mongo express : localhost:8081
  • Créer une base dev-db ("Create database")
  • Créer une collection de documents animals ("Create collection")
  • Ajouter quelques documents :
      {
          "name": "Roger",
          "type": "Rabbit"
      }
    
      {
          "name": "Peppo",
          "type": "Cat"
      }
    
      {
          "name": "Pluto",
          "type": "Dog"
      }
    

Intégration du client Mongo avec Fastify

Pour utiliser mongodb depuis notre application nous avons besoin de :

  • fastify-mongodb : librairie client de mongodb
  • fastify-plugin : un helper de plugin pour fastify

Installer les dépendances

$ npm i fastify-mongodb fastify-plugin

Ajout d'une route GET /animals

Modifier src/plugins/routes-plugin.js pour ajouter la nouvelle route :

const routesPlugin = async (fastify) => {
  const animalsCollection = fastify.mongo.db.collection('animals')

  fastify.get('/', async () => ({ hello: 'world' }))

  fastify.get('/animals', () => animalsCollection.find().toArray())
}

export default routesPlugin

Pour que le gestionnaire de requête puisse fonctionner, il faut que fastify fournisse la propriété mongo. Cette fonctionnalité est fournie par le module fastify-mongodb associé au connecteur src/plugins/db-connector-plugin.js :

import fastifyPlugin from 'fastify-plugin'
import fastifyMongo from 'fastify-mongodb'

const dbConnector = async (fastify) => {
  fastify.register(fastifyMongo, {
    forceClose: true,
    url: 'mongodb://localhost:27017/dev-db',
  })
}

const dbConnectorPlugin = fastifyPlugin(dbConnector)

export default dbConnectorPlugin

Maintenant il faut enregistrer dbConnectorPlugin en adaptant src/server.js :

import Fastify from 'fastify'
import dbConnectorPlugin from './plugins/db-connector-plugin'
import routesPlugin from './plugins/routes-plugin'
…
fastify.register(dbConnectorPlugin)
fastify.register(routesPlugin)
…

Il est temps de tester l'application : npm run watch

Et consommer la ressource /animals :

$ http :3000/animals
HTTP/1.1 200 OK
…
content-type: application/json; charset=utf-8

[
    {
        "_id": "623ed2036d656e61c26e542a",
        "name": "Roger",
        "type": "Rabbit"
    },
    {
        "_id": "623ed2116d656e61c26e542c",
        "name": "Peppo",
        "type": "Cat"
    },
    {
        "_id": "623ed21b6d656e61c26e542e",
        "name": "Pluto",
        "type": "Dog"
    }
]

Notre serveur manque cruellement de traces…

Fastify s'appuie sur pino pour les traces, et de base elles sont en JSON, ce qui est plutôt adapté à des besoins de machine-to-machine, par exemple pour de la production où les logs sont analysées automatiquement.

En revanche pour le confort en local, on aimerait bien des traces plus "human readable", c'est là qu'intervient pino-pretty

On installe la dépendance :

$ npm i pino-pretty

On peut maintenant configurer le logger en modifiant src/server.js :

const fastify = Fastify({
  logger: {
    prettyPrint: {
      translateTime: true,
      ignore: 'pid,hostname,reqId,responseTime,req,res',
    },
  },
})
…

Maintenant il ne reste plus qu'à tester : comme on est en watch, pas besoin de faire quoi que ce soit, à part relancer une requête

[2022-…] INFO: Server listening at http://127.0.0.1:3000
[2022-…] INFO: incoming request
[2022-…] INFO: request completed

Etape 4

Code source

Un composant pour centraliser la configuration

Dans notre projet, un certain nombre d'éléments font actuellement l'objet de "hardcoding", ce n'est pas une bonne pratique et cela pose problème si par exemple la base mongodb change de port ou d'adresse, ou encore simplement de nom. Par ailleurs, notre serveur écoute forcément sur le port 3000, si l'environnement cible ne le permet pas, dommage.

Pour toutes ces raisons, il est temps de se doter d'une "confguration" qui apportera :

  • la possibilité de modifier des valeurs utilisées dans notre appli sans changer le code
  • la possibilité de définir ces valeurs en fonction de l'environnement au moment du run

Une réponse simple à ce besoin consiste à utiliser :

  1. Des variables d'environnement pour toutes les valeurs de configuration
  2. Un objet JavaScript représentant l'image de la configuration dans notre code (avec des valeurs typées contrairement aux variables d'environnement qui ne peuvent être que des strings)

Ainsi on spécifie les particularités dans des variables d'environnement, et notre composant de configuration construit la configuration finale.

Pour gérer les variables d'environnement, nous utiliserons le module dotenv qui est intéressant car il permet en plus de déclarer simplement nos variables d'environnement dans un fichier .env qui sera évidemment ignoré par Git grâce à notre .gitignore.

Installation de la dépendance :

$ npm i dotenv

Maintenant on peut créer le nouveau composant src/config.js :

import 'dotenv/config'

const config = {
  port: process.env.PORT || 3000,
  mongoDbUrl: process.env.MONGO_DB_URL || 'mongodb://localhost:27017/prod-db',
}

export default config

Adapter src/server.js pour loguer la configuration et s'assurer qu'elle est bien alimentée :

import Fastify from 'fastify'
import config from './config'
import dbConnectorPlugin from './plugins/db-connector-plugin'console.log('config :', config)
fastify.register(dbConnectorPlugin)
…

Remarque : on utilise ici console.log en guise de logger temporaire de mise au point, ce code doit disparaître au profit d'un logger plus industriel (naturellement, celui de fastify)

Résultat :

config : { port: 3000, mongoDbUrl: 'mongodb://localhost:27017/prod-db' }

Il faut maintenant faire en sorte de rendre cette configuration disponible pour le reste de notre application, en évitant de faire un couplage fort pour faciliter la réutilisation (on évite d'importer le composant directement partout où on en a besoin).

Pour cela, on modifie src/server.js pour utiliser un mécanisme de "décoration" élégant fourni par fastify, remplacer la ligne console.log par :

fastify.decorate('config', config)
console.log('config :', fastify.config)

Le résultat dans la console devrait être le même

Il suffit à ce stade d'utiliser la propriété config fournie par fastify dans les sources qui en ont besoin.

Dans src/plugins/db-connector-plugin.js remplacer :

    url: 'mongodb://localhost:27017/dev-db',

par :

    url: fastify.config.mongoDbUrl,

Et vérifier que l'application est toujours opérationnelle en refaisant une requête HTTP GET /animals.

Vous devriez obtenir :

$ http :3000/animals
HTTP/1.1 200 OK
…
content-type: application/json; charset=utf-8

[]

Ce qui est normal car la base utilisée par l'application est celle par défaut de notre configuration : prod-db

Pour rétablir le service, il suffit de spécifier la base de développement dans .env :

# Base Mongo DB de développement
MONGO_DB_URL=mongodb://localhost:27017/dev-db

Résultat :

$ http :3000/animals
HTTP/1.1 200 OK
…
content-type: application/json; charset=utf-8

[
    {
        "_id": "623ed2036d656e61c26e542a",
        "name": "Roger",
        "type": "Rabbit"
    },
    {
        "_id": "623ed2116d656e61c26e542c",
        "name": "Peppo",
        "type": "Cat"
    },
    {
        "_id": "623ed21b6d656e61c26e542e",
        "name": "Pluto",
        "type": "Dog"
    }
]

Il reste à rendre le port d'écoute du serveur configurable, avec la même approche.

Remplacer dans src/server.js :

    await fastify.listen(3000)

par :

    await fastify.listen(config.port)

Et on en profite pour changer notre trace de la configuration :

console.log('config :', fastify.config)

devient :

fastify.log.debug(`config : ${JSON.stringify(config)}`)

On constate que la trace a disparu, c'est normal car on utilise maintenant un log level, il faut maintenant rendre cette information configurable pour pouvoir la définir au "runtime" (avec la même approche : une variable d'environnement).

Modifier src/config.js pour avoir :

const config= {
  port: process.env.PORT || 3000,
  logLevel: process.env.LOG_LEVEL || 'error',
  mongoDbUrl: process.env.MONGO_DB_URL || 'mongodb://localhost:27017/prod-db',
}

Puis src/server.js pour avoir :

const fastify = Fastify({
  logger: {
    level: config.logLevel,
    prettyPrint: {
…

A ce stade, plus aucune trace n'apparaît, c'est encore normal puisque notre log level par défaut est error (pour s'en convaincre il suffit d'injecter n'importe quelle erreur dans le code et constater qu'elle est tracée ^^).

Il nous reste donc à définir le log level que l'on souhaite pour un environnement de développement dans notre fichier .env :

# Log lebvel
LOG_LEVEL=debug

# Base Mongo DB de développement
MONGO_DB_URL=mongodb://localhost:27017/dev-db

Résultat dans les traces :

[2022-…] DEBUG: config : {"port":3000,"logLevel":"debug","mongoDbUrl":"mongodb://localhost:27017/dev-db"}
[2022-…] INFO: Server listening at http://127.0.0.1:3000

Et voilà !

Nous avons mis en oeuvre un serveur backend NodeJS en utilisant le minimum de dépendances pour servir nos besoins :

  • Router les requêtes de manière élégante
  • Utiliser une syntaxe de code moderne
  • Mettre en place une boucle REPL confortable pour développer, privilégiant les petites itérations
  • Proposer une gestion de configuration simple et robuste
  • Proposer un cadre propre et extensible, sans être surdimensionné

Le but de cet exercice est aussi de prendre conscience qu'il y a un écart non négligeable entre faire une application rapide (par exemple avec express) avec un "sample" de quelques lignes, et investir dans un "boilerplate" qui pourra résister lorsque l'application va grossir : "le diable est dans les détails" 😄

Une suite à donner à ce "hands-on" qui pourrait être intéressante serait de déployer cette application dans un environnement cible, par exemple un service cloud / PaaS du type Heroku ou Netlify.

Mais c'est une autre histoire…

Retrouvez l'ensemble des sources sur github.com/NijiDigital