Intercepteurs
Un intercepteur est une classe annotée avec le @Injectable()
décorateur et qui implémente l’interface NestInterceptor
.
Les intercepteurs ont un ensemble de capacités utiles inspirées par la technique de la programmation orientée aspects (AOP). Ils rendent possible de :
- lier une logique supplémentaire avant / après l’exécution d’une méthode
- transformer le résultat retourné par une fonction
- transformer l’exception lancée par une fonction
- étendre le comportement de base de la fonction
- remplacer complètement une fonction selon des conditions spécifiques (par exemple, à des fins de mise en cache)
Bases
Chaque intercepteur implémente la méthode intercept()
, qui prend deux arguments. Le premier est l’instance ExecutionContext
(exactement le même objet que pour les gardes). Le ExecutionContext
hérite de ArgumentsHost
. Nous avons vu ArgumentsHost
auparavant dans le chapitre sur les filtres d’exception. Là, nous avons vu que c’est un wrapper autour des arguments passés à l’original handler, et contient différents tableaux d’arguments basés sur le type de l’application. Vous pouvez vous référer aux filtres d’exception pour plus d’informations sur ce sujet.
Contexte d’exécution
En étendant ArgumentsHost
, ExecutionContext
ajoute également plusieurs nouvelles méthodes d’aide qui fournissent des détails supplémentaires sur le processus d’exécution actuel. Ces détails peuvent être utiles pour construire des intercepteurs plus génériques pouvant fonctionner à travers un large éventail de contrôleurs, de méthodes et de contextes d’exécution. En savoir plus sur ExecutionContext
ici.
Call Handler
Le deuxième argument est un CallHandler
. L’interface CallHandler
implémente la méthode handle()
, que vous pouvez utiliser pour invoquer la méthode de gestion de route à un certain moment dans votre intercepteur. Si vous n’appelez pas la méthode handle()
dans votre implémentation de la méthode intercept()
, la méthode de gestion de route ne sera pas exécutée du tout.
Cette approche signifie que la méthode intercept()
enveloppe effectivement le flux de requête/réponse. En conséquence, vous pouvez mettre en œuvre une logique personnalisée à la fois avant et après l’exécution de la méthode de gestion de route finale. Il est clair que vous pouvez écrire du code dans votre méthode intercept()
qui s’exécute avant l’appel à handle()
, mais comment affectez-vous ce qui se passe ensuite ? Étant donné que la méthode handle()
retourne un Observable
, nous pouvons utiliser de puissants opérateurs RxJS pour manipuler davantage la réponse. En utilisant la terminologie de la programmation orientée aspects, l’invocation du gestionnaire de route (c’est-à-dire l’appel à handle()
) est appelée un pointcut, indiquant que c’est le point où notre logique supplémentaire est insérée.
Aspect de l’interception
Le premier cas d’utilisation que nous allons examiner est d’utiliser un intercepteur pour enregistrer l’interaction de l’utilisateur (par exemple, stocker les appels d’utilisateur, dispatcher des événements de manière asynchrone ou calculer un horodatage). Nous montrons ci-dessous un simple LoggingInterceptor
:
logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { Observable } from 'rxjs';import { tap } from 'rxjs/operators';
@Injectable()export class LoggingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { console.log('Before...');
const now = Date.now(); return next .handle() .pipe( tap(() => console.log(`After... ${Date.now() - now}ms`)), ); }}
Vérifiez que handle()
retourne un Observable
RxJS, nous avons un large choix d’opérateurs que nous pouvons utiliser pour manipuler le flux. Dans l’exemple ci-dessus, nous avons utilisé l’opérateur tap()
, qui invoque notre fonction d’enregistrement anonyme lors de la terminaison normale ou exceptionnelle du flux observable, mais n’interfère pas autrement avec le cycle de réponse.
Liaison des intercepteurs
Pour mettre en place l’intercepteur, nous utilisons le décorateur @UseInterceptors()
importé depuis le package @nestjs/common
. Comme les pipes et les gardes, les intercepteurs peuvent être au niveau du contrôleur, au niveau de la méthode ou globaux.
cats.controller.ts
@UseInterceptors(LoggingInterceptor)export class CatsController {}
En utilisant cette construction, chaque gestionnaire de route défini dans CatsController
utilisera LoggingInterceptor
. Lorsque quelqu’un appelle le point de terminaison GET /cats
, vous verrez la sortie suivante dans votre sortie standard :
Before...After... 1ms
Notez que nous sommes passés à la classe LoggingInterceptor
(au lieu d’une instance), laissant la responsabilité de l’instanciation au framework et permettant l’injection de dépendances. Comme avec les pipes, les gardes et les filtres d’exception, nous pouvons également passer une instance sur place :
cats.controller.ts
@UseInterceptors(new LoggingInterceptor())export class CatsController {}
Comme mentionné, la construction ci-dessus attache l’intercepteur à chaque gestionnaire déclaré par ce contrôleur. Si nous voulons restreindre la portée de l’intercepteur à une seule méthode, nous appliquons simplement le décorateur au niveau de la méthode.
Pour mettre en place un intercepteur global, nous utilisons la méthode useGlobalInterceptors()
de l’instance de l’application Nest :
const app = await NestFactory.create(AppModule);app.useGlobalInterceptors(new LoggingInterceptor());
Les intercepteurs globaux sont utilisés dans toute l’application, pour chaque contrôleur et chaque gestionnaire de route. En termes d’injection de dépendances, les intercepteurs globaux enregistrés depuis l’extérieur de tout module (avec useGlobalInterceptors()
comme dans l’exemple ci-dessus) ne peuvent pas injecter de dépendances puisque cela se fait en dehors du contexte de tout module. Pour résoudre ce problème, vous pouvez mettre en place un intercepteur directement depuis n’importe quel module en utilisant la construction suivante :
app.module.ts
import { Module } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({ providers: [ { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor, }, ],})export class AppModule {}
Mapping de la réponse
Nous savons déjà que handle()
retourne un Observable
. Le flux contient la valeur retournée par le gestionnaire de route, et nous pouvons donc facilement la modifier en utilisant l’opérateur map()
de RxJS.
La fonctionnalité de mapping de réponse ne fonctionne pas avec la stratégie de réponse spécifique à la bibliothèque (utiliser l’objet @Res()
directement est interdit).
Créons le TransformInterceptor
, qui modifiera chaque réponse de manière triviale pour démontrer le processus. Il utilisera l’opérateur map()
de RxJS pour assigner l’objet réponse à la propriété data
d’un nouvel objet créé, renvoyant le nouvel objet au client.
transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { Observable } from 'rxjs';import { map } from 'rxjs/operators';
export interface Response<T> { data: T;}
@Injectable()export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> { intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> { return next.handle().pipe( map(data => ({ data })), ); }}
Avec cette construction, lorsque quelqu’un appelle le point de terminaison GET /cats
, la réponse ressemblerait à ceci (en supposant que le gestionnaire de route retourne un tableau vide []
) :
{ "data": []}
Les intercepteurs ont une grande valeur pour créer des solutions réutilisables aux exigences qui se produisent dans l’ensemble d’une application. Par exemple, imaginez que nous devions transformer chaque occurrence d’une valeur null
en une chaîne vide ''
. Nous pouvons le faire en une seule ligne de code et lier l’intercepteur globalement afin qu’il soit automatiquement utilisé par chaque gestionnaire enregistré.
ExcludeNullInterceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { Observable } from 'rxjs';import { map } from 'rxjs/operators';
@Injectable()export class ExcludeNullInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( map(value => value === null ? '' : value), ); }}
Mapping des exceptions
Un autre cas d’utilisation intéressant est de profiter de l’opérateur catchError()
de RxJS pour remplacer les exceptions levées :
errors.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, BadGatewayException, CallHandler } from '@nestjs/common';import { Observable, throwError } from 'rxjs';import { catchError } from 'rxjs/operators';
@Injectable()export class ErrorsInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next .handle() .pipe( catchError(err => throwError(() => new BadGatewayException())), ); }}
Surcharge du flux
Il existe plusieurs raisons pour lesquelles nous voudrions parfois complètement empêcher l’appel du gestionnaire et retourner une valeur différente à la place. Un exemple évident est l’implémentation d’un cache pour améliorer le temps de réponse. Regardons un simple intercepteur de cache qui renvoie sa réponse depuis un cache. Dans un exemple réaliste, nous souhaiterions prendre en compte d’autres facteurs comme le TTL, l’invalidation du cache, la taille du cache, etc., mais cela dépasse le cadre de cette discussion. Ici, nous fournirons un exemple de base qui démontre le concept principal.
cache.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { Observable, of } from 'rxjs';
@Injectable()export class CacheInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const isCached = true; if (isCached) { return of([]); } return next.handle(); }}
Notre CacheInterceptor
a une variable isCached
codée en dur et une réponse codée en dur []
également. Le point clé à noter est que nous retournons un nouveau flux ici, créé par l’opérateur RxJS of()
, par conséquent, le gestionnaire de route ne sera pas appelé du tout. Lorsque quelqu’un appelle un point de terminaison qui utilise CacheInterceptor
, la réponse (un tableau vide codé en dur) sera retournée immédiatement. Pour créer une solution générique, vous pouvez profiter de Reflector
et créer un décorateur personnalisé. Le Reflector
est bien décrit dans le chapitre sur les gardes.
Plus d’opérateurs
La possibilité de manipuler le flux en utilisant des opérateurs RxJS nous donne de nombreuses capacités. Considérons un autre cas d’utilisation courant. Imaginez que vous souhaitiez gérer des délai d’attente sur les requêtes de route. Lorsque votre point de terminaison ne renvoie rien après un certain temps, vous souhaitez terminer avec une réponse d’erreur. La construction suivante permet cela :
timeout.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';import { Observable, throwError, TimeoutError } from 'rxjs';import { catchError, timeout } from 'rxjs/operators';
@Injectable()export class TimeoutInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( timeout(5000), catchError(err => { if (err instanceof TimeoutError) { return throwError(() => new RequestTimeoutException()); } return throwError(() => err); }), ); }}
Après 5 secondes, le traitement de la requête sera annulé. Vous pouvez également ajouter une logique personnalisée avant de lancer RequestTimeoutException
(par exemple, libérer des ressources).