インターセプターを利用したクライアント側キャッシング

Angularは豊富な組み込み機能で知られていますが、時にはその全潜能を知らずにほんの一部の機能だけを利用することがあります。

@armandotrueさんの素晴らしいシリーズ「Directives and Dependency Injectionでのスーパーパワー」に触発されて、このシリーズではAngularのHTTPインターセプターについて探求し、それらを使って解決できる実践的なユースケースを見ていきます。

今日の課題

今日は、何度も繰り返し取得する必要があるデータがあるとしましょう。

例えば、ユーザーのアルバムをリスト化する写真ライブラリアプリを構築する場合、ユーザーはアプリケーション内を行ったり来たりして写真を再度訪れることがよくあります。

キャッシングがなければ、ユーザーがアルバムにアクセスするたびに、各写真の詳細をサーバーから新たに取得する必要があります。

ここには改善の余地がありそうです!

この手順でのリポジトリをクローンして一緒に進めることができます。こちらのリポジトリ

初期状態

こちらが動作中のアプリです。

今のところ、それぞれのアルバムにどれだけの写真があるかを表示しています。しかし、ネットワークタブを見ると、アルバムにアクセスするたびにアプリがその詳細を再度取得していることがわかります。たとえついさっきクリックしたばかりであってもです。

これらのアルバムはユーザーのものなので、変更があるとしたらその変更もユーザー側からのものでしょう。ただ閲覧するだけなら、多くは変わらないかもしれません。

キャッシングが役立ちそうです!

インターセプターを救助に呼ぶ

HttpClientがリクエストを処理する際に追加の挙動を加えるには、関連するロジックを定義する新しいインターセプタを作成する必要があります。

インターセプタとは、HTTPリクエストがサーバーに対して実際に行われる前に通るパイプラインを設定する特殊なタイプのサービスです。

キャッシングに関して言えば、レスポンスが何であるか既にわかっていれば、そのリクエストをインターセプトすることができます。

インターセプターの作成

インターセプターを定義するには、まず作成して登録する必要があります。

インターセプターはHttpInterceptorFn型で、リクエストとパイプライン内の次のハンドラをパラメータとして取り、結果をHttpEventObservableとして返します。

// 📁 app/caching.interceptor.ts

export const cachingInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
  // とりあえず、これは通り抜けるだけ
  return next(req);
};

登録するには、HttpClientのインターセプタに追加する必要があります。

// 📁 main.ts

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(
      // 👇 インターセプタをパイプラインに追加
      withInterceptors([cachingInterceptor])
    ),
  ],
}).catch((err) => console.error(err));

準備完了です!

もしアプリケーションを再び実行すれば、今のところは何も変わりません。これが実用的かどうかはともかく、少なくともHTTPリクエストがアプリに流れ続けているので、何も壊していないことが示されます。

キャッシュの作成

キャッシュに最もストレートなアプローチは、URLによるレスポンスのMapを持つことです。

// 📁 app/caching.interceptor.ts

const cache = new Map<string, HttpEvent<unknown>>();

export const cachingInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
  const cached = cache.get(req.url);

  // 👇 レスポンスがわかっていれば、リクエストせずに返す
  const isCacheHit = cached !== undefined;
  if (isCacheHit) {
    return of(cached);
  }

  return next(req).pipe(
    // 👇 レスポンスがアプリケーションに流れ込んだ際にキャッシュする
    tap((response) => cache.set(req.url, response))
  );
};

もしアプリを再び実行すれば、アルバムの詳細は最初にアクセスしたときにのみ要求されることがわかります。

キャッシュの制限

リクエストをキャッシュするという点ではうまくいっていますが、このアプローチの欠点は_すべて_のリクエストがキャッシュされることです。私たちはアルバムに関連するものだけをキャッシュしたいかもしれません。

キャッシュに何らかのロジックを追加したいのであれば、それをより賢いロジックに変換し、サービスにアップグレードすることができます。

// 📁 app/caching.service.ts

@Injectable({ providedIn: "root" })
export class CachingService {
  readonly #cache = new Map<string, HttpEvent<unknown>>();

  get(key: string): HttpEvent<unknown> | undefined {
    return this.#cache.get(key);
  }

  set(key: string, value: HttpEvent<unknown>): void {
    if (key.includes("album")) {
      this.#cache.set(key, value);
    }
  }
}

インターセプター機能はinjection context内で呼び出されるので、その定義に依存注入を使用できます。

// 📁 app/caching.interceptor.ts

export const cachingInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
  // 👇 ここで専用のサービスに依存する
  const cache = inject(CachingService);

  const cached = cache.get(req.url);

  const isCacheHit = cached !== undefined;
  if (isCacheHit) {
    return of(cached);
  }

  return next(req).pipe(tap((response) => cache.set(req.url, response)));
};

インターセプターの視点からすると、何も変わっていません。しかし、専用のサービスを持つことで、より複雑なロジックの追加が大幅に簡単になります。

キャッシュの強化

最後に、いくらかの時間が経過した後にキャッシュを期限切れにしたいと思うかもしれません。

これを実現するには、各エントリーに対して生存時間を含め、キャッシュをもう少し増強することができます。

// 📁 app/caching.service.ts

interface CacheEntry {
  value: HttpEvent<unknown>;
  expiresOn: number;
}

そして必要に応じて関連する値を期限切れにします。

// 📁 app/caching.service.ts

const TTL = 3_000;

@Injectable({ providedIn: "root" })
export class CachingService {
  readonly #cache = new Map<string, CacheEntry>();

  get(key: string): HttpEvent<unknown> | undefined {
    const cached = this.#cache.get(key);

    if (!cached) {
      return undefined;
    }

    // 👇 エントリが期限切れなら削除
    const hasExpired = new Date().getTime() >= cached.expiresOn;
    if (hasExpired) {
      this.#cache.delete(key);
      return undefined;
    }

    return cached.value;
  }

  set(key: string, value: HttpEvent<unknown>): void {
    if (key.includes("album")) {
      this.#cache.set(key, {
        value,
        // 👇 生存時間を設定
        expiresOn: new Date().getTime() + TTL,
      });
    }
  }
}

もしブラウザのネットワークタブを再び見ると、各リクエストが数秒間キャッシュされた後に期限切れになるのがわかります。

まとめ

この記事では、Angularアプリケーションが行うHTTPリクエストをどのようにインターセプトし、操作するかを見ました。

また、Angularの依存性注入システムを利用して、インターセプターにカスタムロジックを追加する方法も見ました。

最後に、アプリケーションで使用する小さなクライアント側キャッシングシステムを構築しました。

このコードの結果を確認したい場合は、関連するGitHubリポジトリにアクセスしてください。


役立つことを何か学べたことを願っています!

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/this-is-angular/client-side-caching-with-interceptors-ii