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
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
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
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
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 :
- Des variables d'environnement pour toutes les valeurs de configuration
- 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