NestJS GraphQLリゾルバ用のCacheInterceptorを実装してみた

NestJS では(CacheModule を導入した上で)コントローラに `UseInterceptors` デコレータで `CacheInterceptor` を指定することで、コントローラが返すレスポンスをキャッシさせ、サーバの処理負荷を軽減させることができます。

```ts:app.controller.ts
@Controller()
@UseInterceptors(CacheInterceptor)
export class AppController {
  @Get()
  findAll(): string[] {
    return [];
  }
}
```

しかしながら、この`CacheInterceptor`は、[公式ドキュメント](https://docs.nestjs.com/techniques/caching#auto-caching-responses)のWARNINGにも書いてあるように、GraphQLでは使用できません。

https://docs.nestjs.com/techniques/caching#auto-caching-responses

そこで、GraphQL用のCacheInterceptorを自作してみました。

# 自作したGraphQL用のCacheInterceptor

処理の説明はコード中のコメントをご参照ください。

```ts:resolver-cache.interceptor.ts
import { createHash } from "crypto";

import {
    CACHE_MANAGER,
    CallHandler,
    ExecutionContext,
    Inject,
    Injectable,
    Logger,
    NestInterceptor,
} from "@nestjs/common";
import { GqlExecutionContext } from "@nestjs/graphql";
import { Cache } from "cache-manager";
import { GraphQLResolveInfo } from "graphql";
import { Observable, of, tap } from "rxjs";

@Injectable()
export class ResolverCacheInterceptor implements NestInterceptor {
    private readonly logger = new Logger(ResolverCacheInterceptor.name);

    constructor(
        @Inject(CACHE_MANAGER)
        private readonly cacheManager: Cache,
    ) {}

    async intercept(ec: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
        const gec = GqlExecutionContext.create(ec);
        // クエリの情報を取得
        const { fieldName, parentType } = gec.getInfo<GraphQLResolveInfo>();
        if (parentType.name !== "Query") {
            // Query のフィールドではない場合はキャッシュしない
            return next.handle();
        }
        // クエリの引数を取得
        const args = gec.getArgs();
        // キャッシュキーを生成
        const key = this.generateCacheKey(fieldName, args);
        // キャッシュ取得を試行
        const cache = await this.getCache(key);
        if (cache) {
            // キャッシュがある場合、リゾルバメソッドを実行せず、キャッシュを返す
            return of(cache);
        }
        return next.handle().pipe(
            tap((res) => {
                // リゾルバメソッドの戻り値(res)をキャッシュに保存
                this.saveCache(key, res);
            }),
        );
    }

    /**
     * クエリ名と引数からキャッシュキーを生成する
     */
    private generateCacheKey(fieldName: string, args: Record<string, unknown>): string {
        // キャッシュキーの長さをある程度の範囲に揃えるために
        // 引数はJSON文字列にしてハッシュ値化したものをキャッシュキーに含める
        const hash = createHash("md5").update(JSON.stringify(args)).digest("base64");
        return `ResolverCache_${fieldName}_${hash}`;
    }

    /**
     * キャッシュを取得する
     * @param key キャッシュキー
     */
    private async getCache(key: string): Promise<unknown> {
        try {
            const cache = await this.cacheManager.get(key);
            if (cache) {
                this.logger.debug(`cache hit ${key}`);
            } else {
                this.logger.debug(`cache miss ${key}`);
            }
            return cache;
        } catch (err) {
            this.logger.warn(`cache get failed ${key} ${err}`);
            return null;
        }
    }

    /**
     * リゾルバメソッドの戻り値をキャッシュに保存する
     * @param key キャッシュキー
     * @param data リゾルバメソッドの戻り値
     */
    private async saveCache(key: string, data: unknown ): Promise<void> {
        try {
            await this.cacheManager.set(key, data, { ttl: 60 });
            this.logger.debug(`cache saved ${key}`);
        } catch (err) {
            this.logger.warn(`cache save failed ${key} ${err}`);
        }
    }
}
```

## リゾルバメソッドに適用

あとは、これをリゾルバのメソッドに UseInterceptors デコレータで設定することで、初回リゾルバメソッドを実行した戻り値をキャッシュし、以降はリゾルバメソッドを実行せず、キャッシュの内容を返すようになります。
(以下の例では固定の文字列を返しているのでキャッシュするまでもありませんが…)

**例**

```graphql:schema
type Query {
    greeting(name: String!): String!
}
```

```ts:resolver
import { UseInterceptors } from "@nestjs/common";
import { Query, Resolver } from "@nestjs/graphql";
import { GraphQLError } from "graphql";

// 上記のResolverCacheInterceptorをimport
import { ResolverCacheInterceptor } from "./path/to/resolver-cache.interceptor";

@Resolver("Query")
export class QueryResolver {
    @Query(() => String)
    @UseInterceptors(ResolverCacheInterceptor) // これを追加
    async greeting(@Args("name") name: string) {
        return `Hello,${name}!`;
    }
}
```

# カスタマイズ

本記事の実装例では、同一のクエリを同一時刻に同一の引数で呼び出すと、常に同じレスポンスを返すGraphQL APIを前提として、キャッシュキーはクエリ名(Queryのフィールド名)と引数によってユニークになるようにしています。

その他の要素（例えば、コンテキストに保存された認証情報）によって、レスポンス内容が変わる場合は、それらの情報を含めてキャッシュキーがユニークになるように処理を修正する必要があります。

また、キャッシュ時間は一律60秒で実装していますが、レスポンス内容の変更頻度や更新遅延時間の許容範囲によって調整してください。