NestJS Cache-Control 実装
NestJS で Cache-Control ヘッダーを設定する方法を、デコレーター・インターセプター・ミドルウェアの3パターンで実装する
TL;DR
- エンドポイント単位なら
@Header()デコレーターで直接指定するのが最もシンプル - 複数のエンドポイントに横断的に適用するにはインターセプターを使う
@nestjs/cache-managerはサーバーサイドのメモリキャッシュであり、Cache-Control(ブラウザ・CDN向け)とは別物
前提
- NestJS の基本構造(Module / Controller / Service)を理解していること
Cache-Controlヘッダーの意味については Cache-Controlヘッダーの基礎 を参照
パターン1:@Header() デコレーター(エンドポイント単位)
最もシンプルな方法。特定のエンドポイントにだけ設定したい場合に使う。
import { Controller, Get, Header } from '@nestjs/common';
@Controller('products')
export class ProductsController {
// 公開データ:CDNにも60秒キャッシュを許可
@Get()
@Header('Cache-Control', 'public, max-age=60')
findAll() {
return this.productsService.findAll();
}
// 認証不要だがユーザー固有でない集計データ:5分キャッシュ
@Get('stats')
@Header('Cache-Control', 'public, max-age=300')
getStats() {
return this.productsService.getStats();
}
}複数のエンドポイントで同じ値を使い回す場合は定数にまとめると管理しやすい。
const CacheHeaders = {
NO_STORE: 'no-store',
PRIVATE_SHORT: 'private, max-age=60',
PUBLIC_LONG: 'public, max-age=3600',
STATIC: 'public, max-age=31536000, immutable',
} as const;
@Get('me')
@Header('Cache-Control', CacheHeaders.NO_STORE)
getMe(@Req() req: Request) {
// ログインユーザーの個人情報:キャッシュ禁止
return this.usersService.findById(req.user.id);
}パターン2:インターセプター(横断的に適用)
複数のコントローラー・エンドポイントに一括で適用したいときにはインターセプターを使う。
基本的なインターセプターの実装
// cache-control.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Response } from 'express';
@Injectable()
export class CacheControlInterceptor implements NestInterceptor {
constructor(private readonly directive: string) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
tap(() => {
const res = context.switchToHttp().getResponse<Response>();
res.setHeader('Cache-Control', this.directive);
}),
);
}
}コントローラー単位で適用
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheControlInterceptor } from './cache-control.interceptor';
@Controller('articles')
@UseInterceptors(new CacheControlInterceptor('public, max-age=300'))
export class ArticlesController {
@Get()
findAll() {
return this.articlesService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.articlesService.findOne(id);
}
// このエンドポイントだけ上書きしたい場合は @Header() で個別設定
@Get('draft/:id')
@Header('Cache-Control', 'no-store')
findDraft(@Param('id') id: string) {
return this.articlesService.findDraft(id);
}
}アプリ全体にデフォルトを設定(main.ts)
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CacheControlInterceptor } from './interceptors/cache-control.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// デフォルトはキャッシュ禁止(APIサーバーとして安全側に倒す)
app.useGlobalInterceptors(
new CacheControlInterceptor('no-store'),
);
await app.listen(3000);
}
bootstrap();全体に no-store を設定しておき、キャッシュを許可したいエンドポイントだけ @Header() で個別に上書きする方法は、「デフォルト安全」な設計になる。
パターン3:カスタムデコレーター(可読性の向上)
@Header('Cache-Control', '...') の文字列を毎回書くのが嫌な場合、意図が明確なカスタムデコレーターを定義できる。
// decorators/cache-control.decorator.ts
import { Header } from '@nestjs/common';
export const NoStore = () => Header('Cache-Control', 'no-store');
export const PublicCache = (maxAge: number) =>
Header('Cache-Control', `public, max-age=${maxAge}`);
export const PrivateCache = (maxAge: number) =>
Header('Cache-Control', `private, max-age=${maxAge}`);import { NoStore, PublicCache, PrivateCache } from './decorators/cache-control.decorator';
@Controller('users')
export class UsersController {
@Get()
@PublicCache(300) // 公開ユーザー一覧:5分
findAll() { ... }
@Get(':id/profile')
@PublicCache(60) // プロフィールページ:1分
getProfile(@Param('id') id: string) { ... }
@Get('me')
@NoStore() // 自分のデータ:キャッシュ禁止
getMe() { ... }
@Get('me/settings')
@PrivateCache(300) // 設定情報:ブラウザのみ5分
getSettings() { ... }
}よくあるユースケース別の設定例
認証が必要なエンドポイント全般
@UseGuards(JwtAuthGuard)
@Controller('me')
@UseInterceptors(new CacheControlInterceptor('no-store'))
export class MeController {
// ユーザー固有の情報はすべて no-store
}公開APIで変化が少ないマスタデータ
@Get('categories')
@Header('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400')
getCategories() {
return this.categoryService.findAll();
}stale-while-revalidate は「max-age が切れても古いキャッシュを返しつつ、バックグラウンドで再検証する」ディレクティブ。レスポンス速度を維持しながら最終的に最新化される。
検索API(ユーザー固有の検索結果)
@Get('search')
@UseGuards(JwtAuthGuard)
@Header('Cache-Control', 'private, no-cache')
search(@Query('q') q: string, @Req() req: Request) {
return this.searchService.search(q, req.user.id);
}ユーザーごとに結果が変わる(private)。ただし最新性が重要なので毎回再検証する(no-cache)。
ファイルダウンロード(機密ファイル)
@Get('files/:id/download')
@UseGuards(JwtAuthGuard)
@Header('Cache-Control', 'no-store')
@Header('Content-Disposition', 'attachment')
download(@Param('id') id: string, @Res() res: Response) {
const stream = this.filesService.getStream(id);
stream.pipe(res);
}@nestjs/cache-manager との違い
よく混同されるが、用途がまったく異なる。
Cache-Control ヘッダー | @nestjs/cache-manager | |
|---|---|---|
| キャッシュする場所 | ブラウザ・CDN・プロキシ | サーバーのメモリ(または Redis) |
| 目的 | クライアント側の通信を減らす | サーバー側の処理(DB等)を減らす |
| 有効期限の管理 | ブラウザ・CDNが行う | NestJSサーバーが行う |
| ユーザーをまたぐ共有 | public を指定した場合 | 同一サーバーのリクエスト間で共有 |
// @nestjs/cache-manager はサーバーサイドのDBアクセスを減らすキャッシュ
@Get(':id')
async findOne(@Param('id') id: string) {
// DBへのアクセスをサーバーメモリにキャッシュする
const cached = await this.cacheManager.get(`product:${id}`);
if (cached) return cached;
const product = await this.productsService.findOne(id);
await this.cacheManager.set(`product:${id}`, product, 300); // 5分
return product;
}この @nestjs/cache-manager によるキャッシュは、クライアントからは見えない。サーバーが速く返せるようにするための最適化であり、Cache-Control ヘッダーは別途必要。
よくある落とし穴
レスポンスオブジェクトを直接操作するときの注意
NestJS で @Res() を使うと、NestJS のレスポンスハンドリングをバイパスする。@Header() デコレーターは NestJS のレスポンスパイプラインに乗って動くため、@Res() と組み合わせる場合は res.setHeader() で直接設定する。
// ❌ @Res() と @Header() の組み合わせは意図通りに動かないことがある
@Get('file')
@Header('Cache-Control', 'no-store') // 効かない場合がある
download(@Res() res: Response) { ... }
// ✓ @Res() を使う場合は直接 setHeader する
@Get('file')
download(@Res() res: Response) {
res.setHeader('Cache-Control', 'no-store');
// ...
}インターセプターとデコレーターの優先順位
@Header() デコレーターはコントローラーの実行前にヘッダーを設定し、インターセプターはレスポンス後に tap で設定することが多い。両方を同じエンドポイントに使う場合は後から実行された方が上書きするため、順序に注意する。