Kurs NestJS · Moduł 8: Cache i wydajność
Compression i Response Optimization - przyspieszenie posłańców Imperium
W tej lekcji6
Dowódco legionów! Endpoint z listą legionów wysyła dwa megabajty JSON-a, w tym pola, których żaden klient nie powinien zobaczyć. Wyobraź sobie posłańców Imperium, którzy wożą ogromne zwoje pergaminu między prowincjami. Mądry pretorianin kompresuje wiadomości: zamiast pełnych kronik wysyła zwięzłe raporty. W NestJS compression i response optimization to ta sama sztuka - zmniejszamy odpowiedzi, aby szybciej dotarły do klienta.
Compression Middleware - kompresja gzip/deflate
Kompresja to najprostszy sposób na przyspieszenie odpowiedzi HTTP. Middleware compression sam kompresuje odpowiedzi według nagłówka Accept-Encoding klienta:
1// main.ts - dodanie kompresji
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 // Włącz kompresję (gzip, deflate, a od wersji 1.8 także brotli)
10 app.use(compression({
11 filter: (req, res) => {
12 // Nie kompresuj odpowiedzi z nagłówkiem x-no-compression
13 if (req.headers['x-no-compression']) {
14 return false;
15 }
16 // Domyślny filtr - kompresuj text/html, application/json, etc.
17 return compression.filter(req, res);
18 },
19 threshold: 1024, // Kompresuj odpowiedzi > 1KB
20 level: 6, // Poziom kompresji zlib (0-9, domyślnie -1, czyli 6)
21 }));
22
23 await app.listen(3000);
24}
25
26bootstrap();Pakiet obsługuje deflate, gzip, a od wersji 1.8 także brotli: w teście klient akceptujący gzip, deflate, br dostał brotli. Middleware dokłada też nagłówek Vary: Accept-Encoding. Poziom zlib to liczba od 0 do 9, a domyślne -1 oznacza obecnie 6. Dokumentacja NestJS radzi przy dużym ruchu przenieść kompresję do reverse proxy, np. Nginx.
Response Serialization z class-transformer
class-transformer pozwala kontrolować, które pola obiektu wyjdą w odpowiedzi. To jak cenzor w Imperium, który decyduje, jakie informacje mogą opuścić mury Senatu:
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 // Ukryj wewnętrzne dane - nie wysyłaj za mury Imperium!
15 @Exclude()
16 internalCode: string;
17
18 @Exclude()
19 secretOrders: string;
20
21 // Transformacja - przelicz na format czytelny dla obywateli
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 // Warunkowe ukrywanie - pokaż tylko jeśli użytkownik ma rangę
31 @Expose({ groups: ['admin', 'senator'] })
32 budget: number;
33}@Exclude() ukrywa pole, @Expose() je pokazuje, a @Expose({ groups }) - tylko gdy żądanie ma jedną z grup. @Transform() zmienia wartość, a { toPlainOnly: true } ogranicza zmianę do wysyłki. Bez tej opcji transformacja przy obiektach z bazy uruchamia się dwa razy i w teście data zamieniła się w „Invalid Date”.
ClassSerializerInterceptor
Serializację wykonuje ClassSerializerInterceptor. Rejestrujemy go globalnie:
1// app.module.ts - globalny interceptor serializacji
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 {}Zamiast rejestracji globalnej możesz dać @UseInterceptors(ClassSerializerInterceptor) na kontrolerze - jedno albo drugie, bo razem serializują dwa razy. Uwaga na haczyk: interceptor działa tylko na instancjach klasy. Zwykły obiekt, np. z lean(), przechodzi nietknięty - w teście wyciekły internalCode i secretOrders. Pomaga opcja type:
1// legion.controller.ts - użycie w kontrolerze
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 }) // zwykłe obiekty staną się instancjami DTO
8export class LegionController {
9 constructor(private readonly legionService: LegionService) {}
10
11 @Get()
12 findAll(): Promise<LegionResponseDto[]> {
13 // Pola z @Exclude() zostaną automatycznie usunięte z odpowiedzi
14 return this.legionService.findAll();
15 }
16
17 @Get('admin')
18 @SerializeOptions({ type: LegionResponseDto, groups: ['admin'] })
19 findAllAdmin(): Promise<LegionResponseDto[]> {
20 // Pola z @Expose({ groups: ['admin'] }) będą widoczne
21 return this.legionService.findAll();
22 }
23}@SerializeOptions({ type }) zamienia zwykłe obiekty w instancje DTO przed serializacją. Opcje metody zastępują opcje klasy, dlatego przy groups powtarzamy type.
Pagination - stronicowanie odpowiedzi
Zamiast wysyłać wszystkie rekordy naraz, jak całą armię jedną drogą, dzielimy odpowiedź na strony. Parametry przychodzą w query jako tekst, więc DTO je waliduje:
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) zamienia tekst na liczbę, gdy globalny ValidationPipe ma transform: true. @Max(100) blokuje limit=1000000, a @IsIn przepuszcza tylko znane pola sortowania - sortowanie po ukrytym polu, np. hash hasła, zdradzałoby jego kolejność.
Serwis pobiera stronę i liczbę wszystkich rekordów równolegle:
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 z 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() zwraca POJO zamiast Mongoose Document - szybsze!
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() zwraca zwykłe obiekty, więc przy serializacji przydaje się type z poprzedniej sekcji. Na dużych kolekcjach countDocuments() bywa wolne, a skip zwalnia na dalekich stronach - wtedy pomaga kursor z lekcji o optymalizacji.
Response Caching Headers
Nagłówki cache pozwalają przeglądarkom i CDN-om trzymać odpowiedzi lokalnie. Interceptor ustawia je na drodze powrotnej:
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 minut domyślnie
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 - jak długo posłaniec może trzymać kopię
25 response.setHeader(
26 'Cache-Control',
27 `public, max-age=${this.maxAge}, s-maxage=${this.maxAge * 2}`
28 );
29
30 // ETag - identyfikator wersji zasobu, liczony z treści
31 // Przeglądarka odeśle go w If-None-Match i dostanie 304 Not Modified
32 response.setHeader('ETag', this.generateETag(body));
33 }),
34 );
35 }
36
37 private generateETag(body: unknown): string {
38 // Ta sama treść = ten sam ETag; W/ oznacza słaby ETag
39 const hash = createHash('sha1').update(JSON.stringify(body)).digest('base64url');
40 return `W/"${hash}"`;
41 }
42}max-age obowiązuje przeglądarkę, s-maxage - współdzielone cache, jak CDN. public stosuj tylko do danych wspólnych dla wszystkich; dane użytkownika dostają private. Pierwsza wersja liczyła ETag z Date.now(), więc zmieniał się przy każdym żądaniu i 304 nigdy nie padało. Express sam nadaje słaby ETag i odpowiada 304, więc własny ma sens, gdy wyliczysz go taniej, np. z updatedAt.
Interceptor ma parametr maxAge, więc tworzymy go sami przez new:
1// Użycie w kontrolerze
2@Controller('provinces')
3export class ProvinceController {
4 constructor(private readonly provinceService: ProvinceService) {}
5
6 @Get()
7 @UseInterceptors(new CacheHeadersInterceptor(600)) // 10 minut cache
8 findAll() {
9 return this.provinceService.findAll();
10 }
11
12 @Get(':id')
13 @UseInterceptors(new CacheHeadersInterceptor(3600)) // 1 godzina cache
14 findOne(@Param('id') id: string) {
15 return this.provinceService.findOne(id);
16 }
17}Przy @UseInterceptors(CacheHeadersInterceptor) NestJS próbowałby wstrzyknąć liczbę do konstruktora. Nagłówki cache warto poznawać w tej kolejności: Cache-Control to podstawa, ETag dokłada identyfikator wersji, Vary: Accept-Encoding rozdziela kopie w cache pośrednich, a If-None-Match wysyła przeglądarka przy ponownym pytaniu.
Dekoratory @Exclude, @Expose, @Transform - podsumowanie
Trzy kluczowe dekoratory z class-transformer:
- @Exclude() - ukrywa pole z odpowiedzi (jak tajny rozkaz Cezara),
- @Expose() - jawnie oznacza pole do wysłania (jak publiczny edykt),
- @Transform() - modyfikuje wartość przed wysłaniem (jak tłumacz Imperium).
Kompletny przykład łączy wszystkie trzy:
1// Kompletny przykład
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; // Nigdy nie wysyłaj hasła!
16
17 @Exclude()
18 internalNotes: string; // Wewnętrzne notatki
19
20 @Transform(({ value }) => value > 1000 ? 'Veteranus' : 'Tiro', { toPlainOnly: true })
21 @Expose()
22 experienceLevel: string; // Transformacja liczbowa -> tekstowa
23
24 @Transform(({ value }) => value?.toISOString(), { toPlainOnly: true })
25 @Expose()
26 enlistedAt: string; // Data w formacie ISO
27}passwordHash zniknie jednak tylko wtedy, gdy serializer dostanie instancję klasy - przy zwykłym obiekcie hasło pojedzie do klienta. Polecam Ci zasadę: na zewnątrz wysyłaj wyłącznie DTO, nigdy surowe encje. W następnej lekcji posłańcy dostaną kolejki.
Pamiętaj: mądry pretor nie wysyła pełnych kronik, gdy wystarczy zwięzły raport - i nigdy nie dołącza do niego tajnych rozkazów.
Kod do tej lekcji: src/compression-optimization.ts
1// Compression i Response Optimization
2import { Injectable } from '@nestjs/common';
3import { Exclude, Expose, Transform } from 'class-transformer';
4
5// ===========================================
6// 1. Response DTO z serializacja
7// ===========================================
8
9class LegionResponseDto {
10 @Expose()
11 id: number;
12
13 @Expose()
14 name: string;
15
16 @Expose()
17 province: string;
18
19 // Ukryte pola - nie wysylaj za mury Imperium!
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. Demonstracja
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('Strona 1:', page1.data.length, 'rekordow');
87console.log('Strona 2:', page2.data.length, 'rekordow');
88console.log('Total:', page1.meta.total);
89console.log('Strony:', page1.meta.totalPages);
90console.log('');
91console.log('@Exclude() - ukrywa pole z odpowiedzi');
92console.log('@Expose() - jawnie oznacza pole');
93console.log('@Transform() - modyfikuje wartosc');
94console.log('compression() - gzip/deflate middleware');
95console.log('Cache-Control, ETag - naglowki cache HTTP');
96Widzisz błąd w tej lekcji?