コンテンツへスキップ
ブログに戻る

2025年1月3日 金曜日

Next.jsによるコンポーザブルなキャッシング

投稿者

Next.jsのために、シンプルで強力なキャッシングモデルを開発中です。以前の投稿では、キャッシングの取り組みについて、そしてどのようにして「'use cache'」ディレクティブにたどり着いたかをお話しました。

この投稿では、「'use cache'」のAPI設計と利点について説明します。

'use cache'とは?

'use cache'は、必要に応じてデータやコンポーネントをキャッシュすることで、アプリケーションを高速化します。

これはJavaScriptの「ディレクティブ」(コードに追加する文字列リテラル)であり、Next.jsコンパイラに異なる「境界」に入ることを伝えます。例えば、サーバーからクライアントへ移行する際に使用されます。

これは、「'use client'」や「'use server'」といったReactディレクティブと同様の考え方です。ディレクティブは、コードがどこで実行されるべきかを定義するコンパイラ命令であり、フレームワークが個々の要素を最適化し、オーケストレーションすることを可能にします。

仕組みは?

簡単な例から始めましょう

async function getUser(id) {
  'use cache';
  let res = await fetch(`https://api.vercel.app/user/${id}`);
  return res.json();
}

舞台裏では、「'use cache'」ディレクティブにより、Next.jsはこのコードをサーバー関数に変換します。コンパイル中、このキャッシュエントリの「依存関係」が特定され、キャッシュキーの一部として使用されます。

例えば、`id`はキャッシュキーの一部になります。`getUser(1)`を複数回呼び出すと、キャッシュされたサーバー関数からメモ化された出力が返されます。この値を変更すると、キャッシュに新しいエントリが作成されます。

サーバーコンポーネントでキャッシュされた関数をクロージャで使用する例を見てみましょう。

function Profile({ id }) {
  async function getNotifications(index, limit) {
    'use cache';
    return await db
      .select()
      .from(notifications)
      .limit(limit)
      .offset(index)
      .where(eq(notifications.userId, id));
  }
 
  return <User notifications={getNotifications} />;
}

この例はもう少し複雑です。キャッシュキーの一部となるべきすべての依存関係を見つけることができますか?

引数`index`と`limit`は理解できます。これらの値が変われば、通知の異なるスライスが選択されます。しかし、ユーザー`id`はどうでしょうか?その値は親コンポーネントから来ています。

コンパイラは`getNotifications`が`id`にも依存していることを理解し、その値は自動的にキャッシュキーに含まれます。これにより、キャッシュキーにおける誤った依存関係や欠落した依存関係によるキャッシュ問題のカテゴリー全体が防止されます。

なぜキャッシュ関数を使わないのか?

前述の例をもう一度見てみましょう。ディレクティブの代わりに`cache()`関数を使うことはできないでしょうか?

function Profile({ id }) {
  async function getNotifications(index, limit) {
    return await cache(async () => {
      return await db
        .select()
        .from(notifications)
        .limit(limit)
        .offset(index)
        // Oops! Where do we include id in the cache key?
        .where(eq(notifications.userId, id));
    });
  }
 
  return <User notifications={getNotifications} />;
}

`cache()`関数はクロージャを調べて`id`の値がキャッシュキーの一部であるべきかを判断できません。`id`がキーの一部であることを手動で指定する必要があります。もしそれを忘れたり、誤って指定したりすると、キャッシュの衝突や古いデータの危険性があります。

クロージャはあらゆる種類のローカル変数を捕捉できます。素朴なアプローチでは、意図しない変数を誤って含めたり(または除外したり)する可能性があります。これは誤ったデータのキャッシュにつながるか、機密情報がキャッシュキーに漏洩した場合にキャッシュポイズニングのリスクを招く可能性があります。

'use cache'は、コンパイラにクロージャを安全に処理し、キャッシュキーを正しく生成するために十分なコンテキストを提供します。`cache()`のような実行時のみのソリューションでは、すべてを手動で行う必要があり、間違いを犯しやすくなります。対照的に、ディレクティブは静的に分析することで、内部ですべての依存関係を確実に処理できます。

非シリアル化入力値はどのように扱われるか?

キャッシュする入力値には2つの異なるタイプがあります

  • シリアル化可能: ここでいう「シリアル化可能」とは、意味を失うことなく入力が安定した文字列ベースの形式に変換できることを意味します。多くの人がまず`JSON.stringify`を思い浮かべるかもしれませんが、私たちは実際にはReactのシリアル化(例: サーバーコンポーネント経由)を使用して、プロミス、循環データ構造、その他の複雑なオブジェクトを含む、より広範囲の入力を処理します。これはプレーンなJSONができる範囲を超えています。
  • 非シリアル化可能: これらの入力はキャッシュキーの一部ではありません。これらの値をキャッシュしようとすると、サーバー「参照」が返されます。この参照は、Next.jsによって実行時に元の値を復元するために使用されます。

`id`をキャッシュキーに含めるのを忘れていなかったとしましょう

await cache(async () => {
  return await db
    .select()
    .from(notifications)
    .limit(limit)
    .offset(index)
    .where(eq(notifications.userId, id));
}, [id, index, limit]);

これは、入力値がシリアル化可能であれば機能します。しかし、`id`がReact要素やより複雑な値である場合、入力キーを手動でシリアル化する必要があります。`id`プロップに基づいて現在のユーザーをフェッチするサーバーコンポーネントを考えてみましょう。

async function Profile({ id, children }) {
  'use cache';
  const user = await getUser(id);
 
  return (
    <>
      <h1>{user.name}</h1>
      {/* Changing children doesn’t break the cache... why? */}
      {children}
    </>
  );
}

仕組みを見ていきましょう

  1. コンパイル中、Next.jsは「'use cache'」ディレクティブを認識し、キャッシングをサポートする特別なサーバー関数を作成するようにコードを変換します。コンパイル中にキャッシングは行われず、Next.jsは実行時キャッシングに必要なメカニズムをセットアップしているだけです。
  2. コードが「キャッシュ関数」を呼び出すと、Next.jsはその関数の引数をシリアル化します。JSXのように直接シリアル化できないものは、すべて「参照」プレースホルダーに置き換えられます。
  3. Next.jsは、与えられたシリアル化された引数に対してキャッシュされた結果が存在するかどうかを確認します。結果が見つからない場合、関数はキャッシュする新しい値を計算します。
  4. 関数が完了すると、戻り値がシリアル化されます。戻り値の非シリアル化可能な部分は、参照に変換し直されます。
  5. キャッシュ関数を呼び出したコードは、出力を逆シリアル化し、参照を評価します。これにより、Next.jsは参照を実際のオブジェクトや値と入れ替えることができ、`children`のような非シリアル化可能な入力が元のキャッシュされていない値を保持できるようになります。

これは、``コンポーネントのみを安全にキャッシュし、子要素はキャッシュしないことを意味します。その後のレンダリングでは、`getUser()`は再度呼び出されません。`children`の値は動的であるか、異なるキャッシュ寿命を持つ個別にキャッシュされた要素である可能性があります。これがコンポーザブルなキャッシングです。

これは見覚えがある…

もし「サーバーとクライアントのコンポジションと同じモデルのように感じる」と考えているなら、その通りです。これは時々「ドーナツ」パターンと呼ばれます

  • ドーナツの外側は、データ取得や重いロジックを処理するサーバーコンポーネントです。
  • 真ん中のは、インタラクティブ性を持つ可能性のある子コンポーネントです。
app/page.tsx
export default function Page() {
  return (
    <ServerComponent>
      {/* Create a hole to the client */}
      <ClientComponent />
    <ServerComponent />
  );
}

'use cache'も同様です。ドーナツは外側コンポーネントのキャッシュされた値であり、穴は実行時に埋められる参照です。そのため、`children`を変更してもキャッシュされた出力全体が無効になることはありません。子要素は単に後で埋められる参照に過ぎません。

タグ付けと無効化については?

キャッシュの寿命は、異なるプロファイルで定義できます。デフォルトのプロファイルセットが含まれていますが、必要に応じて独自のカスタム値を定義することも可能です。

async function getUser(id) {
  'use cache';
  cacheLife('hours');
  let res = await fetch(`https://api.vercel.app/user/${id}`);
  return res.json();
}

特定のキャッシュエントリを無効にするには、キャッシュにタグを付けてから`revalidateTag()`を呼び出すことができます。強力なパターンの一つは、データをフェッチした後(例: CMSから)にキャッシュにタグを付けられることです。

async function getPost(postId) {
  'use cache';
  let res = await fetch(`https://api.vercel.app/blog/${postId}`);
  let data = await res.json();
  cacheTag(postId, data.authorId);
  return data;
}

シンプルで強力

'use cache'の目標は、キャッシングロジックの作成をシンプルかつ強力にすることです。

  • シンプルさ: ローカルな推論でキャッシュエントリを作成できます。キャッシュキーエントリの忘れや、コードベースの他の部分への意図しない変更など、グローバルな副作用について心配する必要はありません。
  • 強力さ: 静的に分析可能なコードだけでなく、より多くのものをキャッシュできます。例えば、実行時に変更される可能性のある値であっても、評価後にその出力結果をキャッシュしたい場合などです。

`'use cache'`はNext.js内でまだ実験段階です。お試しいただいた上での早期のフィードバックをお待ちしております。

ドキュメントでさらに学ぶ.