Cours NestJS · Module 8: Cache et performances
API Rate Limiting et la couche de cache - contrôler le trafic portuaire
Dans cette leçon5
Un port accueille autant de navires qu'il peut en décharger. Quand il en arrive davantage, il y a deux issues : décharger plus vite ou en laisser entrer moins. Une application sous charge dispose exactement des deux mêmes - le cache réduit le travail que coûte chaque requête, et le rate limiting plafonne le nombre de requêtes que tu acceptes.
Cette leçon referme les deux côtés : d'abord la sentinelle à la porte, puis l'entrepôt d'où l'on délivre la marchandise sans descendre au dépôt.
La sentinelle - ThrottlerModule
NestJS possède un mécanisme intégré de limitation du trafic, n'écris donc pas ton propre compteur :
1@Module({
2 imports: [
3 ThrottlerModule.forRoot([
4 {
5 ttl: 60000,
6 limit: 100,
7 },
8 ]),
9 ],
10 providers: [
11 { provide: APP_GUARD, useClass: ThrottlerGuard },
12 ],
13})
14export class AppModule {}Deux nombres décrivent toute la règle. ttl est la longueur de la fenêtre en millisecondes - une minute ici. limit est le nombre de requêtes autorisées dans cette fenêtre. Ensemble : cent requêtes par minute depuis une adresse.
L'enregistrement par APP_GUARD fait de ThrottlerGuard un guard global - il s'applique à toutes les routes sans ajouter @UseGuards à chaque contrôleur. Une fois la limite dépassée, le client reçoit 429 Too Many Requests, et la méthode du contrôleur ne s'exécute pas du tout.
Les routes individuelles se desserrent ou se resserrent par un décorateur :
1@Post('login')
2@Throttle({ default: { ttl: 60000, limit: 5 } })
3login(@Body() dto: LoginDto) { }Cinq tentatives de connexion par minute au lieu de cent. C'est ici que le rate limiting cesse d'être une affaire de performance pour devenir une protection : sans lui, rien n'arrête la recherche de mots de passe par force brute.
L'entrepôt - un service de cache
L'autre côté relève du cache. L'envelopper dans ton propre service donne un seul endroit pour les clés et les durées de vie :
1@Injectable()
2export class CacheService {
3 constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
4
5 async get<T>(key: string): Promise<T | undefined> {
6 return this.cache.get<T>(key);
7 }
8
9 async set<T>(key: string, value: T, ttl = 300): Promise<void> {
10 await this.cache.set(key, value, ttl);
11 }
12
13 async del(key: string): Promise<void> {
14 await this.cache.del(key);
15 }
16}Trois méthodes suffisent à tout. get lit, set écrit avec une durée de vie, del supprime. Le ttl = 300 par défaut se remplace à chaque appel - court pour des données changeantes, long pour des tables de référence.
Note del - la méthode la plus facile à oublier en écrivant et la plus difficile à remplacer ensuite. Sans moyen de supprimer une clé, le seul recours contre des données périmées est d'attendre leur expiration.
Le cache automatique des endpoints
Pour de simples lectures, tu n'as même pas à écrire cela. CacheInterceptor s'en charge :
1@Controller('provinces')
2export class ProvincesController {
3 @Get()
4 @UseInterceptors(CacheInterceptor)
5 @CacheTTL(600)
6 findAll() {
7 return this.provincesService.findAll();
8 }
9}À chaque requête, il passe par cinq étapes, toujours dans cet ordre :
- Interception de la requête HTTP.
- Vérification de la clé dans le cache.
- Exécution du handler - seulement en cas de MISS.
- Enregistrement du résultat dans le cache.
- Renvoi de la réponse au client.
La troisième étape en est le cœur : en cas de succès, la méthode du contrôleur ne s'exécute pas du tout, il n'y a donc ni requête en base ni travail. L'interceptor construit la clé à partir de l'URL par défaut, si bien que /provinces?page=2 et /provinces?page=3 sont deux entrées distinctes.
D'où sa limite également : l'interceptor ne traite que les requêtes GET. Mettre en cache un POST n'a pas de sens, puisqu'il doit changer l'état et non le lire.
L'invalidation - la partie la plus difficile
Le cache est facile tant que les données ne changent pas. Quand elles changent, les entrées périmées doivent partir - et le processus compte quatre étapes :
- Détection du changement de données dans la source (BD).
- Identification des clés de cache à invalider.
- Suppression des données périmées du cache (
del). - Récupération et enregistrement des données fraîches dans le cache.
1async update(id: number, dto: UpdateProvinceDto) {
2 const province = await this.repo.save({ id, ...dto });
3
4 await this.cacheService.del(`province:${id}`);
5 await this.cacheService.del('provinces:all');
6
7 return province;
8}La deuxième étape cause le plus d'ennuis, et le code ci-dessus montre pourquoi. Modifier une province invalide deux clés : l'entrée de cette province et la liste de toutes, car la liste contient aussi ses données. Avec une troisième page de résultats et un cinquième filtre, ces clés se comptent par dizaines - et c'est là que commencent les oublis.
Alors en concevant tes clés pose-toi d'emblée la question inverse : non pas « comment enregistrer ceci », mais « que devrai-je supprimer quand cela changera ». Un cache que tu ne sais pas invalider est pire que pas de cache - car il montre des données périmées et tu l'ignores.
Récapitulatif
Le port contrôle son trafic et délivre la marchandise depuis l'entrepôt :
- sous charge, tu as deux voies : le cache réduit le travail par requête, le rate limiting plafonne le nombre de requêtes,
ThrottlerModule.forRootse décrit par deux nombres :ttl(la fenêtre en millisecondes) etlimit(les requêtes qu'elle autorise),- l'enregistrement par
APP_GUARDrendThrottlerGuardglobal ; dépasser la limite vaut au client un 429, @Throttleresserre la limite d'une route unique - à la connexion, c'est une protection, non une optimisation,- un service de cache enveloppe trois méthodes :
get,setavec TTL configurable, etdel, CacheInterceptoropère en cinq étapes : interception de la requête → vérification de la clé → exécution du handler (en cas de MISS) → enregistrement du résultat → renvoi de la réponse,- en cas de succès, la méthode du contrôleur ne s'exécute jamais ; la clé vient de l'URL, des paramètres différents donnent donc des entrées différentes,
- l'interceptor ne traite que les
GET, - l'invalidation en quatre étapes : détecter le changement en base → identifier les clés → supprimer les périmées (
del) → récupérer et enregistrer les fraîches, - un seul changement invalide d'ordinaire plusieurs clés - conçois tes clés en te demandant ce que tu devras supprimer.
Dans la prochaine leçon, nous aborderons la compression et l'optimisation des réponses - ce qui réduit le trafic à la sortie. En attendant, retiens ceci : le cache accélère le service, une limite protège de l'excès - et le difficile, dans un cache, n'est pas l'écriture mais de savoir quand la supprimer.
Code de cette leçon : src/rate-limiting.ts
1// API Rate Limiting - Contrôle du trafic portuaire
2import {
3 Injectable, NestMiddleware, HttpException, HttpStatus,
4} from '@nestjs/common';
5import { Request, Response, NextFunction } from 'express';
6
7// 1. Rate Limiter simple
8@Injectable()
9export class RateLimitMiddleware implements NestMiddleware {
10 private requests: Map<string, { count: number; resetTime: number }> = new Map();
11 private readonly MAX_REQUESTS = 100; // Max de requêtes
12 private readonly WINDOW_MS = 60 * 1000; // Fenêtre de temps : 1 minute
13
14 use(req: Request, res: Response, next: NextFunction) {
15 const clientIp = req.ip || 'unknown';
16 const now = Date.now();
17
18 let record = this.requests.get(clientIp);
19
20 // Réinitialiser si la fenêtre a expiré
21 if (!record || now > record.resetTime) {
22 record = { count: 0, resetTime: now + this.WINDOW_MS };
23 this.requests.set(clientIp, record);
24 }
25
26 record.count++;
27
28 // Set informational headers
29 res.setHeader('X-RateLimit-Limit', this.MAX_REQUESTS);
30 res.setHeader('X-RateLimit-Remaining',
31 Math.max(0, this.MAX_REQUESTS - record.count));
32 res.setHeader('X-RateLimit-Reset',
33 new Date(record.resetTime).toISOString());
34
35 if (record.count > this.MAX_REQUESTS) {
36 throw new HttpException(
37 'Too Many Requests - le port est surchargé !',
38 HttpStatus.TOO_MANY_REQUESTS
39 );
40 }
41
42 next();
43 }
44}
45
46// 2. NestJS Throttler (solution recommandée)
47// npm install @nestjs/throttler
48//
49// @Module({
50// imports: [
51// ThrottlerModule.forRoot({
52// ttl: 60, // Fenêtre de temps (secondes)
53// limit: 100, // Max de requêtes dans la fenêtre
54// }),
55// ],
56// })
57//
58// // Utilisation sur le contrôleur ou l'endpoint :
59// @UseGuards(ThrottlerGuard)
60// @Throttle(10, 60) // 10 requêtes par 60 secondes
61// @Get('tribute')
62// getTribute() { ... }
63
64// 3. Différentes stratégies de limitation
65const strategies = {
66 // Per IP - chaque client a sa propre limite
67 perIp: { key: 'ip', limit: 100, window: 60 },
68
69 // Per User - les utilisateurs connectés ont une limite plus élevée
70 perUser: { key: 'userId', limit: 500, window: 60 },
71
72 // Per Endpoint - limites différentes selon les endpoints
73 perEndpoint: {
74 'GET /tributes': { limit: 200, window: 60 },
75 'POST /tributes': { limit: 20, window: 60 },
76 'DELETE /tributes': { limit: 5, window: 60 },
77 },
78
79 // Sliding Window - comptage plus précis
80 slidingWindow: { limit: 100, window: 60, precision: 1 },
81};
82
83// 4. Redis-based rate limiting (pour plusieurs instances)
84// Chaque instance de l'API partage l'état dans Redis
85// Clé : ratelimit:{ip}:{endpoint}
86// Valeur : compteur de requêtes
87// TTL : durée de la fenêtre
88Tu as repéré une erreur dans cette leçon ?
Tâches pratiques dans le jeu
- Éditeur de code
Crée un service de cache avec des méthodes get/set/del utilisant le store Redis avec un TTL configurable
- Tri vertical
Ordonne les étapes du processus d'invalidation du cache, de la détection du changement à la mise à jour :
- Clique dans l'ordre
Ordonne l'ordre des opérations du CacheInterceptor lors du traitement d'une requête HTTP :