Kurs NestJS · Moduł 8: Cache i wydajność

Compression i Response Optimization - przyspieszenie posłańców Imperium

7 min czytania
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');
96

Widzisz błąd w tej lekcji?

Przydatne artykuły