Avantages / inconvénients de Mongoose
Avantages
- Library utilisée de facto par la communauté NodeJS pour MongoDb. On retrouve donc beaucoup de documentation.
- Permet de définir des models de données et d’enforcer leur validation, ce que ne permettait pas MongoDb < 3.2 (Schema Validation — MongoDB Manual)
- La librairie est stable et maintenue depuis de nombreuses années
- Propose quelques méthodes haut niveau comme
populate
pour simplifier unaggregate
et un$lookup
. - Propose un système de hooks permettant d’exécuter des actions avant / après un type de query (par exemple rajouter un log après un insert en base)
Inconvénients
- La validation des schémas se fait au niveau applicatif, il n’y a donc pas de validation lorsqu’on interagit avec la base de données depuis des clients autres (mongo shell, Compass, …) ce qui laisse la place à des erreurs.
- Mongoose ne retourne pas de POJO mais des objets qui lui sont propres. On peut donc les muter puis les sauvegarder en base grâce à l’utilisation de
model.prototype.save()
. Attention alors quand on utilise ces objets et qu’on les fait circuler dans notre chaine d’appels, il y a un risque d’effet de bord. Il est possible de rajouter un appel àlean()
à nos queries pour que Mongoose retourne un POJO (Mongoose v6.1.1: API docs) :Users.find({ first_name: 'Léo' }).lean()
; - Les performances sont moindres comparé au driver natif (MongoDB Native Driver vs Mongoose: Performance Benchmarks | Jscrambler Blog)
- L’API de Mongoose diffère parfois légèrement de celle de Mongodb qu’il faut connaitre pour éviter des comportements inattendus (par exemple les méthodes pour muter les données sont wrappées dans un
$set
de manière transparente).
L’équipe de MongoDB a fait un comparatif assez complet ici : MongoDB & Mongoose: Compatibility and Comparison
Validation des données : Json Schema vs mongoose
Mongoose :
new Schema({ firstName: { type: String, required: true, min: 2, }, lastName: { type: String, required: true, min: 2, }, phone: { type: String, validate: { validator: function(v) { return /\\\\d{3}-\\\\d{3}-\\\\d{4}/.test(v); }, message: props => `${props.value} is not a valid phone number!` }, required: [true, 'User phone number required'] }, });
Json Schema :
{ properties: { firstName: { type: 'string', minLength: 2 }, lastName: { type: 'string', minLength: 2 }, phone: { type: 'string' }, }, required: ['firstName', 'lastName'], }
On voit ici que JSON Schema ne permet pas d’implémenter des logiques de validations personnalisées puisque que ce sont des schemas statiques. Ils ont ainsi l’avantage d’être language agnostic ou encore de pouvoir être retourné par une API à un client qui pourra l’exploiter.
Ici il faudra alors valider le format du champ phone
au niveau du code métier de l’application.
Utiliser la validation MongoDB Json Schema
Depuis la version 3.2, il est possible d’enforcer la validation d’un document MongoDB à partir d’un JSON Schema (Specification | JSON Schema), bien que ce ne soit vraiment utilisable que depuis la v5 qui rajoute la raison pour laquelle une validation a été rejetée.
{ "code": 121, "errmsg": "Document failed validation", "errInfo": { "failingDocumentId": ObjectId("5fe0eb9642c10f01eeca66a9"), "details": { "operatorName": "$jsonSchema", "schemaRulesNotSatisfied": [ { "operatorName": "properties", "propertiesNotSatisfied": [ { "propertyName": "price", "details": [ { "operatorName": "minimum", "specifiedAs": { "minimum": 0 }, "reason": "comparison failed", "consideredValue": -2 } ] } ] } ] } } }
Dans l’exemple ci-dessus on peut voir que la validation a échoué puisque le champ price
ne pouvait pas avoir une valeur inférieure à 0.
Ajouter un Json Schema à une collection MongoDB
Il est possible d’ajouter une validation Json Schema a une collection existante :
db.runCommand({ collMod: "users", validator: { $jsonSchema: { bsonType: "object", required: ["first_name", "last_name"], properties: { first_name: { bsonType: 'string', minLength: 2 }, last_name: { bsonType: 'string', minLength: 2 }, } }, }, });
Par défaut, MongoDB applique la validation sur TOUS les documents existants, incluant ceux qui ne respectent pas le schema de validation (si votre model a évolué au fil du temps par exemple, attention à ce que le schema ne soit pas trop restrictif). Il est possible de demander à MongoDB de n’enforcer la validation que sur les documents existants qui respectent les règles de validation en rajoutant
validationLevel: ‘moderate’
à la query (par défaut sa valeur est strict).
Plus de détails dans la documentation de MongoDB : Schema Validation — MongoDB Manual
Intégration dans une application NodeJS
En prenant cet exemple d’application Node ExpressJs qui utilise Mongoose : Node.js (forked) – StackBlitz
Intégrer le client MongoDB
Installer le client : npm i —save mongodb
.
On vient mettre à jour initDatabase
pour utiliser le driver :
async function initDatabase() { const client = new MongoClient(connectionString, { serverSelectionTimeoutMS: 1000, }); await client.connect(); const db = client.db(config.dbName); await createCollections({ db, models }); // une première étape qui permet de créer les collections en base si nécessaire await registerJsonSchemaValidators({ db, models }); // une deuxième étape dans laquelle on vient enregistrer les json schemas de validation pour chaque collection loadDriverCollections({ db, models }); // et une dernière étape qui va nous servir à garder en mémoire l’instance de chaque db.collection du client MongoDB (ce sera plus clair par la suite). }
Ici on rajoute trois étapes en plus de la connexion à la base
Comme l’idée ici est de pouvoir migrer facilement de Mongoose au MongoDB driver, il va falloir faire en sorte de minimiser les différences d’utilisation entre notre librairie et Mongoose, notamment pour ce qui est d’utiliser les models.
Nous allons donc exposer une fonction registerModel
qui aura à la fois comme but de créer le model en interne, mais aussi d’en récupérer une instance pour pouvoir l’utiliser directement, comme le propose mongoose :
const UserModel = require('../models/User'); function findOneUserById({ userId }) { return findOne({ _id: userId }); }
L’implémentation de la fonction registerModel
:
const models = {}; function registerModel(name, { schema }) { Object.assign(models, { [name]: { name, schema, collection: {}, // mongodb driver collection instance }, }); return models[name].collection; }
Premièrement, on vient rajouter un object models
qui nous permettra de garder en mémoire au sein du module les models enregistrés par l’application.
Pour l’exemple, le format d’un model est très simple puisqu’on voit qu’il ne se compose que d’un name
et d’un schema
(le JSON schema). On pourrait très bien imaginer rajouter d’autres propriétés telles que indexes
nous permettant de créer des index…
On initialise un model vide à l’appel de registerModel
grâce à Object.assign
. La propriété collection
est un object vide (qui contiendra une fois la connexion avec MongoDB établie l’instance de la collection du driver MongoDB) ce qui nous permettra de le modifier par référence un peu plus tard.
On peut maintenant définir un model comme suit :
const userSchema = { bsonType: 'object', properties: { email: { type: 'string', format: 'email' }, password: { type: 'string', minLength: 8 }, }, required: ['email', 'password'], }; module.exports = registerModel('user', { schema: userSchema, });
On voit ici que le format email
est supporté par JSON Schema ce qui est plutôt pratique.
La liste des types supportés par JSON Schema est disponible ici JSON Schema Reference — Understanding JSON Schema 2020-12 documentation
Il ne manque plus qu’à créer la collection en base (si nécessaire, on ne créer que celles qui n’existent pas) et à lui assigner un JSON Schema.
async function createCollections({ db, models }) { const existingCollections = await db .listCollections({}, { nameOnly: true }) .toArray(); const modelsCollectionsNames = _.map(models, 'name'); const collectionsToCreate = _.difference( existingCollections, modelsCollectionsNames ); const createCollectionsPromises = _.map( collectionsToCreate, (collectionName) => db.createCollection(collectionName) ); return Promise.all(createCollectionsPromises); }
On récupère tout d’abord la liste de noms des collections existantes en base (await db.listCollections({}, { nameOnly: true }).toArray()
) puis on vient récupérer celles qui n’existent pas pour finalement les créer.
function registerJsonSchemaValidators({ db, models }) { const registerValidatorsPromises = _.map(models, ({ schema, name }) => db.command({ collMod: name, validator: { $jsonSchema: schema }, validationLevel: 'strict', }) ); return Promise.all(registerValidatorsPromises); }
Ensuite on vient mettre à jour notre référence à l’objet collection du MongoDB driver pour NodeJS :
function loadDriverCollections({ db, models }) { _.forEach(models, (model) => { const collection = db.collection(model.name); Object.assign(model.collection, collection); }); }
En terme d’usage, c’est très proche de ce que peut proposer Mongoose bien qu’il manque certains sucres syntaxiques tels que populate
pour faire des aggregations ou encore sort
et paginate
. Il faudra plutôt utiliser un aggregate
MongoDB pour les remplacer, ce qui a aussi l’avantage d’écrire des queries utilisables à la fois dans notre application que dans un Mongoshell ou autre client MongoDB.
Projet d’exemple : Mongodb driver – StackBlitz