Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 420 minIntermédiaire

MVC avec Node.js et Express

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • organiser un projet Express en routes, contrôleurs, modèles et vues ;
  • écrire un modèle qui exécute des requêtes préparées avec mysql2 ;
  • rendre une vue EJS ou renvoyer du JSON depuis un contrôleur ;
  • profiter de la gestion des erreurs async d'Express 5.

4 exercices corrigés

Structurer une application Node.js en MVC avec Express, c'est répartir le code en quatre dossiers : routes/ associe une méthode HTTP et une adresse à une action, controllers/ contient ces actions, models/ exécute le SQL, views/ produit le HTML (ou, dans une API, le contrôleur renvoie du JSON). Express ne t'impose aucune organisation : c'est à toi de la choisir et de la tenir. Ce chapitre construit les pages « salles » de ResaSalles avec Express 5, mysql2 et le moteur de gabarits EJS, puis montre la même action en version API. Les bases de Node.js et d'Express sont dans le cours Node.js (Express.js : serveur et routes), qui organise aussi son API en routes, contrôleurs, services et dépôts (API REST CRUD avec Express).

L'arborescence du projet

text
resasalles-web/
├── .env                        ← paramètres de connexion, hors de Git
├── package.json
└── src/
    ├── server.js               ← démarre l'écoute
    ├── app.js                  ← crée l'application et assemble les pièces
    ├── db.js                   ← le pool de connexions mysql2
    ├── routes/
    │   └── salle.routes.js
    ├── controllers/
    │   └── salle.controller.js
    ├── models/
    │   └── salle.model.js
    └── views/
        ├── erreur.ejs
        └── salles/
            ├── liste.ejs
            └── detail.ejs

On installe trois paquets : npm install express mysql2 ejs. En novembre 2026, cela donne Express 5.3, mysql2 3.24 et EJS 7. Le projet utilise les modules ECMAScript (import et export) : ajoute "type": "module" dans package.json.

json
{
  "name": "resasalles-web",
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.js",
    "start": "node --env-file=.env src/server.js"
  },
  "dependencies": {
    "ejs": "^7.0.2",
    "express": "^5.3.0",
    "mysql2": "^3.24.6"
  }
}

L'option --env-file=.env de Node.js charge les variables du fichier .env dans process.env, sans paquet supplémentaire. --watch redémarre le serveur à chaque modification d'un fichier.

La connexion : un pool partagé

js
// src/db.js
import mysql from 'mysql2/promise';

// Un seul pool pour toute l'application ; les paramètres viennent de l'environnement (.env).
export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,
});

Un pool garde quelques connexions ouvertes et les prête aux requêtes qui arrivent ; c'est indispensable sur un serveur qui traite plusieurs requêtes en même temps. On le crée une seule fois et on le passe aux modèles.

Le modèle

js
// src/models/salle.model.js
// Le modèle : les données des salles et le SQL qui va avec. Aucune trace de req ni de res.
export function creerSalleModel(pool) {
  return {
    async lister() {
      const [lignes] = await pool.execute('SELECT id, nom, capacite, visio FROM salle ORDER BY nom');
      return lignes;
    },

    async trouverParId(id) {
      const [lignes] = await pool.execute('SELECT id, nom, capacite, visio FROM salle WHERE id = ?', [id]);
      return lignes[0] ?? null;
    },

    async reservations(id) {
      const [lignes] = await pool.execute(
        'SELECT debut, fin, objet FROM reservation WHERE salle_id = ? ORDER BY debut',
        [id],
      );
      return lignes;
    },
  };
}

pool.execute() envoie une requête préparée : les ? sont remplacés par les valeurs du tableau, côté serveur, sans risque d'injection. Elle renvoie un tableau dont le premier élément contient les lignes, d'où la déstructuration const [lignes] = …. Les colonnes DATETIME arrivent sous forme d'objets Date JavaScript.

creerSalleModel(pool) est une fonction fabrique : elle reçoit le pool et renvoie un objet dont les méthodes s'en servent. Le modèle n'importe pas db.js lui-même : on peut lui donner un autre pool, par exemple celui d'une base de test. C'est le même principe que le constructeur du modèle PHP ; une classe JavaScript (class SalleModel { constructor(pool) { … } }) ferait exactement le même travail.

Le contrôleur

js
// src/controllers/salle.controller.js
// Le contrôleur : lit la requête, appelle le modèle, choisit la vue.
export function creerSalleController(salleModel) {
  return {
    async liste(req, res) {
      const salles = await salleModel.lister();
      res.render('salles/liste', { salles });
    },

    async detail(req, res) {
      const id = Number(req.params.id);
      const salle = Number.isInteger(id) ? await salleModel.trouverParId(id) : null;
      if (!salle) {
        return res.status(404).render('erreur', { titre: 'Salle introuvable', message: "Cette salle n'existe pas." });
      }
      const reservations = await salleModel.reservations(id);
      res.render('salles/detail', { salle, reservations });
    },
  };
}

Chaque action reçoit req (la requête) et res (la réponse). Elle convertit les paramètres (req.params.id est toujours une chaîne : Number('abc') donne NaN, que Number.isInteger refuse), appelle le modèle et choisit la vue avec res.render(nomDeLaVue, données).

Les routes

js
// src/routes/salle.routes.js
import { Router } from 'express';

// Les routes : quelle URL déclenche quelle action du contrôleur.
export function creerSalleRoutes(salleController) {
  const router = Router();
  router.get('/', salleController.liste);
  router.get('/:id', salleController.detail);
  return router;
}

Un Router regroupe les routes d'une même ressource. Monté sur /salles dans app.js, il répond à GET /salles et GET /salles/:id. Le fichier de routes ne contient aucune logique : il se lit comme une table des matières de l'application.

Les vues EJS

html
<!-- src/views/salles/liste.ejs -->
<h1>Les salles</h1>
<ul>
  <% for (const salle of salles) { %>
    <li>
      <a href="/salles/<%= salle.id %>"><%= salle.nom %></a>
      (<%= salle.capacite %> places<%= salle.visio ? ', visio' : '' %>)
    </li>
  <% } %>
</ul>
html
<!-- src/views/salles/detail.ejs -->
<h1><%= salle.nom %></h1>
<p><%= salle.capacite %> places</p>
<% if (reservations.length === 0) { %>
  <p>Aucune réservation.</p>
<% } else { %>
  <ul>
    <% for (const r of reservations) { %>
      <li><%= r.debut.toLocaleString("fr-FR") %> → <%= r.fin.toLocaleTimeString("fr-FR") %> : <%= r.objet %></li>
    <% } %>
  </ul>
<% } %>

EJS mélange HTML et JavaScript : <% … %> exécute du code (boucle, condition), <%= … %> affiche une valeur échappée, comme htmlspecialchars en PHP. Une salle nommée <script>x</script> s'affiche &lt;script&gt;x&lt;/script&gt;, donc comme du texte. La balise <%- … %> affiche sans échapper : réserve-la à du HTML que tu as produit toi-même, jamais à une donnée saisie.

Pour la salle 1, la page affiche par exemple :

text
Mont-Blanc
12 places
03/12/2026 09:00:00 → 10:30:00 : Point hebdo

L'assemblage : app.js et server.js

js
// src/app.js
import express from 'express';
import { fileURLToPath } from 'node:url';
import { creerSalleModel } from './models/salle.model.js';
import { creerSalleController } from './controllers/salle.controller.js';
import { creerSalleRoutes } from './routes/salle.routes.js';

export function creerApp(pool) {
  const salleController = creerSalleController(creerSalleModel(pool));

  const app = express();
  app.set('view engine', 'ejs');
  app.set('views', fileURLToPath(new URL('./views', import.meta.url)));

  app.use('/salles', creerSalleRoutes(salleController));

  // Aucune route n'a répondu : 404.
  app.use((req, res) => {
    res.status(404).render('erreur', { titre: 'Page introuvable', message: "Cette page n'existe pas." });
  });
  // Une erreur a été levée (même dans une fonction async) : 500.
  app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).render('erreur', { titre: 'Erreur', message: 'Une erreur est survenue.' });
  });
  return app;
}
js
// src/server.js
import { creerApp } from './app.js';
import { pool } from './db.js';

const port = Number(process.env.PORT ?? 3000);
creerApp(pool).listen(port, () => {
  console.log(`ResaSalles sur http://localhost:${port}`);
});

Séparer app.js (construire l'application) et server.js (la démarrer sur un port) n'est pas un caprice : les tests d'intégration importent creerApp avec une base de test, sans ouvrir de port fixe (tester une API).

L'ordre des app.use compte. Express essaie les routes dans l'ordre où elles sont déclarées ; le middleware 404 vient après toutes les routes, et le gestionnaire d'erreurs, reconnaissable à ses quatre paramètres (err, req, res, next), vient en dernier.

La même action en version API

Dans une application où le front est écrit en React (chapitre 10), le serveur ne produit plus de HTML : il renvoie du JSON. Le modèle ne change pas d'une ligne. Le contrôleur remplace res.render par res.json :

js
// La même action, pour une API : le JSON joue le rôle de la vue.
async detailJson(req, res) {
  const id = Number(req.params.id);
  const salle = Number.isInteger(id) ? await salleModel.trouverParId(id) : null;
  if (!salle) return res.status(404).json({ erreur: 'Salle introuvable' });
  res.json({ ...salle, visio: Boolean(salle.visio), reservations: await salleModel.reservations(id) });
},
http
GET /api/salles/1 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":1,"nom":"Mont-Blanc","capacite":12,"visio":true,"reservations":[{"debut":"2026-12-03T08:00:00.000Z","fin":"2026-12-03T09:30:00.000Z","objet":"Point hebdo"}]}

Deux remarques sur ce JSON. Les dates sont converties au format ISO 8601 en temps universel (le Z final) : 9 h à Paris en décembre, c'est 8 h UTC. Le client les reconvertit à l'heure locale pour les afficher (les dates en JSON). Et visio : mysql2 renvoie la colonne TINYINT(1) telle quelle, donc 1 ou 0, pas true ou false. Pour une API, convertis-la dans le contrôleur, comme ici avec Boolean(salle.visio) (pour une liste : res.json(salles.map((s) => ({ ...s, visio: Boolean(s.visio) })))). Les clients (React, C#) reçoivent alors true ou false. Ce n'est pas un détail : un client C# dont la propriété Visio est un bool refuse de lire 1 et lève une JsonException.

C'est la preuve que MVC tient sa promesse : changer de « vue » (HTML vers JSON) n'a touché que la dernière ligne du contrôleur. Les règles de conception d'une API (codes de statut, format des erreurs, pagination) sont dans le cours API REST.

Les conventions de nommage

Express ne t'impose rien, mais les projets Node.js suivent des conventions qu'un correcteur reconnaît tout de suite :

ÉlémentConvention couranteExemple
Fichier de routes<ressource>.routes.jssalle.routes.js
Contrôleur<ressource>.controller.js, une méthode par actionliste, detail, creer, modifier, supprimer
Modèle ou Repository<ressource>.model.js ou <ressource>.repository.jssalle.model.js
Préfixe des routes d'une API/api (et souvent une version, /api/v1)/api/salles/1
Variables d'environnementMajuscules, préfixe par domaineDB_HOST, JWT_SECRET

L'important n'est pas la convention choisie, mais de la tenir partout. Un projet où un fichier s'appelle salleCtrl.js, un autre ReservationController.js et un troisième gestion-employes.js est plus long à lire qu'il ne le devrait.

Exercices

Exercice 1 sur 4 Brancher une nouvelle page Réussi À revoir

On veut la page GET /salles/visio, qui liste seulement les salles équipées pour la visioconférence. Écris la méthode du modèle, l'action du contrôleur et la route. Où places-tu la route par rapport à router.get('/:id', …), et pourquoi ?

Voir la réponseMasquer la réponse de l'exercice 1

Modèle :

js
async listerAvecVisio() {
  const [lignes] = await pool.execute('SELECT id, nom, capacite, visio FROM salle WHERE visio = 1 ORDER BY nom');
  return lignes;
},

Contrôleur (la vue salles/liste peut resservir) :

js
async listeVisio(req, res) {
  const salles = await salleModel.listerAvecVisio();
  res.render('salles/liste', { salles });
},

Route : router.get('/visio', salleController.listeVisio); avant router.get('/:id', …). Express essaie les routes dans l'ordre : si /:id vient d'abord, /salles/visio est pris pour la salle d'identifiant visio, Number('visio') vaut NaN, et la page répond « Salle introuvable ».

Tu avais trouvé ?

Exercice 2 sur 4 Où est l'erreur ? Réussi À revoir

Un étudiant a écrit ce contrôleur. Relève trois problèmes, de sécurité ou d'architecture.

js
async detail(req, res) {
  const [lignes] = await pool.query(`SELECT * FROM salle WHERE id = ${req.params.id}`);
  res.send(`<h1>${lignes[0].nom}</h1>`);
}
Voir la réponseMasquer la réponse de l'exercice 2
  1. Injection SQL : l'identifiant est collé dans la requête avec un gabarit de chaîne. Il faut une requête préparée (pool.execute('… WHERE id = ?', [id])).
  2. Faille XSS : le nom est inséré dans le HTML sans échappement. Une vue EJS avec <%= salle.nom %> l'échapperait.
  3. Architecture : le contrôleur contient du SQL (rôle du modèle) et du HTML (rôle de la vue). Et si la salle n'existe pas, lignes[0] vaut undefined : lignes[0].nom lève une erreur, la réponse est un 500 au lieu d'un 404.

Tu avais trouvé ?

Exercice 3 sur 4 Express 4 ou Express 5 ? Réussi À revoir

Tu reprends un projet qui contient, dans chaque contrôleur :

js
exports.liste = async (req, res, next) => {
  try {
    const salles = await Salle.lister();
    res.render('salles/liste', { salles });
  } catch (err) {
    next(err);
  }
};

Le projet passe à Express 5. Ces try/catch sont-ils encore nécessaires ? Que deviennent les erreurs ?

Voir la réponseMasquer la réponse de l'exercice 3

Ils ne sont plus nécessaires : Express 5 attrape le rejet de la promesse renvoyée par une fonction async et appelle lui-même next(err). L'erreur arrive au gestionnaire d'erreurs à quatre paramètres déclaré à la fin d'app.js. On peut donc simplifier chaque action en supprimant le try/catch. On garde un try/catch seulement là où l'on veut traiter une erreur précise sur place (par exemple transformer une erreur de clé dupliquée en message pour l'utilisateur).

Tu avais trouvé ?

Exercice 4 sur 4 HTML ou JSON ? Réussi À revoir

Dans ResaSalles, la page d'une salle existe en HTML (detail) et en JSON (detailJson). Quels fichiers ces deux versions partagent-elles ? Que faudrait-il ajouter pour une troisième version qui renvoie la liste des réservations au format CSV ?

Voir la réponseMasquer la réponse de l'exercice 4

Elles partagent le modèle (salle.model.js) et le routeur s'ils sont montés au même endroit ; seules les actions du contrôleur et la « vue » (le gabarit EJS d'un côté, l'objet passé à res.json de l'autre) diffèrent.

Pour le CSV : une nouvelle action qui appelle le même salleModel.reservations(id), construit les lignes (debut;fin;objet), fixe l'en-tête Content-Type: text/csv; charset=utf-8 et envoie le texte, plus une route (GET /salles/:id/reservations.csv). Le modèle ne change pas : c'est tout l'intérêt de la séparation.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : MVC en C# WinFormsRevoir le sommaire du cours