users (sans rate-limit)groupsusers)Dans ce TD nous allons faire évoluer une architecture complète autour d’un service users. Étape après étape nous ajouterons un service groups, un front minimal, puis différentes optimisations (SSE, rate-limit, cache Redis, RabbitMQ + dashboard indépendant).
users (sans rate-limit)Créez un dossier users/ avec l’ossature suivante.
package.json
{
"type": "module",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"devDependencies": {
"nodemon": "^3.1.10"
},
"dependencies": {
"@faker-js/faker": "^10.1.0",
"express": "^5.1.0",
"morgan": "^1.10.1"
}
}
Dockerfile
FROM node:25-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 80
CMD ["npm", "run", "dev"]
src/index.js
import express from 'express';
import morgan from 'morgan';
import router from './routes.js';
import { seedUsers } from './users.js';
const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.use(router);
const PORT = 80;
app.listen(PORT, async () => {
console.log('Users service running…');
await seedUsers(); // génération dès le démarrage
});
src/routes.js
import express from 'express';
import users from './users.js';
const router = express.Router();
router.get('/', (req, res) => res.send('Simple user API!'));
router.get('/users', users.getUsers);
router.get('/users/:uuid', users.getUser);
export default router;
src/users.js
import { faker } from '@faker-js/faker';
const USERS_COUNT = 100;
let store = {};
export async function seedUsers(force = false) {
if (!force && Object.keys(store).length) return;
faker.seed(123);
store = {};
for (let i = 0; i < USERS_COUNT; i++) {
const uuid = faker.string.uuid();
store[uuid] = {
pseudo: faker.internet.userName(),
prenom: faker.person.firstName(),
nom: faker.person.lastName(),
email: faker.internet.email()
};
}
console.log(`Generated ${USERS_COUNT} users.`);
}
async function getUsers(req, res) {
await seedUsers();
res.status(200).send(Object.keys(store));
}
async function getUser(req, res) {
await seedUsers();
const user = store[req.params.uuid];
if (!user) return res.status(404).send({ error: 'User not found' });
res.status(200).send(user);
}
export default { getUsers, getUser };
Lancez et testez le service.
groupsCe service permet de manipuler des groupes d’utilisateurs.
Dans un premier temps, nous allons nous concentrer sur les deux routes suivantes.
La première permet de récupérer la liste de tous les groupes. Un groupe est constitué d’un identifiant unique gid, du nom du groupe name et de la taille du groupe size.
La seconde route permet d’obtenir la liste de tous les utilisateurs d’un groupe donné.
Elle retourne les mêmes informations, augmentées d’un tableau members contenant toutes les informations sur les utilisateurs du groupe.
Pour obtenir ces informations, il est nécessarie d’utiliser le service users.
src/routes.js
// [...]
router.get('/groups', listGroups);
router.get('/groups/:id', getGroup);
// [...]
Écrivez une fonction seedGroups pour créer des groupes aléatoires.
Une approche simple est de mélanger le tableau d’utilisateurs et d’en extraire un sous-ensemble.
Comme il n’existe pas de fonction native pour mélanger un tableau en Javascript, vous pouvez faire un tirage aléatoire.
Cette solution est non optimale et non-uniforme mais fonctionne pour notre cas d’illustration.
Vous pouvez utiliser un code comme ça : userIds.sort(() => 0.5 - Math.random()).slice(0, size))
Réalisez un fichier docker-compose.yaml pour combiner les deux services avec un reverse-proxy jouant le rôle de API gateway.
Lancez et testez le service.
Utilisez Vite+React pour
GET /groups),GET /groups/:id),Exemple React rapide :
import { useEffect, useState } from 'react';
const API_URL = import.meta.env.GROUPS_API_URL || '/api/groups';
function App() {
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
// Charger la liste des groupes au montage
useEffect(() => {
const loadGroups = async () => {
try {
const res = await fetch(API_URL);
const data = await res.json();
setGroups(Array.isArray(data) ? data : []);
} catch (e) {
console.error('Erreur chargement groupes', e);
}
};
loadGroups();
}, []);
// Handler au clic sur un groupe
const handleSelectGroup = async (groupId) => {
try {
const res = await fetch(`${API_URL}/${groupId}`);
const data = await res.json();
setSelectedGroup(data);
} catch (e) {
console.error('Erreur chargement du groupe', e);
}
};
return (
<main>
<section>
<h2>Groupes</h2>
{groups.length === 0 && <p>Aucun groupe à afficher.</p>}
<ul>
{groups.map((group) => (
<li
key={group.id}
onClick={() => handleSelectGroup(group.id)}
>
<strong>{group.name}</strong>
{' — '}
{group.memberCount ?? 0} membres
</li>
))}
</ul>
</section>
<section>
<h2>Membres</h2>
{!selectedGroup && <p>Sélectionnez un groupe.</p>}
{selectedGroup && (
<ul>
{selectedGroup.members?.map((member) => (
<li key={member.uuid}>
<strong>
{(member.prenom || '') + (member.nom ? ` ${member.nom}` : '')}
</strong>
<em>{member.pseudo || member.uuid}</em>
<span>{member.email || member.error || 'Email inconnu'}</span>
</li>
))}
</ul>
)}
</section>
</main>
);
}
export default App;
Lancez et testez le front.
Les Server-Sent Events (SSE) sont un mécanisme HTTP unidirectionnel : le navigateur ouvre une seule requête longue durée via EventSource, et le serveur peut pousser des messages continus en envoyant du texte dans la réponse (chaque message commence par data: et se termine par une ligne vide). L’avantage est double : c’est simple (HTTP standard) et le client retente automatiquement la connexion si elle se coupe.
Nous allons faire en sorte que le service groups envoie des lignes :
event: progress
data: {...}
event: complete
data: {...}
groupsAjoutez une nouvelle route GET /groups/:id/stream qui :
Content-Type: text/event-stream) ;// 1. Ouverture SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 2. Fonction utilitaire pour envoyer un événement SSE
function sendEvent(eventName, data) {
res.write(`event: ${eventName}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
fetchUser),event: progress avec { percent, fetched, total } ;// 3. Récupération des membres
completed++;
const percent = Math.round((completed / total) * 100);
sendEvent('progress', { percent, fetched: completed, total });
event: complete avec les membres enrichis.// 4. Envoi final
sendEvent('complete', {
id: group.id,
name: group.name,
members: results
});
Astuce : utilisez Promise.all pour paralléliser et écrire un événement à chaque résolution (completed++).
new EventSource(${API_URL}/${id}/stream).progress arrive.complete et affichez la liste.Exemple de code
const [progress, setProgress] = useState(null);
const [groupData, setGroupData] = useState(null);
const handleSelectGroup = (groupId) => {
setProgress({ percent: 0 });
setGroupData(null);
const source = new EventSource(`${API_URL}/${groupId}/stream`);
source.addEventListener('progress', (event) => {
const data = JSON.parse(event.data);
setProgress(data);
});
source.addEventListener('complete', (event) => {
const data = JSON.parse(event.data);
setGroupData(data);
setProgress(null);
source.close();
});
source.onerror = () => {
console.log('Erreur SSE');
source.close();
setProgress(null);
};
};
Il faut pouvoir annuler la requête lorsqu’un autre groupe est sélectionné.
users)Utilisez express-rate-limit dans users/src/routes.js comme suit :
import rateLimit from 'express-rate-limit';
const userDetailLimiter = rateLimit({
windowMs: 60_000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' }
});
router.get('/users/:uuid', userDetailLimiter, users.getUser);
Testez l’application avec un taux de RPS assez bas. Que constatez-vous ?
Ajouter un cache dans le service groups pour limiter les requêtes au service users.
Expérimentez différentes valeurs de TTL pour visualiser l’impact sur les cache.miss.
Nous allons mettre en oeuvre un écran de supervision de l’activité du cache en utilisant un bus de messages.
RabbitMQ est un broker AMQP. Les producteurs publient des messages dans un exchange ; celui-ci les route vers une ou plusieurs queues consommées par d’autres services. Dans notre cas, l’API groups publie des événements (création de groupe, cache miss, etc.) sur un exchange fanout, et tous les consommateurs connectés reçoivent la même information.
Ajoutez un service rabbitmq (image rabbitmq:3.13-management). Fournissez RABBIT_URL=amqp://rabbitmq et RABBIT_EXCHANGE=groups.events aux services groups et notifier.
api/src/events.js
import amqp from 'amqplib';
const RABBIT_URL = process.env.RABBIT_URL || 'amqp://rabbitmq';
const RABBIT_EXCHANGE = process.env.RABBIT_EXCHANGE || 'groups.events';
let channelPromise;
async function getChannel() {
if (channelPromise) return channelPromise;
channelPromise = (async () => {
const connection = await amqp.connect(RABBIT_URL);
const channel = await connection.createChannel();
await channel.assertExchange(RABBIT_EXCHANGE, 'fanout', { durable: false });
return channel;
})();
return channelPromise;
}
export async function publishEvent(type, payload = {}) {
const channel = await getChannel();
const message = { type, payload, timestamp: new Date().toISOString() };
channel.publish(RABBIT_EXCHANGE, '', Buffer.from(JSON.stringify(message)), {
contentType: 'application/json'
});
}
Appelez publishEvent :
cache.js (événements cache.hit / cache.miss).notifierPetit service Express (notifier/) qui :
groups.events,hits/misses,GET /events) envoyant { type: 'cache.stats', hits, misses, total, missRate }.Pensez à désactiver le buffering Nginx pour /events.
Créez un dossier dashboard/ avec un simple index.html (vanilla JS) qui affiche les informations utiles :
<script>
const source = new EventSource('/events');
source.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type !== 'cache.stats') return;
// mettre à jour l'UI
};
</script>
Servez ce dashboard via un conteneur nginx séparé et une route /dashboard/.
En utilisant cette architecture, le front “groupes” continue de fonctionner pendant que le le dashboard vit sa vie, consomme les mêmes événements SSE, et affiche des stats globales sans dépendre du front initial.