ドキュメントでは教えてくれないNext.js Cache handlerの挙動

サムネイル

はじめに

はじめまして、電子書籍開発部 基盤開発グループの加藤です。
我々基盤開発グループは主に、DMMブックスの保守・運用、高速化、リプレースなどを担当しています。

今回は、Next.jsのData cacheを導入する前段階として動作検証や基盤構築をした際に得られた知見を共有したいと思います。

前提として、今回調査した内容はNext.js v15系時点でのものです。
したがってv16系では今回の話を適用できない可能性があります。

前提知識

Cache handler とは

Cache handler は、Next.js のキャッシュ機能である Data cache やページのキャッシュの動作を制御するための機構です。
デフォルトではインメモリキャッシュが使用されますが、カスタマイズすることで独自のキャッシュストアを実装したり、
細かな挙動調整をしたりが可能です。

カスタマイズする際はキャッシュの取得、保存、削除などの操作をするためのメソッドを提供するクラスとして実装する必要があり、next.config.jsでクラスを定義したファイルを指定します。

// cache-handler.mjs
export default class CacheHandler {
    // ...
}
// next.config.js
module.exports = {
    // ...
    cacheHandler: package.resolve('./path/to/your/cache-handler.mjs'),
};

余談として Next.js v16 において cacheHandlers という別のオプションがありますが、
これは "use cache" ディレクティブの動作を構成するための全く別のオプションであることに注意してください。
私は調査中ずっと勘違いしていました...。

わかったこと

ここからは本題として、今回調査してわかった Data cache の挙動、 Cache handler の仕様について紹介します。

大した内容では無いのですが、もし参考になれば幸いです。

  • キャッシュ有効期限切れ時の挙動
  • 実は get メソッドの第二引数にコンテキストが渡される

キャッシュ有効期限時の挙動

まずは背景から。

Data cacheでは、fetchの際にnext: { revalidate: number }オプションを指定することで、キャッシュの有効期限を設定できます。

const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 30 }, // キャッシュの有効期限を30秒に設定
});

最初に動作を検証するとしたら、当然ながらリロードしまくってキャッシュが効いていること・更新されることを確認しますよね?

そこでいったんAPIを設置し、試してみました。

// app/api/now.ts
export async function GET() {
    return NextResponse.json({ now: new Date.toLocaleString() })
}

// app/page.tsx
export default async function Page() {
    const data = await fetch(baseUrl + "/api/now", {
        cache: "force-cache",
        next: {
            revalidate: 30, // 今回は30秒
        }
    })

    return (
        <h1>{data}</h1>
    )
}

今回は検証のしやすそうな30秒に設定しました。

早速ページを開いてみると確かに現在時刻が表示され、数秒後にリロードすると先ほどの時刻が確認できます。

では最初に開いてから40秒後にリロードしてみるとどうでしょう。

私としては新たな現在時刻が表示されることを期待していたのですが、結果としては最初の時刻が表示されました。
そしてもう一度すぐにリロードすると、今度は先ほど(40秒後)の時刻が表示されました。

大体こんな感じ↓。

回目 開いた時刻 表示時刻
1 11:00:00 11:00:00
2 11:00:10 11:00:00
3 11:00:40 11:00:00
4 11:00:50 11:00:40

このことから Next.js の Data cache は、キャッシュが存在する場合にはキャッシュの期限切れに関わらずそのデータを利用し、
期限切れのキャッシュについてはその後バックグラウンドで更新されることがわかりました。

HTTP レスポンスの Cache-Control ヘッダーのように、寿命と寿命後の利用可能期間を自由に設定できれば理想的でした。
しかし、Data cacheでは寿命後の利用期間を制限できず、実質的に「再検証期間が無限」の設定しかできないわけです。

そこで、寿命を強制させたい!と思ったら、Cache handler のカスタマイズの時間です。

以下のように、キャッシュデータと一緒に最終更新日時を保存しておいて、 取得(get)時に判定してあげればOKです。

// cache-handler.js
class CacheHandler {
    async set(key, value) {
        this.storage.set({
            // 最終更新日時を一緒に保存
            lastUpdatedAt: new Date().getTime(),
            ...value,
        })
    }

    async get(key) {
        const value = await this.storage.get(key);
        if (value) {
            const age =  new Date().now() - value.lastUpdatedAt;
            const expired = value.revalidate * 1000 < age;
            if (expired) {
                return null;
            }
        }
        return value;
    }

    // ...
}

Cache handlerの一部だけをカスタマイズする、ということはできないので他のメソッドも実装する必要があることに注意してください。 デフォルトと同じ挙動にしたい場合は、デフォルトのキャッシュハンドラーを参考にすると良いでしょう。

実は get メソッドの第二引数にコンテキストが渡される

CacheHandlergetメソッドは、キャッシュを取得する際に呼び出されます。
ドキュメントには記載されていませんが、実は第二引数にコンテキストオブジェクトが渡されます。

// cache-handler.js
module.exports = class CacheHandler {
    // ...
    get(key, ctx) {
        console.log(ctx)
        // ...
    }
}

// log
{
  kind: 'FETCH',
  revalidate: 10,
  fetchUrl: 'http://localhost:4000/api/fuga',
  fetchIdx: 1,
  tags: [],
  softTags: [
    '_N_T_/layout',
    '_N_T_/datacache/layout',
    '_N_T_/datacache/page',
    '_N_T_/datacache'
  ]
}

したがって、以下の様なユースケースを実装できるでしょう。

  • タグに応じた処理
  • キャッシュの寿命を短くする
    • 保存した時は一日だったけど、やっぱり寿命 1 時間で取得したい!

まとめ

今回は Next.js の Data cache や Cache handler の挙動について紹介しました。

今後もDMMブックスの高速化や安定化に向けて、様々な技術的チャレンジを続けていきますので、引き続き応援よろしくお願いいたします。

それでは、良いお年を!

参考資料