🌍

Dynamic IOの成り立ちと"use cache"の深層

に公開8

"use cache"は、Next.jsのDynamic IOで利用することができる新たなディレクティブです。本稿は筆者の備忘録を兼ねて、Dynamic IOの成り立ちや"use cache"の内部実装について解説するものです。

Dynamic IOの成り立ち

Dynamic IOは2024年10月のNext Confで発表された、Next.jsにおける新しいコンセプトを実証するための実験的モードです。Dynamic IOはその名の通り、主に動的I/O処理に対する振る舞いを大きく変更するものです。

具体的には、以下の処理に対する振る舞いが変更されます。

  • データフェッチ: fetch()やDBアクセスなど
  • Dynamic APIs: headers()cookies()など
  • Next.jsがラップするモジュール: DateMath、Node.jsのcryptoモジュールなど
  • 任意の非同期関数(マイクロタスクを除く)

Dynamic IOでこれらを扱う際には<Suspense>境界内に配置、もしくは"use cache"でキャッシュ可能であることを宣言する必要があります[1]

  • <Suspense>: 動的にデータフェッチを実行する場合、対象のServer Componentsを<Suspense>境界内に配置します。従来同様<Suspense>境界内はStreamingで配信されます。
  • "use cache": データフェッチがキャッシュ可能な場合、"use cache"を宣言することで、Next.jsにキャッシュ可能であることを指示します。

Partial Pre-Rendering(PPR)を理解してる方であれば、Static RenderingDynamic Renderingが1つのページで混在し<Suspense>境界単位でレンダリングを分離していく設計については馴染み深いことでしょう。

ppr shell

https://zenn.dev/akfm/articles/nextjs-partial-pre-rendering

Dynamic IOではさらにこれを発展させ、Dynamic RenderingにStatic Renderingを入れ子にすることが可能となります。

export default function Page() {
  return (
    <>
      ...
      {/* Static Rendering */}
      <Suspense fallback={<Loading />}>
        {/* Dynamic Rendering */}
        <DynamicComponent>
          {/* Static Rendering */}
          <StaticComponent />
        </DynamicComponent>
      </Suspense>
    </>
  );
}

Dynamic IOではレスポンスの開始がデータフェッチによってブロックされることがないため、効率的な配信が可能となります。

私見: キャッシュの混乱とNext.jsへの評価

Dynamic IOは現状実験的モードですが、未来のNext.jsのあり方の1つとも考えられます。従来のNext.jsのデフォルトで強力なキャッシュは、開発者に多くの混乱をもたらしました。Dynamic IOにおいて開発者は、<Suspense>境界内で常に実行するか"use cache"でキャッシュ可能にするか明示的に選択することになるため、従来の混乱は解消されると考えられます。

キャッシュの複雑さはNext.jsに対する最も大きなネガティブ要素だったと言っても過言ではありません。Dynamic IOの開発が進むにつれ、Next.jsに対する評価も大きく改める必要があるのではないかと筆者は考えています。

"use cache"

"use cache"はDynamic IOにおける最も重要なコンセプトです。"use cache"はファイルや関数の先頭につけることができ、Next.jsはこれにより関数やファイルスコープがキャッシュ可能であることを理解します。

// File level
"use cache";

export default async function Page() {
  // ...
}

// Component level
export async function MyComponent() {
  "use cache";
  return <></>;
}

// Function level
export async function getData() {
  "use cache";
  const data = await fetch("/api/data");
  return data;
}

"use cache"は引数や参照してる変数などを自動的にキャッシュのキーとして認識しますが、childrenのようなキーに不適切な一部の値は自動的に除外されます。

より詳細に知りたい方は、以下公式ドキュメントを参照ください。

https://nextjs.org/docs/canary/app/api-reference/directives/use-cache

キャッシュの永続化

"use cache"によるキャッシュは、内部的に以下2つに分類されます。

  • オンデマンドキャッシュ[2]: オンデマンド(next start以降)で利用されるキャッシュ
  • ResumeDataCache: PPRのPrerenderから引き継がれるキャッシュ

オンデマンドキャッシュは、現時点ではシンプルなLRUのインメモリキャッシュです。内部的にはCacheHandlerという抽象化がされており、将来的には開発者がカスタマイズ可能になることが示唆されています。

ResumeDataCacheはPPRのPrerenderから引き継がれる特殊なキャッシュで、現時点ではCacheHandlerとは別物になっています。

"use cache"の内部実装

以降は"use cache"がどう実現されているのか、Next.jsの内部実装について解説します。

"use cache"は大まかに以下のような仕組みで実現されています。

  1. SWC Pluginで"use cache"対象となる関数を、Next.js内部定義のcache()を通して定義する形に変換する
  2. cache()は引数にキャッシュのIDや元となる関数などを含み、このIDなどをもとにキャッシュの取得や保存を行う

"use cache"に対するトランスパイル

Next.jsアプリケーションはSWCでトランスパイルされ、Server Actionsをマークする"use server"に対してはSWC Pluginによって独自の変換処理が適用されます。"use cache"の変換も、このSWC Pluginに含まれる形で実装されています。

"use server"用のPluginで"use cache"に関する処理をしているのはだいぶ違和感がありますが、実験的モードですし実装速度を優先したのかもしれません。

以下はfunction() {}の先頭に"use cache"があった時の処理です。if let Directive::UseCache { cache_kind } = directive { ... }"use cache"を判定しています。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/crates/next-custom-transforms/src/transforms/server_actions.rs#L993-L1032

この処理内でself.maybe_hoist_and_create_proxy_for_cache_function()が呼ばれることで、self.has_cache = trueとなります。ここで設定されたself.has_cacheにより、以下の分岐に入ります。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/crates/next-custom-transforms/src/transforms/server_actions.rs#L1979-L2001

ここでは対象コードのアウトプットであるnewに対し、コメントにもあるようなimport { cache as $$cache__ } from "private-next-rsc-cache-wrapper";が挿入されるような処理がされています。

さらにself.maybe_hoist_and_create_proxy_for_cache_function()の後続処理で、対象のfunction() {}に対しexport var {cache_ident} = ...の形に変換処理が適用されます。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/crates/next-custom-transforms/src/transforms/server_actions.rs#L836-L863

上記のinitで呼ばれるwrap_cache_expr()にて、対象のfunction() {}$$cache__("name", "id", 0, function() {})のような形に変換されます。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/crates/next-custom-transforms/src/transforms/server_actions.rs#L2217-L2236

これらの処理により、"use cache"の対象関数はprivate-next-rsc-cache-wrappercache()関数を介して定義される形に変換されます。

その他にもいくつか処理はありますが、残り部分の処理は割愛します。これらの処理を経て最終的には以下のような入出力が得られます。

input
"use cache";
import React from "react";
import { Inter } from "@next/font/google";

const inter = Inter();

export async function Cached({ children }) {
  return <div className={inter.className}>{children}</div>;
}
output
/* __next_internal_action_entry_do_not_use__ {"c0dd5bb6fef67f5ab84327f5164ac2c3111a159337":"$$RSC_SERVER_CACHE_0"} */ import { registerServerReference } from "private-next-rsc-server-reference";
import {
  encryptActionBoundArgs,
  decryptActionBoundArgs,
} from "private-next-rsc-action-encryption";
import { cache as $$cache__ } from "private-next-rsc-cache-wrapper";
import React from "react";
import inter from '@next/font/google/target.css?{"path":"app/test.tsx","import":"Inter","arguments":[],"variableName":"inter"}';
export var $$RSC_SERVER_CACHE_0 = $$cache__(
  "default",
  "c0dd5bb6fef67f5ab84327f5164ac2c3111a159337",
  0,
  /*#__TURBOPACK_DISABLE_EXPORT_MERGING__*/ async function Cached({
    children,
  }) {
    return <div className={inter.className}>{children}</div>;
  },
);
Object.defineProperty($$RSC_SERVER_CACHE_0, "name", {
  value: "Cached",
  writable: false,
});
export var Cached = registerServerReference(
  $$RSC_SERVER_CACHE_0,
  "c0dd5bb6fef67f5ab84327f5164ac2c3111a159337",
  null,
);

private-next-rsc-cache-wrapper

上記の変換で挿入されたprivate-next-rsc-cache-wrapperは、webpackのaliasです。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/build/create-compiler-aliases.ts#L165-L166

上記パスは以下のファイルのbuild結果で、use-cache-wrapper.tsよりcache()をそのままexportしています。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/build/webpack/loaders/next-flight-loader/cache-wrapper.ts

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/server/use-cache/use-cache-wrapper.ts#L438-L443

このcache()関数こそが、"use cache"の振る舞いを実装している部分になります。

cache()

cache()関数は数百行程度ありますが、ここでは特にキャッシュの永続化の仕組みについて確認します。キャッシュの永続化は前述の通り以下2つに分けられています。

  • オンデマンドキャッシュ(CacheHandler由来)
  • ResumeDataCache

オンデマンドキャッシュ(CacheHandler由来)

CacheHandlerは以下で定義されます。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/server/use-cache/use-cache-wrapper.ts#L57-L60

設定可能なCacheHandlerのように見えますが、導入時のPRや筆者が実装を確認した限りでは設定できるような手段は見つかりませんでした。そのため、現状は必ずDefaultCacheHandlerが利用されるものと考えられます。なお、DefaultCacheHandlerはLRUのインメモリキャッシュです。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/server/lib/cache-handlers/default.ts#L33

ResumeDataCache

ResumeDataCacheは、文字通りレンダリングをResume=再開するためのキャッシュです。これはPPR有効時に利用される共有キャッシュで、next build時に生成したキャッシュをそのままnext startで再利用することができます。

https://github.com/vercel/next.js/blob/564794df56e421d6d4c2575b466a8be3a96dd39a/packages/next/src/server/use-cache/use-cache-wrapper.ts#L552-L557

ResumeDataCacheは以下のPRで追加されました。

https://github.com/vercel/next.js/pull/72161

Prerender時にアクセスされなかった"use cache"な関数は、ResumeDataCacheではなくCacheHandlerでハンドリングされます。

キャッシュデータの実態

CacheHandlerで永続化されるキャッシュもResumeDataCacheも、データの実態はRSC Payloadです。つまり"use cache"を適用するとコンポーネントも関数も同様に、RSC Payloadとして内部的に処理されます。

"use cache"のキャッシュのキーや関数の戻り値はシリアル化可能であるという制約は、内部的にRSC Payloadで扱うことや、外部に保存することを考慮しての制約だと考えられます。

おおよそ"use cache"を実現するための実装が理解できたので、調査はここまでとしました。

感想

これまで筆者はNext.jsのキャッシュの仕組みに関する記事をいくつか執筆してきました。

https://zenn.dev/akfm/articles/next-app-router-navigation

https://zenn.dev/akfm/articles/next-app-router-client-cache

https://zenn.dev/akfm/articles/nextjs-revalidate

上記の記事を執筆してる時に何度も、注意すべき制約が多いことが気がかりでした。今回"use cache"について調査している時には、利用者側の制約については少ないような印象を受けました。SWC Pluginによる実装は黒魔術やMagicと称され忌避されることも多いですが、実際利用する観点においてはシンプルに設計されていると思います。

従来のキャッシュに対するネガティブな意見は、デフォルト挙動に関するものが大きかったとは思います。Dynamic IOはシンプルな設計の上に成り立っているように感じており、調査を進めるほど期待感が高まりました。今後のDynamic IOの開発に期待したいところです。

脚注
  1. Dynamic APIsはリクエスト時の情報を基本としているため、"use cache"することはできません。 ↩︎

  2. CacheHandler由来のキャッシュ」では冗長なため、本稿において筆者が命名したものです。 ↩︎

Discussion

sumirensumiren

記事ありがとうございます!

=======

Dynamic IOではさらにこれを発展させ、Dynamic RenderingにStatic Renderingを入れ子にすることが可能となります。

Dynamic IOではレスポンスの開始がデータフェッチによってブロックされることがないため、効率的な配信が可能となります。

ここだけちょっとわからなかったので、質問してもよろしいでしょうか。

https://nextjs.org/blog/our-journey-with-caching
これを見る限り、dynamic IOのスコープはfetchのオプション + unstable_cacheを代替するもので、あまりレンダリングの考え方には影響ないのかなと、少なくとも自分は理解しました。

なので、DynamicComponentのなかにStaticComponentを入れ子にしたときに具体的にどのように挙動が変わるのか理解できませんでした。Static ComponentをIOキャッシュを伴うものと見ているかどうかが結構大きい気がしています。

また、Dynamic IOではフェッチによってブロックされることがないというのも、現状でできていることとの違いがわからなかったので、よければ詳しく教えてもらえないでしょうか。

=======

特に記事を直してほしいとかではなく単純な疑問です!返事を求めるのも本来厚かましいことと承知のうえですので、気が向いたらの回答で構いません!記事が興味深かったので質問でした!

akfm_satoakfm_sato

質問ありがとうございます!
大晦日にご苦労様ですww

dynamic IOのスコープはfetchのオプション + unstable_cacheを代替するもの

こちらについては「Our Journey with Caching」にて

But for a moment, I'd like you to forget about all that.
(でも、しばらくの間、そんなことは忘れてほしい。)

とあるように、Dynamic IOの世界観では今までの世界観は一旦忘れて良いかと思います。
その上でDynamic IOのコンセプトを簡単に書き出すと「動的I/O処理を含む場合には遅延させる(<Suspense>)かキャッシュする("use cache")必要がある」ということになります。

これらの前提のもと、

Dynamic IOではフェッチによってブロックされることがないというのも、現状でできていることとの違いがわからなかったので、よければ詳しく教えてもらえないでしょうか。

こちらに回答すると、フェッチなどの動的I/O処理を扱うには「遅延させる(<Suspense>)かキャッシュする("use cache")必要がある」ので、ページのレンダリングでフェッチを即座に扱うことができなくなりました。つまり新たにできることが増えたというより、できることを減らしてパフォーマンスを追求する形です。ちょっと語弊があるのですが少々極端にいうと、PPRを強制するような世界観のイメージです。
一方、

なので、DynamicComponentのなかにStaticComponentを入れ子にしたときに具体的にどのように挙動が変わるのか理解できませんでした。

こちらは新たにできるようになったことになります。従来はPPRによって「静的コンポーネントに動的コンポーネントを埋め込む」ことはできたのですが、「動的コンポーネントに静的コンポーネントを埋め込む」ことはできませんでした。これはキャッシュのオプトインが"use cache"ディレクティブによってするような設計になったことで、新たにできるようになったものです。
以下に例を挙げてみます。ユーザー情報を含み、動的にレンダリングする必要のある<Cart>内で、<ProductCard>のようなキャッシュ可能なコンポーネントを埋め込むことなどができるようになったというイメージです。

async function Cart() {
  const session = await getSession();
  // `session.cart.products`に商品ID配列があると仮定
  // session情報を参照してるので`<Cart>`は動的にレンダリングする必要がある

  return (
    <>
      {session.cart.products.map(product => (
        {/* `<ProductCard/>`は`"use cache"`でキャッシュされるコンポーネントでもOK */}
        {/* `"use cache"`指定時のキャッシュはRSC Payloadで永続化されるのでコンポーネントもキャッシュ可能 */}
        <ProductCard id={product.id}>
      ))}
    </>
  )
}

以上になります。
知りたきことに対する回答になってますかね...?
sumirenさんが認識してる「現状でできていること」と僕が認識してる「現状でできていること」に齟齬があって変な回答になってないか少し心配です。
↑の回答で意味がわからないという部分があれば、この「現状でできていること」も併せてご教示いただけると幸いです。

sumirensumiren

ありがとうございます!!

できることを減らしてパフォーマンスを追求する形です。ちょっと語弊があるのですが少々極端にいうと、PPRを強制するような世界観のイメージです。

あぁ、たしかに!あまりやることがないので忘れてましたが、たしかにいままでは同期でfetchを扱うことで非Streamingでのレンダリングも可能でしたね!それがStreaming or Staticの二択になったのは確かに1つのレンダリング上の変化ですね。

Dynamic IOではレスポンスの開始がデータフェッチによってブロックされることがないため、効率的な配信が可能となります。

この記載は、上記の制約を指しているであってますかね!

以下に例を挙げてみます。ユーザー情報を含み、動的にレンダリングする必要のある<Cart>内で、<ProductCard>のようなキャッシュ可能なコンポーネントを埋め込むことなどができるようになったというイメージです。

なるほど。これって今までできてなかったでしたっけ...?動くけどPPRはできなかったということですかね?自分の現状に対する理解は以下です!

  • 動的コンポーネントでfetch等を含まない静的コンポーネントを利用することはできる
  • 同様に動的コンポーネントでfetch等(ビルド時 or revalidate)を含む静的コンポーネントを利用することはできる(例えばProductCardが3分ごとにデータrevalidateしてたとしても問題なく動く)
  • ただしPPRはされず、動的コンポーネントのレンダリング時にHTMLなりRSC Payloadに変換される
akfm_satoakfm_sato

補足ありがとうございます!

同様に動的コンポーネントでfetch等(ビルド時 or revalidate)を含む静的コンポーネントを利用することはできる(例えばProductCardが3分ごとにデータrevalidateしてたとしても問題なく動く)

多分ここに細かいニュアンスの齟齬がありそうな気がします👀
従来はFull Route Cache=Route全てをキャッシュするか、Data Cache=fetch単位でキャッシュするかしかなかったんですが、Dynamic IOでは"use cache"をコンポーネント単位で付与できるようになりました。これは従来できなかったことだと思います。

僕の実装例で言うと、<Cart>内の<ProductCard>の内容がキャッシュ可能だったとしても、従来は↓のようにfetchにキャッシュを指定する必要がありました。

async function ProductCard({ id }: { id: string }) {
  console.log("render <ProductCard>");
  const product = await fetch(`.../${id}`, {
    cache: "force-cache",
  })

  // ...
}

この場合"render <ProductCard>"<Cart>がレンダリングされるたびに出力されました。
これがDynamic IO以降は以下になります。

async function ProductCard({ id }: { id: string }) {
  "use cache";

  console.log("render <ProductCard>");
  const product = await fetch(`.../${id}`)

  // ...
}

この場合"render <ProductCard>"<Cart>がレンダリングされるたびに出力されません
この<ProductCard>がrevalidateされるまでずっとコンポーネントレベルでキャッシュされます。

ここが従来との違いになります!
いかがでしょうか...?

sumirensumiren

ありがとうございます!長くやりとり継続いただいていてすみません!できる範囲で回答お願いします!

この場合"render <ProductCard>"は<Cart>がレンダリングされるたびに出力されません。
この<ProductCard>がrevalidateされるまでずっとコンポーネントレベルでキャッシュされます。

なるほど、ようやく理解できました!言い方に違いはあるかもしれませんが、以下のように理解しております。

  • 現状でもfetch等に関していえばData Cacheで個別にコントロールできるし、Dynamic IOでも変わらない
  • Dynamic IOではFull Route CacheやData Cacheの使い分けがなくなり、内部的にDynamic IOのキャッシュで統一される
    • 現状でもpprを指定すればFull Route Cacheをユーザー側があまり意識しないのは同じだが、内部的に、Full Route CacheはSuspense境界によって制御してData Cacheはfetchやunstable_cacheで制御していたのが、use cacheの仕組みによるコンポーネントや関数レベルでのキャッシュに統一される
    • (だとするとRoute Segment Configみたいな考え方はなくなっていって、そこの挙動は全部pprになっていくんですかね...?)
  • コンポーネントや関数レベルでキャッシュが可能になることで、たとえば動的なコンポーネントが内部的に静的なコンポーネント(use cacheや非同期関数のない全くの静的なコンポーネントや、非同期関数を含んでいてもuse cacheされたもの)をrenderしていても、静的な部分はデータレベルではなくコンポーネントレベルでキャッシュでき、レンダリングのオーバーヘッドが削減される

ちなみに記事の下記の例はコンポジションになっていますが、コンポジションでも現状は実行時のツリー上にSuspenseの下にいたら動的にrenderされる認識でいらっしゃいますかね?(自分の直感が誤っているかもなので、検証してみようかなと思います)(もはやDynamic IOにベットするのだから現状の挙動は気にしなくて良い感もありますが)

export default function Page() {
return (
<>
...
{/* Static Rendering /}
<Suspense fallback={<Loading />}>
{/
Dynamic Rendering /}
<DynamicComponent>
{/
Static Rendering */}
<StaticComponent />
</DynamicComponent>
</Suspense>
</>
);
}

akfm_satoakfm_sato

現状でもpprを指定すればFull Route Cacheをユーザー側があまり意識しないのは同じだが、内部的に、Full Route CacheはSuspense境界によって制御してData Cacheはfetchやunstable_cacheで制御していたのが、use cacheの仕組みによるコンポーネントや関数レベルでのキャッシュに統一される

はい、そうなります!
そして本記事で扱ってるように、"use cache"によるキャッシュの永続化は従来とは異なるものになってます。

(だとするとRoute Segment Configみたいな考え方はなくなっていって、そこの挙動は全部pprになっていくんですかね...?)

こちらもおっしゃる通り...だと思います!
Our Journey with CachingでRoute Segment ConfigはEscape Hatchesと表現されており、今後は併用されない世界観を作っていくつもりなのであろうと予想しています。
最もこれは僕がそう読み解いてるだけで、直接明言されているわけではないので「おそらくそうだろう」という予想のお話ですが...

ちなみに記事の下記の例はコンポジションになっていますが、コンポジションでも現状は実行時のツリー上にSuspenseの下にいたら動的にrenderされる認識でいらっしゃいますかね?

はい、Dynamic IO以前の世界(PPR有効時)では<StaticComponents>は動的にレンダリングされて、回避する手段はなかった認識です。
Dynamic APIs利用時にNext.jsはpostponeという特別なPromiseをthrowします。これはbuild時には永遠に解決されないPromiseです。このPromiseがthrowされたら<Suspense>境界に対して動的レンダリングをマークしていくので、コンポジションにしてても<Suspense>境界単位が同一な<StaticComponents>は動的にレンダリングされるはずだと思います!

koichikkoichik

experimental.dynamicIOを有効にしているのにRoute Segment Configを指定すると数ヶ月前には既にビルドでエラーになったはず
全てのRoute Segment Configではないかもだけど少なくともexport const dynamic = ...はそうだった記憶です

sumirensumiren

ありがとうございます!

Dynamic APIs利用時にNext.jsはpostponeという特別なPromiseをthrowします。これはbuild時には永遠に解決されないPromiseです。このPromiseがthrowされたら<Suspense>境界に対して動的レンダリングをマークしていくので、コンポジションにしてても<Suspense>境界単位が同一な<StaticComponents>は動的にレンダリングされるはずだと思います

なるほど、たしかにレンダリング時にPromiseをthrowする仕組みを考えると、コンポジションなどのコードベース上での依存関係というよりは、実際に解決されるツリーでSuspense内のものは動的になりそうな気がしますね!腑に落ちました!
ありがとうございました!

Route Segment Configを指定すると数ヶ月前には既にビルドでエラーになったはず

こちら、ちょうど試しててexport runtime = edgeとかでも怒られてました!ありがとうございます!