Interceptorとは

Interceptorとは

NestJSにおけるInterceptor(インターセプター)とは、@Injectable()デコレータでアノテーションされたクラスでNestInterceptorインターフェイスを実装しています。
Interceptorは、アスペクト指向プログラミング(AOP, Aspect Oriented Programming)の手法にヒントを得た、一連の便利な機能を備えております。これらの機能により、以下の様なことができるようになります。
  • メソッド実行の前後に余分なロジックをバインドする
  • 関数から返される結果を変換する
  • 関数からスローされる例外を変換する
  • 関数の基本的な振る舞いを拡張する
  • 特定の条件に応じて関数を完全にオーバーライドする

基本

それぞれのInterceptorはintercept()メソッドを実装しており、2つの引数を取ります。最初の引数はExcecutionContextのインスタンスです。ExcecutionContextArgumentsHostを継承しています。ArgumentsHost例外フィルタの章で確認できます。

実行コンテキスト

ArgumentsHostを継承したExcecutionContextには、現在の実行処理に関するさらなる情報を提供するヘルパーメソッドもいくつか追加されています。これらのメソッドは、多種多様なコントローラやメソッド、実行コンテキストで動作する汎用的なInterceptorを作成する際には非常に有用です。

ハンドラーを呼び出す

第二引数にはCallHandlerを指定します。CallHandlerインターフェイスはhandler()メソッドを実装しています。これを使うと、インターセプターのどこかでルートハンドラメソッドを呼び出すことができます。もしintercept()メソッドの実装でhandle()メソッドを呼ばなかった場合はルートハンドラメソッドは実行されません。
この方式は、intercept()メソッドがリクエスト/レスポンスストリームを効果的にラップしていることを意味します。その結果、最終的なルートハンドラの実行前と実行後の両方でカスタムロジックを実装できます。intercept()メソッドにhandle()をコールする前に実行させるコードを書けるのは明白です。
handle()メソッドはObservableを返すので、rxjsライブラリの強力な演算子を使ってレスポンスを操作できます。アスペクト指向プログラミングの用語を使うと、ルートハンドラの呼び出しはポイントカットと呼ばれ、追加ロジックが挿入されるポイントであることを示しています。

アスペクトのインターセプション(傍受)

最初に見ていくのは、インターセプターを使用してユーザとのやり取りを記録する場合です。以下にシンプルな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`)),
);
}
}
メモ
NestInterceptor<T, R>とは、TObservable<T>の型、RObservable<R>でラップされた値の型を示す汎用インターフェイスです。
注意
インターセプターは、コントローラやプロバイダ、ガードなどと同様にコンストラクタを通して依存性を注入できます。
handle()はRxJSのObservableを返すので、ストリームを操作するために使用できるオペレータの幅広い選択があります。上記の例では、tap()演算子を使いました。これは、observableストリームの優雅な、または例外的な終了時に、匿名ログ関数を呼び出しますが、それ以外は応答サイクルに干渉しません。

Interceptorをバインドする

Interceptorを設定するためには、@nestjs/commonパッケージからインポートした@UseInterceptors()デコレータを使います。パイプやガードと同様に、インターセプターもコントローラスコープ、メソッドスコープやグローバルスコープのいずれかを指定できます。
cats.controller.ts
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
メモ
@UseInterceptors()デコレータは@nestjs/commonパッケージからインポートしています。
上記の構成で、CatsControllerに定義された各ルートハンドラはLoggingInterceptorを使うことになります。GET /catsエンドポイントを呼び出すと、標準出力に次のような出力が表示されます。
Before...
After... 1ms
インスタンスの代わりにLoggingInterceptor型を渡すことで、フレームワークにインスタンス化の責任を負わせ、依存性注入を可能にしていることに注意してください。パイプ、ガードや例外フィルタと同様にインプレイスインスタンスを渡すことができます。
cats.controller.ts
@UseInterceptors(new LoggingInterceptor())
export class CatsController {}
先ほど説明したように、上記の構文ではこのコントローラで宣言されているすべてのハンドラにインターセプターをアタッチしています。もしインターセプターのスコープを一つのメソッドに限定したい場合は、単純にメソッドレベルでデコレータを適用します。
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
グローバルインターセプターはアプリケーション全体、要はすべてのコントローラやルートハンドラで使用します。依存性の注入という観点では、モジュールの外から(上記ではuseGlobalInterceptors()で)登録したグローバルインターセプタは依存性を注入することができません。
この問題を解決するために、以下のような構文で任意のモジュールから直接インターセプターを設定できます。
app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule {}
メモ
この方法でインターセプターの依存性注入を行う場合、この構造が採用されたモジュールに関係なくインターセプタは実際にはグローバルであることに留意してください。
依存性注入はインターセプターが定義されているモジュールの上(上記の例ではLoggingInterceptor)で行いましょう。

レスポンスのマッピング

handle()Observableを返すことは以前のセクションで説明しました。このストリームはルートハンドラから返された値を含んでいるので、RxJSのmap()演算子を使って簡単に変異させられます。
注意
レスポンスのマッピング機能は、ライブラリ固有のレスポンスストラテジーでは動作しません。(@Res()オブジェクトを直接使用することは禁止されています)
TransformInterceptorを作成して、些細な方法でそれぞれのレスポンスを変更します。RxJSのmap()演算子を使って、レスポンスオブジェクトを新しく作成したオブジェクトのdataプロパティに代入し、新しいオブジェクトをクライアントに返します。
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 })));
}
}
メモ
NestJSのインターセプターは同期関数と非同期のintercept()メソッドの両方で機能します。必要に応じて、メソッドを非同期に切り替えるだけです。
上記の構成で、誰かがGET /catsエンドポイントを呼び出すと、レスポンスは次のようになります。(ルートハンドラが空の配列[]を返すことを仮定)
{
"data": []
}
インターセプターはアプリケーション全体で発生する要求に対して再利用可能なソリューションを作成する際に大きな価値を発揮します。
例えば、nullが出現するたびにそれを空文字列''に変換する必要が有ることを仮定しましょう。これを一行のコードで実現し、インターセプターをグローバルにバインドしておけば、登録したハンドラで自動的に使用できるようになります。
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 ));
}
}

例外のマッピング

もう一つの興味深い例は、RxJSのcatchError()演算子を利用してあろーされた例外をオーバーライドすることです。
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())),
);
}
}

ストリームの継承

ハンドラの呼び出しを完全に止めて、代わりに別の値を返したい理由はいくつかあります。わかりやすい例としては、キャッシュを実装してレスポンスタイムを向上させるというものがあります。
ここでは、キャッシュからレスポンスを返すシンプルなキャッシュインターセプターを見てみましょう。現実的な例では、TTLやキャッシュの無効化、キャッシュのサイズなど他の要素も考慮したいのですが、本セクションでは主な概念を示す基本的な例を示します。
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();
}
}
CacheInterceptorはハードコートされたisCached変数とハードコードされたresponse[]を同様に持っています。注意するべき点は、ここではRxJSのof()演算子によって作成された新しいストリームを返しているので、ルートハンドラが全く呼び出されないということです。
CacheInterceptorを利用するエンドポイントを呼び出すと、レスポンス(ハードコードされた空の配列)が直ちに返されます。汎用的なソリューションを作成するには、Reflectorを活用して独自のデコレータを作成します。

その他の演算子

RxJSの演算子を使ってストリームを操作することで、多くの機能を利用することができます。別の一般的なユースケースを考えてみましょう。
ルートリクエストのタイムアウトを処理することを想像してみてください。エンドポイントが一定時間経過して何も返さない場合はエラーレスポンスでプログラムを終了したいことを想定します。
以下のプログラムで上記の機能を実装できます。
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);
}),
);
};
};
5秒経過すると、リクエストはキャンセルされます。RequestTimeoutExceptionを投げる前にカスタムロジックを追加することもできます。