Aller au contenu
Architecture d'un projet node.js Bulletproof

Architecture d'un projet node.js Bulletproof

·12 mins·

Introduction
#

Express.js est un excellent framework pour la crĂ©ation d’une API REST en node.js, mais il ne vous donne aucune indication sur la manière d’organiser votre projet node.js.

Bien que cela puisse paraĂ®tre stupide, c’est un problème rĂ©el.

Une organisation correcte de la structure de votre projet node.js évitera la duplication de code, améliorera la stabilité et, éventuellement, vous aidera à faire évoluer vos services si cela est effectué correctement.

Cet article est une recherche approfondie, issue de mes annĂ©es d’expĂ©rience dans le traitement d’un projet node.js mal structurĂ©, de mauvais schĂ©mas et d’innombrables heures de refactorisation de code et de dĂ©placements.

La structure du dossier 🏢
#

Voici la structure du projet node.js dont je parle.

J’utilise ceci dans chaque service d’API REST de node.js que je construis. Voyons en dĂ©tail ce que chaque composant fait.

src
│   app.js          # App entry point
└───api             # Express route controllers for all the endpoints of the app
└───config          # Environment variables and configuration related stuff
└───jobs            # Jobs definitions for agenda.js
└───loaders         # Split the startup process into modules
└───models          # Database models
└───services        # All the business logic is here
└───subscribers     # Event handlers for async task
└───types           # Type declaration files (d.ts) for Typescript

C’est plus qu’un moyen de commander des fichiers JavaScript…

Architecture à 3 couches 🥪
#

L’idĂ©e est d’utiliser le principe de sĂ©paration des prĂ©occupations pour Ă©loigner la logique mĂ©tier des routes de l’API node.js.

3 layer pattern

Parce qu’un jour, vous voudrez utiliser votre logique mĂ©tier sur un outil CLI, ou ne pas aller très loin, dans une tâche rĂ©currente.

Et faire un appel API du serveur node.js Ă  lui-mĂŞme, ce n’est pas une bonne idĂ©e…

3 layer pattern for node.js REST API

Ne placez pas votre logique métier dans les contrôleurs
#

Vous pouvez ĂŞtre tentĂ© d’utiliser simplement les contrĂ´leurs express.js pour stocker la logique mĂ©tier de votre application, mais cela devient rapidement du code spaghetti. Dès que vous aurez besoin d’Ă©crire des tests unitaires, vous finirez par avoir affaire Ă  des mocks complexes pour des objets express.js tel que req ou res.

Il est difficile de distinguer quand une rĂ©ponse doit ĂŞtre envoyĂ©e et quand continuer le traitement en “arrière-plan”, disons après que la rĂ©ponse a Ă©tĂ© envoyĂ©e au client.

Voici un exemple de ce qu’il ne faut pas faire.

route.post('/', async (req, res, next) => {
  // This should be a middleware or should be handled by a library like Joi.
  const userDTO = req.body;
  const isUserValid = validators.user(userDTO)
  if(!isUserValid) {
    return res.status(400).end();
  }
  // Lot of business logic here...
  const userRecord = await UserModel.create(userDTO);
  delete userRecord.password;
  delete userRecord.salt;
  const companyRecord = await CompanyModel.create(userRecord);
  const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);
  ...whatever...
  // And here is the 'optimization' that mess up everything.
  // The response is sent to client...
  res.json({ user: userRecord, company: companyRecord });
  // But code execution continues :(
  const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
  eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
  intercom.createUser(userRecord);
  gaAnalytics.event('user_signup',userRecord);
  await EmailService.startSignupSequence(userRecord)
});

Utilisez une couche de service pour votre logique métier 💼
#

Cette couche est l’endroit oĂą votre logique mĂ©tier doit vivre.

C’est juste un ensemble de classes, suivant les principes SOLID appliquĂ©s Ă  node.js.

Dans cette couche, il ne devrait exister aucune forme de “requĂŞte SQL”, utilisez la couche d’accès aux donnĂ©es pour cela.

  • Éloignez votre code du routeur express.js
  • Ne transmettez pas l’objet req ou res Ă  la couche service
  • Ne renvoyez aucun Ă©lĂ©ment liĂ© Ă  la couche de transport HTTP, tel qu’un code d’Ă©tat ou des en-tĂŞtes de la couche de service.

Exemple

route.post('/', 
  validators.userSignup, // this middleware take care of validation
  async (req, res, next) => {
    // The actual responsability of the route layer.
    const userDTO = req.body;
    // Call to service layer.
    // Abstraction on how to access the data layer and the business logic.
    const { user, company } = await UserService.Signup(userDTO);
    // Return a response to client.
    return res.json({ user, company });
  });

Voici comment votre service travaillera dans les coulisses.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
export default class UserService() {
async Signup(user) {
  const userRecord = await UserModel.create(user);
  const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id 
  const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created
  ...whatever
  await EmailService.startSignupSequence(userRecord)
  ...do more stuff
  return { user: userRecord, company: companyRecord };
}
}

Utilisez aussi un calque Pub/Sub 🎙️️
#

Le modèle pub/sub va au-delĂ  de l’architecture classique Ă  3 couches proposĂ©e ici, mais il est extrĂŞmement utile.

Le simple endpoint de l’API node.js qui crĂ©e un utilisateur immĂ©diatement, peut vouloir appeler des services tiers, peut-ĂŞtre Ă  un service d’analyse, ou peut-ĂŞtre dĂ©marrer une sĂ©quence de courriels.

TĂ´t ou tard, cette simple opĂ©ration “crĂ©er” fera plusieurs choses, et vous obtiendrez 1000 lignes de code, le tout en une seule fonction.

Cela viole le principe de la responsabilité unique.

Il est donc préférable de séparer les responsabilités dès le départ, afin que votre code reste maintenable.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';
export default class UserService() {
  async Signup(user) {
    const userRecord = await UserModel.create(user);
    const companyRecord = await CompanyModel.create(user);
    const salaryRecord = await SalaryModel.create(user, salary);
    eventTracker.track(
      'user_signup',
      userRecord,
      companyRecord,
      salaryRecord
    );
    intercom.createUser(
      userRecord
    );
    gaAnalytics.event(
      'user_signup',
      userRecord
    );
    await EmailService.startSignupSequence(userRecord)
    ...more stuff
    return { user: userRecord, company: companyRecord };
  }
}

Un appel impĂ©ratif Ă  un service dĂ©pendant n’est pas la meilleure façon de le faire.

Une meilleure approche est d’Ă©mettre un Ă©vĂ©nement, c’est-Ă -dire “un utilisateur s’est inscrit avec cet email”.

Et vous avez terminĂ©, c’est maintenant la responsabilitĂ© des listeners de faire leur travail.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';
export default class UserService() {
  async Signup(user) {
    const userRecord = await this.userModel.create(user);
    const companyRecord = await this.companyModel.create(user);
    this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
    return userRecord
  }
}

Vous pouvez maintenant scinder les gestionnaires d’Ă©vĂ©nements/listeners en plusieurs fichiers.

eventEmitter.on('user_signup', ({ user, company }) => {
  eventTracker.track(
    'user_signup',
    user,
    company,
  );
  intercom.createUser(
    user
  );
  gaAnalytics.event(
    'user_signup',
    user
  );
})
eventEmitter.on('user_signup', async ({ user, company }) => {
  const salaryRecord = await SalaryModel.create(user, company);
})
eventEmitter.on('user_signup', async ({ user, company }) => {
  await EmailService.startSignupSequence(user)
})

Vous pouvez mettre les instructions en attente dans un bloc try-catch ou vous pouvez simplement le laisser Ă©chouer et gĂ©rer le ‘unhandledPromise’ process.on('unhandledRejection',cb)

Injection de dépendances 💉
#

Dependency Injection (D.I.) ou inversion of control (IoC) est un modèle commun qui aidera l’organisation de votre code, en “injectant” ou en passant par le constructeur les dĂ©pendances de votre classe ou fonction.

De cette façon, vous aurez la possibilitĂ© d’injecter une “dĂ©pendance compatible” lorsque, par exemple, vous Ă©crirez les tests unitaires pour le service, ou lorsque le service sera utilisĂ© dans un autre contexte.

Code sans D.I.

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';  
class UserService {
  constructor(){}
  Sigup(){
    // Caling UserMode, CompanyModel, etc
    ...
  }
}

Code avec injection manuelle de dépendance

export default class UserService {
  constructor(userModel, companyModel, salaryModel){
    this.userModel = userModel;
    this.companyModel = companyModel;
    this.salaryModel = salaryModel;
  }
  getMyUser(userId){
    // models available throug 'this'
    const user = this.userModel.findById(userId);
    return user;
  }
}

Vous pouvez maintenant injecter des dépendances personnalisées.

import UserService from '../services/user';
import UserModel from '../models/user';
import CompanyModel from '../models/company';
const salaryModelMock = {
  calculateNetSalary(){
    return 42;
  }
}
const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
const user = await userServiceInstance.getMyUser('12346');

Le nombre de dĂ©pendances qu’un service peut avoir est infini, et refactoriser chaque instanciation de celui-ci lorsque vous en ajoutez un nouveau est une tâche ennuyeuse et sujette aux erreurs.

C’est pourquoi des frameworks d’injection de dĂ©pendance ont Ă©tĂ© crĂ©Ă©s.

L’idĂ©e est de dĂ©clarer vos dĂ©pendances dans la classe, et quand vous avez besoin d’une instance de cette classe, vous appelez simplement le Service Locator.

Voyons un exemple utilisant typedi, une bibliothèque npm qui apporte l’injection de dĂ©pendances Ă  node.js

Pour en savoir plus sur l’utilisation de typedi, consultez la documentation officielle

AVERTISSEMENT exemple en typescript

import { Service } from 'typedi';
@Service()
export default class UserService {
  constructor(
    private userModel,
    private companyModel, 
    private salaryModel
  ){}
  getMyUser(userId){
    const user = this.userModel.findById(userId);
    return user;
  }
}

services/user.ts Maintenant typedi s’occupera de rĂ©soudre toute dĂ©pendance dont le UserService a besoin.

import { Container } from 'typedi';
import UserService from '../services/user';
const userServiceInstance = Container.get(UserService);
const user = await userServiceInstance.getMyUser('12346');

L’abus d’appels de localisateur de service est un anti-pattern

Utilisation de l’injection de dĂ©pendance avec Express.js dans Node.js

L’utilisation de D.I. dans express.js est la dernière pièce du puzzle de l’architecture de ce projet node.js.

Couche de routage

route.post('/', 
  async (req, res, next) => {
    const userDTO = req.body;
    const userServiceInstance = Container.get(UserService) // Service locator
    const { user, company } = userServiceInstance.Signup(userDTO);
    return res.json({ user, company });
  });

GĂ©nial, le projet Ă  l’air gĂ©nial ! C’est tellement organisĂ© que j’ai envie de coder quelque chose en ce moment.

Un exemple de test unitaire 🕵🏻
#

En utilisant l’injection de dĂ©pendance et ces modèles d’organisation, le test unitaire devient vraiment simple.

Vous n’avez pas besoin de simuler des objets req/res ou de require(…)

Exemple: Test unitaire pour la mĂ©thode d’inscription de l’utilisateur

tests/unit/services/user.js

import UserService from '../../../src/services/user';
describe('User service unit tests', () => {
  describe('Signup', () => {
    test('Should create user record and emit user_signup event', async () => {
      const eventEmitterService = {
        emit: jest.fn(),
      };
      const userModel = {
        create: (user) => {
          return {
            ...user,
            _id: 'mock-user-id'
          }
        },
      };
      const companyModel = {
        create: (user) => {
          return {
            owner: user._id,
            companyTaxId: '12345',
          }
        },
      };
      const userInput= {
        fullname: 'User Unit Test',
        email: '[email protected]',
      };
      const userService = new UserService(userModel, companyModel, eventEmitterService);
      const userRecord = await userService.SignUp(teamId.toHexString(), userInput);
      expect(userRecord).toBeDefined();
      expect(userRecord._id).toBeDefined();
      expect(eventEmitterService.emit).toBeCalled();
    });
  })
})

Cron Jobs et tâches récurrentes ⚡
#

Ainsi, maintenant que la logique mĂ©tier est encapsulĂ©e dans la couche service, il est plus facile de l’utiliser depuis un job Cron.

Vous ne devriez jamais compter sur setTimeout ou une autre façon primitive de retarder l’exĂ©cution du code, mais sur un framework qui persiste vos jobs, et l’exĂ©cution de ceux-ci, dans une base de donnĂ©es.

De cette façon, vous aurez le contrôle sur les jobs échoués, et la rétroaction de ceux qui réussissent.

Configurations et secrets 🤫
#

Suivant les concepts Ă©prouvĂ©s de Twelve-Factor App pour node.js, la meilleure approche pour stocker les clĂ©s API et les chaĂ®nes de connexions aux base de donnĂ©es, c’est en utilisant dotenv.

Mettez un fichier .env, qui ne doit jamais ĂŞtre validĂ© (mais qui doit exister avec des valeurs par dĂ©faut dans votre rĂ©fĂ©rentiel) puis, le paquet npm dotenv charge le fichier .env et insert les variables dans l’objet process.env de node.js.

Cela pourrait suffire, mais j’aimerais ajouter une Ă©tape supplĂ©mentaire. Avoir un fichier config/index.ts oĂą le paquet npm dotenv et charge le fichier .env et puis j’utilise un objet pour stocker les variables, dons nous avons une structure et un code d’autocomplĂ©tion.

config/index.js

const dotenv = require('dotenv');
// config() will read your .env file, parse the contents, assign it to process.env.
dotenv.config();
export default {
  port: process.env.PORT,
  databaseURL: process.env.DATABASE_URI,
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  },
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  },
  mailchimp: {
    apiKey: process.env.MAILCHIMP_API_KEY,
    sender: process.env.MAILCHIMP_SENDER,
  }
}

De cette façon, vous Ă©vitez d’inonder votre code avec les instructions process.env.MY_RANDOM_VAR, et en ayant l’autocomplĂ©tion vous n’avez pas besoin de savoir comment nommer la variable d’environnement.

Loaders 🏗️
#

J’ai pris ce modèle du microframework de W3Tech mais sans dĂ©pendre de leur paquet.

L’idĂ©e est de diviser le processus de dĂ©marrage de votre service node.js en modules testables.

Voyons une initialisation d’application express.js classique

const mongoose = require('mongoose');
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const cors = require('cors');
const errorhandler = require('errorhandler');
const app = express();
app.get('/status', (req, res) => { res.status(200).end(); });
app.head('/status', (req, res) => { res.status(200).end(); });
app.use(cors());
app.use(require('morgan')('dev'));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json(setupForStripeWebhooks));
app.use(require('method-override')());
app.use(express.static(__dirname + '/public'));
app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
require('./config/passport');
require('./models/user');
require('./models/company');
app.use(require('./routes'));
app.use((req, res, next) => {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});
app.use((err, req, res) => {
  res.status(err.status || 500);
  res.json({'errors': {
    message: err.message,
    error: {}
  }});
});
... more stuff 
... maybe start up Redis
... maybe add more middlewares
async function startServer() {    
  app.listen(process.env.PORT, err => {
    if (err) {
      console.log(err);
      return;
    }
    console.log(`Your server is ready !`);
  });
}
// Run the async function to start our server
startServer();

Comme vous le voyez, cette partie de votre application peut être un vrai gâchis.

Voici une façon efficace d’y faire face.

const loaders = require('./loaders');
const express = require('express');
async function startServer() {
  const app = express();
  await loaders.init({ expressApp: app });
  app.listen(process.env.PORT, err => {
    if (err) {
      console.log(err);
      return;
    }
    console.log(`Your server is ready !`);
  });
}
startServer();

Maintenant, les chargeurs ne sont plus que de minuscules fichiers avec un but concis

loders/index.js

import expressLoader from './express';
import mongooseLoader from './mongoose';
export default async ({ expressApp }) => {
  const mongoConnection = await mongooseLoader();
  console.log('MongoDB Intialized');
  await expressLoader({ app: expressApp });
  console.log('Express Intialized');
  // ... more loaders can be here
  // ... Initialize agenda
  // ... or Redis, or whatever you want}

Le loader express

loaders/express.js

import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as cors from 'cors';
export default async ({ app }: { app: express.Application }) => {
  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.enable('trust proxy');
  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));
  // ...More middlewares
  // Return the express app
  return app;
})

Le loader mongo

loaders/mongoose.js

import * as mongoose from 'mongoose'
export default async (): Promise<any> => {
  const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
  return connection.connection.db;
}
```## Conclusion
Nous plongeons en profondeur dans la structure d'un projet node.js testé en production, voici quelques conseils résumés:
- Utiliser une architecture Ă  3 couches.
- Ne mettez pas votre logique métier dans les contrôleurs express.js
- Ayez l'injection de dépendance pour votre tranquillité d'esprit.
- Ne divulguez jamais vos mots de passe, secrets et clés API, utilisez un gestionnaire de configuration.
- Divisez vos configurations de serveur node.js en petits modules qui peuvent être chargés indépendamment.