Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Interceptors - kurier, który wraca z odpowiedzią

Wartownik przy rogatce liczy wchodzących. Strażnik skarbca decyduje, kto wejdzie. Obaj kończą służbę w tej samej chwili - gdy przybysz przekroczy próg. Żaden z nich nie widzi, z czym stamtąd wyjdzie.

Kurier widzi. Jedzie z rozkazem do namiestnika, czeka, aż tamten odpowie, i przywozi odpowiedź z powrotem - a po drodze może do niej coś dopisać. Interceptor to pierwszy mechanizm cyklu żądania, który wykonuje kod także po zakończeniu handlera, i to jedno zdanie tłumaczy prawie wszystko, co robi.

Interfejs i sygnatura

Interceptor to klasa implementująca interfejs

NestInterceptor
. Nie
CanActivate
- ten należy do guardów z poprzedniej lekcji; nie
PipeTransform
- to pipes, które poznasz zaraz po tej lekcji; nie
ExceptionFilter
- ten zajmuje się przechwytywaniem błędów. Interfejs wymaga jednej metody:

1import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
2import { Observable } from 'rxjs';
3
4@Injectable()
5export class AuditInterceptor implements NestInterceptor {
6  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
7    return next.handle();
8  }
9}

Sygnatura składa się z trzech części zapisywanych w tej kolejności:

intercept(
otwiera metodę,
context: ExecutionContext,
to pierwszy argument, a
next: CallHandler)
to drugi. Powyższy interceptor jeszcze nic nie robi - przekazuje żądanie dalej i oddaje wynik nietknięty.

ExecutionContext
znasz już z guardów: to ten sam obiekt, z tymi samymi metodami
getHandler()
i
switchToHttp()
. Nowy jest
CallHandler
- uchwyt do reszty łańcucha. Dopóki nie wywołasz na nim
handle()
, handler kontrolera w ogóle się nie uruchomi.

Cztery fazy

Praca interceptora dzieli się na cztery etapy, zawsze w tej kolejności:

  1. Wywołanie metody
    intercept()
    .
  2. Logika przed przekazaniem żądania (before).
  3. Wykonanie route handlera (
    next.handle()
    ).
  4. Przetwarzanie odpowiedzi (after).

Podział jest widoczny w kodzie gołym okiem i wystarczy zapamiętać jedną granicę: wszystko, co napiszesz przed

return next.handle()
, to faza before; wszystko, co trafi do
.pipe(...)
, to faza after.
Jedna metoda obejmuje obie strony przejazdu kuriera - drogę tam i drogę z powrotem.

Dlatego właśnie interceptor potrafi zmierzyć czas obsługi żądania, czego middleware ani guard nie potrafią: zapisuje znacznik w fazie before i odczytuje go w fazie after, w tej samej metodzie i na tej samej zmiennej.

tap - działanie bez zmiany wyniku

next.handle()
nie zwraca gotowych danych, tylko
Observable
- strumień z biblioteki RxJS, do którego odpowiedź dopiero trafi. Operacje na strumieniu dopina się metodą
.pipe()
, a operatorem najczęściej używanym do wykonania logiki po otrzymaniu odpowiedzi jest
tap
:

1@Injectable()
2export class LoggingInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    const start = Date.now();
5
6    return next.handle().pipe(
7      tap(() => console.log(`Czas: ${Date.now() - start}ms`)),
8    );
9  }
10}

const start = Date.now()
wykonuje się w fazie before, funkcja wewnątrz
tap
- w fazie after, po powrocie odpowiedzi. Różnica obu odczytów to czas obsługi żądania.

Nazwa

tap
pochodzi od „podsłuchu": operator podgląda przepływającą wartość i przepuszcza ją bez zmiany. To dokładnie to, czego chcemy przy logowaniu - odpowiedź klienta nie może zależeć od tego, czy akurat coś zapisujemy do konsoli. Trzej sąsiedzi z RxJS robią co innego:
map
podmienia wartość,
filter
potrafi ją wstrzymać, a
reduce
zwija cały strumień w jedną wartość i czeka na jego koniec - w żądaniu HTTP wartość jest jedna, więc nie ma czego zwijać.

map - zmiana kształtu odpowiedzi

Gdy odpowiedź ma naprawdę wyglądać inaczej, sięgasz po

map
. Typowe zastosowanie to wspólna koperta dla wszystkich endpointów:

1@Injectable()
2export class TransformInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    return next.handle().pipe(
5      map((data) => ({ success: true, data })),
6    );
7  }
8}

Kontroler zwraca listę danin, a klient dostaje

{ success: true, data: [...] }
- i tak samo przy każdym innym endpoincie, bez dopisywania choćby linijki w kontrolerach. Różnica wobec poprzedniego przykładu mieści się w jednym zdaniu:
tap
ogląda,
map
podmienia
. Jeśli funkcja w
tap
coś zwróci, zostanie to zignorowane; to, co zwróci funkcja w
map
, staje się odpowiedzią.

RxJS ma tych operatorów więcej i część przydaje się w interceptorach od razu:

catchError
przechwytuje wyjątek z handlera,
timeout
przerywa zbyt długą obsługę żądania. Wszystkie wpinasz w to samo
.pipe()
.

Podpięcie

Interceptor podpina się dekoratorem

@UseInterceptors
- na metodzie albo na całym kontrolerze:

1@Controller('tributes')
2@UseInterceptors(LoggingInterceptor)
3export class TributesController {
4  @Get()
5  findAll() {
6    return this.tributesService.findAll();
7  }
8}

Podajesz klasę, nie jej instancję - tworzenie zostawiasz wstrzykiwaniu zależności, dzięki czemu interceptor może mieć własne zależności w konstruktorze. Kilka interceptorów po przecinku tworzy łańcuch: faza before biegnie w kolejności zapisu, a faza after - w odwrotnej, bo strumień wraca tą samą drogą.

Gdy koperta ma obejmować całą aplikację, rejestrujesz interceptor globalnie:

app.useGlobalInterceptors(new TransformInterceptor())
w
main.ts
albo jako provider z tokenem
APP_INTERCEPTOR
. Druga droga jest lepsza wszędzie tam, gdzie interceptor sam czegoś potrzebuje - provider przechodzi przez wstrzykiwanie zależności,
new
nie.

Podsumowanie

Kurier jedzie i wraca, @name:

  • interceptor implementuje interfejs
    NestInterceptor
    - nie
    CanActivate
    , nie
    PipeTransform
    , nie
    ExceptionFilter
    ,
  • sygnatura w kolejności:
    intercept(
    context: ExecutionContext,
    next: CallHandler)
    ,
  • cztery fazy: wywołanie
    intercept()
    → logika przed przekazaniem żądania (before) → wykonanie route handlera (
    next.handle()
    ) → przetwarzanie odpowiedzi (after),
  • granica jest prosta: przed
    return next.handle()
    to before, wewnątrz
    .pipe(...)
    to after,
  • next.handle()
    zwraca
    Observable
    ; bez jego wywołania handler się nie wykona,
  • tap
    to operator najczęściej używany do logiki po otrzymaniu odpowiedzi
    - podgląda wartość i przepuszcza bez zmiany, w odróżnieniu od
    map
    (podmienia),
    filter
    (wstrzymuje) i
    reduce
    (zwija strumień),
  • pomiar czasu:
    const start = Date.now()
    w fazie before,
    tap(() => ...)
    z drugim
    Date.now()
    w fazie after,
  • map((data) => ({ success: true, data }))
    opakowuje odpowiedź we wspólną kopertę,
  • podpięcie przez
    @UseInterceptors(LoggingInterceptor)
    , globalnie przez
    useGlobalInterceptors
    albo token
    APP_INTERCEPTOR
    .

W następnej lekcji poznasz pipes - czwarty i ostatni mechanizm cyklu, który zajmie się pojedynczymi argumentami metody, zanim ta w ogóle się uruchomi. A na razie zapamiętaj: middleware i guard pracują na wejściu, interceptor jako jedyny jedzie w obie strony.

Przejdź do CodeWorlds