Cours NestJS · Module 8: Cache et performances
Compression et optimisation des réponses - accélérer les messagers de l'Empire
Dans cette leçon6
Commandant des légions ! L'endpoint qui liste les légions envoie deux mégaoctets de JSON, y compris des champs qu'aucun client ne devrait voir. Imagine les messagers de l'Empire qui transportent d'immenses rouleaux de parchemin entre les provinces. Un prétorien avisé compresse les messages : au lieu de chroniques complètes, il envoie des rapports concis. Dans NestJS, la compression et l'optimisation des réponses relèvent du même art : on réduit les réponses pour qu'elles arrivent plus vite au client.
Compression Middleware - compression gzip/deflate
La compression est le moyen le plus simple d'accélérer les réponses HTTP. Le middleware compression compresse lui-même les réponses selon l'en-tête Accept-Encoding du client :
1// main.ts - ajout de la compression
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4import compression from 'compression';
5
6async function bootstrap() {
7 const app = await NestFactory.create(AppModule);
8
9 // Activer la compression (gzip, deflate et, depuis la version 1.8, brotli)
10 app.use(compression({
11 filter: (req, res) => {
12 // Ne pas compresser les réponses avec l'en-tête x-no-compression
13 if (req.headers['x-no-compression']) {
14 return false;
15 }
16 // Filtre par défaut - compresser text/html, application/json, etc.
17 return compression.filter(req, res);
18 },
19 threshold: 1024, // Compresser les réponses > 1KB
20 level: 6, // Niveau de compression zlib (0-9, par défaut -1, soit 6)
21 }));
22
23 await app.listen(3000);
24}
25
26bootstrap();Le paquet gère deflate, gzip et, depuis la version 1.8, aussi brotli : dans un test, un client acceptant gzip, deflate, br a reçu du brotli. Le middleware ajoute aussi l'en-tête Vary: Accept-Encoding. Le niveau zlib est un nombre de 0 à 9, et la valeur par défaut -1 correspond actuellement à 6. La documentation de NestJS conseille, en cas de trafic important, de déplacer la compression vers un reverse proxy comme Nginx.
Response Serialization avec class-transformer
class-transformer permet de contrôler quels champs d'un objet partent dans la réponse. C'est comme un censeur de l'Empire qui décide quelles informations peuvent quitter les murs du Sénat :
1// legion.entity.ts
2import { Exclude, Expose, Transform } from 'class-transformer';
3
4export class LegionResponseDto {
5 @Expose()
6 id: number;
7
8 @Expose()
9 name: string;
10
11 @Expose()
12 province: string;
13
14 // Cacher les données internes - ne pas les envoyer hors des murs de l'Empire !
15 @Exclude()
16 internalCode: string;
17
18 @Exclude()
19 secretOrders: string;
20
21 // Transformation - convertir dans un format lisible par les citoyens
22 @Transform(({ value }) => Math.round(value), { toPlainOnly: true })
23 @Expose()
24 soldiers: number;
25
26 @Transform(({ value }) => new Date(value).toLocaleDateString('pl-PL'), { toPlainOnly: true })
27 @Expose()
28 foundedAt: string;
29
30 // Masquage conditionnel - n'afficher que si l'utilisateur a le rang
31 @Expose({ groups: ['admin', 'senator'] })
32 budget: number;
33}@Exclude() cache un champ, @Expose() l'affiche, et @Expose({ groups }) ne l'affiche que si la requête possède l'un des groupes. @Transform() modifie une valeur, et { toPlainOnly: true } limite la modification à l'envoi. Sans cette option, la transformation s'exécute deux fois pour les objets venant de la base, et dans le test la date est devenue « Invalid Date ».
ClassSerializerInterceptor
La sérialisation est effectuée par ClassSerializerInterceptor. On l'enregistre globalement :
1// app.module.ts - un intercepteur de sérialisation global
2import { Module } from '@nestjs/common';
3import { APP_INTERCEPTOR } from '@nestjs/core';
4import { ClassSerializerInterceptor } from '@nestjs/common';
5
6@Module({
7 providers: [
8 {
9 provide: APP_INTERCEPTOR,
10 useClass: ClassSerializerInterceptor,
11 },
12 ],
13})
14export class AppModule {}Au lieu de l'enregistrement global, tu peux mettre @UseInterceptors(ClassSerializerInterceptor) sur un contrôleur : l'un ou l'autre, car les deux ensemble sérialisent deux fois. Attention au piège : l'intercepteur ne fonctionne que sur des instances de classe. Un objet ordinaire, par ex. issu de lean(), passe intact : dans le test, internalCode et secretOrders ont fuité. L'option type aide :
1// legion.controller.ts - utilisation dans un contrôleur
2import { Controller, Get, SerializeOptions } from '@nestjs/common';
3import { LegionResponseDto } from './legion.entity';
4import { LegionService } from './legion.service';
5
6@Controller('legions')
7@SerializeOptions({ type: LegionResponseDto }) // les objets ordinaires deviennent des instances du DTO
8export class LegionController {
9 constructor(private readonly legionService: LegionService) {}
10
11 @Get()
12 findAll(): Promise<LegionResponseDto[]> {
13 // Les champs avec @Exclude() sont retirés automatiquement de la réponse
14 return this.legionService.findAll();
15 }
16
17 @Get('admin')
18 @SerializeOptions({ type: LegionResponseDto, groups: ['admin'] })
19 findAllAdmin(): Promise<LegionResponseDto[]> {
20 // Les champs avec @Expose({ groups: ['admin'] }) seront visibles
21 return this.legionService.findAll();
22 }
23}@SerializeOptions({ type }) transforme les objets ordinaires en instances du DTO avant la sérialisation. Les options de la méthode remplacent celles de la classe, c'est pourquoi on répète type à côté de groups.
Pagination - découper les réponses en pages
Au lieu d'envoyer tous les enregistrements d'un coup, comme toute l'armée par une seule route, on découpe la réponse en pages. Les paramètres arrivent dans la query sous forme de texte, donc un DTO les valide :
1// pagination.dto.ts
2import { Type } from 'class-transformer';
3import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
4
5export class PaginationDto {
6 @IsOptional()
7 @Type(() => Number)
8 @IsInt()
9 @Min(1)
10 page?: number = 1;
11
12 @IsOptional()
13 @Type(() => Number)
14 @IsInt()
15 @Min(1)
16 @Max(100)
17 limit?: number = 20;
18
19 @IsOptional()
20 @IsIn(['id', 'name', 'province'])
21 sortBy?: string = 'id';
22
23 @IsOptional()
24 @IsIn(['ASC', 'DESC'])
25 sortOrder?: 'ASC' | 'DESC' = 'ASC';
26}
27
28export class PaginatedResponse<T> {
29 data: T[];
30 meta: {
31 page: number;
32 limit: number;
33 total: number;
34 totalPages: number;
35 hasNextPage: boolean;
36 hasPreviousPage: boolean;
37 };
38}@Type(() => Number) transforme le texte en nombre quand le ValidationPipe global a transform: true. @Max(100) bloque limit=1000000, et @IsIn ne laisse passer que les champs de tri connus : trier sur un champ caché, comme un hash de mot de passe, révélerait son ordre.
Le service récupère la page et le nombre total d'enregistrements en parallèle :
1// legion.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectModel } from '@nestjs/mongoose';
4import { Model } from 'mongoose';
5
6@Injectable()
7export class LegionService {
8 constructor(@InjectModel(Legion.name) private legionModel: Model<Legion>) {}
9
10 async findPaginated(pagination: PaginationDto): Promise<PaginatedResponse<any>> {
11 const { page, limit, sortBy, sortOrder } = pagination;
12 const skip = (page - 1) * limit;
13
14 // MongoDB avec Mongoose
15 const [data, total] = await Promise.all([
16 this.legionModel
17 .find()
18 .sort({ [sortBy]: sortOrder === 'ASC' ? 1 : -1 })
19 .skip(skip)
20 .limit(limit)
21 .lean() // lean() renvoie un POJO au lieu d'un Mongoose Document - plus rapide !
22 .exec(),
23 this.legionModel.countDocuments(),
24 ]);
25
26 const totalPages = Math.ceil(total / limit);
27
28 return {
29 data,
30 meta: {
31 page,
32 limit,
33 total,
34 totalPages,
35 hasNextPage: page < totalPages,
36 hasPreviousPage: page > 1,
37 },
38 };
39 }
40}lean() renvoie des objets ordinaires, l'option type de la section précédente est donc utile pour la sérialisation. Sur de grandes collections, countDocuments() peut être lent, et skip ralentit sur les pages lointaines : le curseur de la leçon sur l'optimisation aide alors.
Response Caching Headers
Les en-têtes de cache permettent aux navigateurs et aux CDN de garder les réponses localement. Un intercepteur les pose sur le chemin du retour :
1// cache-headers.interceptor.ts
2import {
3 Injectable,
4 NestInterceptor,
5 ExecutionContext,
6 CallHandler,
7} from '@nestjs/common';
8import { createHash } from 'node:crypto';
9import { Observable } from 'rxjs';
10import { tap } from 'rxjs/operators';
11import { Response } from 'express';
12
13@Injectable()
14export class CacheHeadersInterceptor implements NestInterceptor {
15 constructor(
16 private readonly maxAge: number = 300, // 5 minutes par défaut
17 ) {}
18
19 intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
20 const response = context.switchToHttp().getResponse<Response>();
21
22 return next.handle().pipe(
23 tap((body) => {
24 // Cache-Control - combien de temps un messager peut garder une copie
25 response.setHeader(
26 'Cache-Control',
27 `public, max-age=${this.maxAge}, s-maxage=${this.maxAge * 2}`
28 );
29
30 // ETag - l'identifiant de version de la ressource, calculé à partir du contenu
31 // Le navigateur le renvoie dans If-None-Match et reçoit 304 Not Modified
32 response.setHeader('ETag', this.generateETag(body));
33 }),
34 );
35 }
36
37 private generateETag(body: unknown): string {
38 // Même contenu = même ETag ; W/ signale un ETag faible
39 const hash = createHash('sha1').update(JSON.stringify(body)).digest('base64url');
40 return `W/"${hash}"`;
41 }
42}max-age s'applique au navigateur, s-maxage aux caches partagés comme un CDN. N'utilise public que pour des données communes à tous ; les données d'un utilisateur reçoivent private. La première version calculait l'ETag à partir de Date.now(), il changeait donc à chaque requête et le 304 n'arrivait jamais. Express attribue lui-même un ETag faible et répond 304, donc ton propre ETag n'a de sens que si tu peux le calculer à moindre coût, par ex. à partir de updatedAt.
L'intercepteur a un paramètre maxAge, on le crée donc nous-mêmes avec new :
1// Utilisation dans un contrôleur
2@Controller('provinces')
3export class ProvinceController {
4 constructor(private readonly provinceService: ProvinceService) {}
5
6 @Get()
7 @UseInterceptors(new CacheHeadersInterceptor(600)) // 10 minutes de cache
8 findAll() {
9 return this.provinceService.findAll();
10 }
11
12 @Get(':id')
13 @UseInterceptors(new CacheHeadersInterceptor(3600)) // 1 heure de cache
14 findOne(@Param('id') id: string) {
15 return this.provinceService.findOne(id);
16 }
17}Avec @UseInterceptors(CacheHeadersInterceptor), NestJS essaierait d'injecter un nombre dans le constructeur. Les en-têtes de cache s'apprennent dans cet ordre : Cache-Control est la base, ETag ajoute un identifiant de version, Vary: Accept-Encoding sépare les copies dans les caches intermédiaires, et If-None-Match est envoyé par le navigateur quand il redemande.
Les décorateurs @Exclude, @Expose, @Transform - récapitulatif
Trois décorateurs clés de class-transformer :
- @Exclude() - cache un champ de la réponse (comme un ordre secret de César),
- @Expose() - marque explicitement un champ à envoyer (comme un édit public),
- @Transform() - modifie une valeur avant l'envoi (comme l'interprète de l'Empire).
Un exemple complet réunit les trois :
1// Exemple complet
2import { Exclude, Expose, Transform } from 'class-transformer';
3
4export class SoldierResponse {
5 @Expose()
6 id: number;
7
8 @Expose()
9 name: string;
10
11 @Expose()
12 rank: string;
13
14 @Exclude()
15 passwordHash: string; // N'envoie jamais le mot de passe !
16
17 @Exclude()
18 internalNotes: string; // Notes internes
19
20 @Transform(({ value }) => value > 1000 ? 'Veteranus' : 'Tiro', { toPlainOnly: true })
21 @Expose()
22 experienceLevel: string; // Transformation nombre -> texte
23
24 @Transform(({ value }) => value?.toISOString(), { toPlainOnly: true })
25 @Expose()
26 enlistedAt: string; // Date au format ISO
27}passwordHash ne disparaît toutefois que si le sérialiseur reçoit une instance de classe : avec un objet ordinaire, le mot de passe part chez le client. Je te recommande une règle : n'envoie vers l'extérieur que des DTO, jamais des entités brutes. Dans la prochaine leçon, les messagers recevront des files d'attente.
Retiens : un préteur avisé n'envoie pas de chroniques complètes quand un rapport concis suffit, et n'y joint jamais les ordres secrets.
Code de cette leçon : src/compression-optimization.ts
1// Compression et Response Optimization
2import { Injectable } from '@nestjs/common';
3import { Exclude, Expose, Transform } from 'class-transformer';
4
5// ===========================================
6// 1. Response DTO avec sérialisation
7// ===========================================
8
9class LegionResponseDto {
10 @Expose()
11 id: number;
12
13 @Expose()
14 name: string;
15
16 @Expose()
17 province: string;
18
19 // Champs cachés - ne pas envoyer au-delà des murs de l'Empire !
20 @Exclude()
21 internalCode: string;
22
23 @Exclude()
24 secretOrders: string;
25
26 @Transform(({ value }) => Math.round(value))
27 @Expose()
28 soldiers: number;
29
30 @Expose({ groups: ['admin'] })
31 budget: number;
32}
33
34// ===========================================
35// 2. Pagination pattern
36// ===========================================
37
38interface PaginatedResponse<T> {
39 data: T[];
40 meta: {
41 page: number;
42 limit: number;
43 total: number;
44 totalPages: number;
45 hasNextPage: boolean;
46 };
47}
48
49@Injectable()
50class PaginationService {
51 paginate<T>(items: T[], page: number, limit: number): PaginatedResponse<T> {
52 const total = items.length;
53 const totalPages = Math.ceil(total / limit);
54 const start = (page - 1) * limit;
55 const data = items.slice(start, start + limit);
56
57 return {
58 data,
59 meta: {
60 page,
61 limit,
62 total,
63 totalPages,
64 hasNextPage: page < totalPages,
65 },
66 };
67 }
68}
69
70// ===========================================
71// 3. Demonstration
72// ===========================================
73
74const service = new PaginationService();
75const legions = Array.from({ length: 50 }, (_, i) => ({
76 id: i + 1,
77 name: 'Legion ' + (i + 1),
78 province: ['Gallia', 'Hispania', 'Britannia'][i % 3],
79 soldiers: Math.floor(Math.random() * 5000) + 1000,
80}));
81
82const page1 = service.paginate(legions, 1, 10);
83const page2 = service.paginate(legions, 2, 10);
84
85console.log('=== Compression & Response Optimization ===');
86console.log('Page 1 :', page1.data.length, 'enregistrements');
87console.log('Page 2 :', page2.data.length, 'enregistrements');
88console.log('Total:', page1.meta.total);
89console.log('Pages :', page1.meta.totalPages);
90console.log('');
91console.log('@Exclude() - masque un champ de la réponse');
92console.log('@Expose() - marque explicitement un champ');
93console.log('@Transform() - modifie la valeur');
94console.log('compression() - gzip/deflate middleware');
95console.log('Cache-Control, ETag - en-têtes de cache HTTP');
96Tu as repéré une erreur dans cette leçon ?