WebアプリケーションでCDNキャッシュの設定をする

## 初めに

以前、以下の記事を書きました。

https://qiita.com/shinjiroy/items/4681185f5604e59b26de

そこで、動的コンテンツのキャッシュの設定はオリジンの、それもアプリケーションにさせるべきだということを書きました。  
この記事では、アプリケーション側でのキャッシュ設定の考え方と実装方法を説明します。

## 前提

- WebアプリケーションのレスポンスはHTMLでもJSONでも可
- 使用する言語やフレームワーク、ミドルウェアは問わない
- Webアプリケーションのミドルウェア(Apache、Nginx等)ではキャッシュに関するヘッダを設定しない
- Webアプリケーションは正常、異常時、適切なHTTPステータスコードを返す
- CDN側で動的コンテンツのエンドポイント(URLパス)についてはオリジンの設定に従う
- CDN側でクエリパラメータやヘッダ等によるキャッシュの分岐(キャッシュキー)の設定は済んでいる。または、アプリケーションの変更に対して随時修正が出来る状態である

## キャッシュ設定の方針

### 1.キャッシュ対象の明確な区別

ユーザー別の情報を扱うエンドポイント（マイページや決済処理等）はキャッシュ対象から除外します。  
具体的にはセッションやAPIトークン等で認証し、オリジンにあるDBからユーザーの情報を取得する必要のあるエンドポイントです。

### 2.CDNキャッシュとブラウザキャッシュの分離

Cache-ControlヘッダやExpiresヘッダは、CDNとブラウザの両方で使用されます。  
例えば `Cache-Control: max-age=3600` を設定すると、CDNが1時間キャッシュするだけでなくブラウザでも1時間キャッシュしてしまいます。  
ブラウザのキャッシュ時間は短めに設定し、意図しない長時間のキャッシュを防ぎます。

### 3.エンドポイント別のキャッシュ設定

コンテンツの性質[^1]に応じて、キャッシュ時間を調整します。

まず、キャッシュして良いエンドポイント共通の設定として以下を定義します：

- ブラウザキャッシュの時間(1-5分程度)
- キャッシュの再検証時間[^2]( `stale-while-revalidate` , `stale-if-error` )

そして、エンドポイント別の設定は以下です：

- CDNキャッシュの時間
- キャッシュタグ(URLパスの階層別、パラメータ別)

### 4.運用・保守性の確保

CDNもキャッシュも正しく理解して使いこなすのはやはり一般的に難しいと思います。  
そのため、キャッシュ設定はCDNやキャッシュに詳しくない人でも設定しやすく、かつ安全に運用できるようにします。  
新しいエンドポイント追加時の設定漏れや、意図しないキャッシュによるインシデントを防げるようにします。

## 実装例

Laravelで実装してみました。  
この例ではCloudflare CDNを想定していますが、使用するCDNに応じて適宜読み替えてください

[shinjiroy/laravel-cdn-cache-example](https://github.com/shinjiroy/laravel-cdn-cache-example)

### キャッシュヘッダ設定用のMiddleware

[SetCacheHeaders.php](https://github.com/shinjiroy/laravel-cdn-cache-example/blob/main/src/app/Http/Middleware/SetCacheHeaders.php)

Controllerの処理完了後、キャッシュして良いレスポンスに対してキャッシュ設定用のヘッダを追加します。

```php
public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);

    if (CacheHeaderHelper::isCachableResponse($request,$response)) {
        $headers = CacheHeaderHelper::generateCacheHeaders($request);
        foreach ($headers as $key => $value) {
            $response->headers->set($key, $value);
        }
    }

    return $response;
}
```

### キャッシュ設定の判定と生成

[CacheHeaderHelper.php](https://github.com/shinjiroy/laravel-cdn-cache-example/blob/main/src/app/Helpers/CacheHeaderHelper.php)

具体的なキャッシュ可能なレスポンスの判定と、キャッシュヘッダの生成を実装します。

#### キャッシュ可能なレスポンスの判定

以下の条件でキャッシュ可能か判定します：  

- 正常なレスポンス（200系）
- リダイレクト（300系）
- 404エラー
- Set-Cookieヘッダが存在しない
- キャッシュ設定が定義されている

ステータスコードに関する条件はアプリケーションの要件によって変えると良いです。

```php
public static function isCachableResponse(Request $request, Response $response) : bool
{
    if (empty(self::getRouteConfig($request))) {
        return false;
    }

    if ($response->headers->has('Set-Cookie')) {
        return false;
    }

    if ($response->isSuccessful()) {
        return true;
    }

    if ($response->isRedirection()) {
        return true;
    }

    if ($response->isNotFound()) {
        return true;
    }

    return false;
}
```

#### キャッシュヘッダの生成

キャッシュ時間とキャッシュタグのためのヘッダを生成します。

```php
public static function generateCacheHeaders(Request $request): array
{
    $headers = [];
    $defaults = Config::get('cache-headers.defaults');
    $routeConfig = self::getRouteConfig($request);

    // Cache-Tagヘッダーの設定
    $tags = self::generateCacheTags($request);
    if ($tags) {
        $headers['Cache-Tag'] = $tags;
    }

    // ブラウザキャッシュ用のCache-Control
    $headers['Cache-Control'] = "max-age={$defaults['browser_max_age']}, private";

    // CDNキャッシュ用のCdn-Cache-Control
    $cdnMaxAge = $routeConfig['cdn_max_age'] ?? $defaults['browser_max_age'];
    $headers['Cdn-Cache-Control'] = "max-age={$cdnMaxAge}, stale-while-revalidate={$defaults['cdn_stale_while_revalidate']}";

    return $headers;
}
```

#### キャッシュ設定の定義

URLパスごとのキャッシュ時間を設定ファイルで定義します。  
ここで

- [CDNキャッシュとブラウザキャッシュの分離](#2cdnキャッシュとブラウザキャッシュの分離)
- [エンドポイント別のキャッシュ設定](#3エンドポイント別のキャッシュ設定)
- [運用・保守性の確保](#4運用保守性の確保)

を実現しようとしています。

```php
return [
    'routes' => [
        '/' => [
            'cdn_max_age' => 86400,
        ],
        'test' => [
            'query_params' => ['q'],
            'cdn_max_age' => 7200,
        ],
        'content/{id}' => [
            'cdn_max_age' => 3600,
        ],
    ],

    'defaults' => [
        'browser_max_age' => 60,
        'cdn_stale_while_revalidate' => 3600,
    ],
];
```

#### キャッシュタグの生成

URLパスの階層別、パラメータ別にキャッシュタグを生成します。  
特にパラメータのタグに関してはパスパラメータは全て設定しますが、  
クエリパラメータはutmパラメータや悪戯等を考慮し、設定ファイルで指定された物のみ設定するようにします。

```php
private static function generateCacheTags(Request $request): string
{
    $tags = [];

    // ルート名をタグに追加
    $route_name = $request->route()->getName();
    $tags = $route_name ? self::generateTagsByRouteName($route_name) : [];

    // パスパラメータをタグに追加
    foreach ($request->route()->parameters() as $key => $value) {
        if (!self::isValidCacheTag($value)) {
            continue;
        }
        $tags[] = Str::lower("{$key}:{$value}");
    }

    // 設定されたクエリパラメータをタグに追加
    $routeConfig = self::getRouteConfig($request);
    if ($routeConfig && isset($routeConfig['query_params'])) {
        foreach ($routeConfig['query_params'] as $key) {
            if ($request->has($key)) {
                if (!self::isValidCacheTag($request->query($key))) {
                    continue;
                }
                $tags[] = Str::lower("{$key}:{$request->query($key)}");
            }
        }
    }

    return implode(',', $tags);
}
```

### Middlewareの適用

「キャッシュ対象の明確な区別」を実装する

キャッシュ可能なURLに対して作成したMiddlewareを適用し、 **Set-Cookieがヘッダが付くMiddleware** を除外します。  
ここで

- [キャッシュ対象の明確な区別](#1キャッシュ対象の明確な区別)

を実現しようとしています。

また、この実装ではrouteファイルに直接書き込んでいますが、Laravelのバージョンに応じて適切と思われるファイルに書き込んで頂ければと思います。

```php
// レスポンスをキャッシュして良いURL
Route::withoutMiddleware([
    Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
    Illuminate\Session\Middleware\StartSession::class,
    Illuminate\View\Middleware\ShareErrorsFromSession::class,
    Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
])->middleware([
    App\Http\Middleware\SetCacheHeaders::class
])->group(function () {
    Route::get('/', function () {
    return view('index');
    })->name('index');

    // 省略
});

// レスポンスをキャッシュしてはならないURL
Route::get('/mypage', function () {
    return view('mypage');
})->name('mypage.index');
```

## 最後に

この記事では、WebアプリケーションでCDNキャッシュを適切に設定するための考え方と、Laravelでの実装例を紹介しました。

実装のポイントは以下の通りです：

- キャッシュしてはいけないエンドポイントを確実に特定し、キャッシュされないようにする
- CDNキャッシュとブラウザキャッシュを区別して設定する
- エンドポイント別にキャッシュの設定を柔軟に行えるようにする

運用時の注意点は以下の通りです：

- 新しいエンドポイントを追加する際は、必ずキャッシュ設定を検討する
- キャッシュタグに使われるパスパラメータやクエリパラメータのキー名の表記ゆれに気を付ける
- キャッシュの設定ミスによるインシデントを防ぐため、慎重にテストを行う
  - レスポンスヘッダを逐一確認する
  - 正常レスポンス、異常レスポンスで確認する

この考え方、実装をベースに、アプリケーションの要件やチームの都合に合わせてカスタマイズしていただければと思います。

[^1]: コンテンツの性質によってキャッシュしたい、出来る時間が変わって来ます。
      参考:[コンテンツ別のキャッシュ戦略の例](https://qiita.com/shinjiroy/items/4681185f5604e59b26de#%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84%E5%88%A5%E3%81%AE%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5%E6%88%A6%E7%95%A5%E3%81%AE%E4%BE%8B)
[^2]: キャッシュの再検証時間はキャッシュが切れた時にガッとオリジンリクエストが来ないようにするための補助的な物です。  
      キャッシュが切れた後、指定した時間の間のどこかでCDNが非同期にオリジンリクエストをし直します。  
      例えば1時間に設定しても1時間後に再検証されるなんてことは滅多にありませんが、1年くらいにすると目に見えて再検証が遅くなります。  
      あくまで、リクエストが滞りなく発生している状況でキャッシュが切れた時の保険として考えておくと良いかもしれません。