Astro route cachingについてのメモ#2

Astro route cachingについてのメモ#2
個人ブログ Astro キャッシュ SSR パフォーマンス

Astro route caching(Experimental)についてのメモ#2です。 メモ#1の続きになるので、まだ読まれていない方はこちらをどうぞ。

前回のおさらいと導入

前回は、route cachingという機能の先出しと、その前提になるAstroのレンダリングモデルを整理しました。要点だけまとめると以下の通りです。

  • Astroには、Prerender(SSG)だけでなく、リクエスト時にビルドなしで動的更新するためのOn-demand Render(SSR)という仕組みがあります
  • Prerenderは事前生成(= CDNなどへ配信ファイルを”キャッシュ”)しておきますが、On-demand Renderはデフォルトだとキャッシュされず、毎回リクエスト時に更新になります
  • リアルタイム性が絶対的に必要ならこのままでもよいかもしれません。しかし、多少のラグを許容してキャッシュを保持した方がユーザー体験がよくなるケースも考えられます
  • ただし、以前Astroプロジェクトでは、PaaSやCDNなどのキャッシュ機能を自分で活用するしかありませんでした

この「SSRで返すレスポンスを、プラットフォーム依存の機能に寄せすぎずにどう扱うか」という悩みに対して、Astroが共通APIとして整備し始めたのがroute cachingです。

今回は前置きはここまでにして、次の2点に絞って見ていきます。

  • route cachingがいつ、どのような問題意識で提起されたか
  • cache.set() をはじめ、どのようなAPIとして設計されているか

使いどころや、どんなユースケースを想定しているのかは次のメモ#3に回します。

route caching機能の提起〜導入まで

Discussion #181 (2022年〜)

route cachingに直接つながる議論を辿ると、かなり早い段階では2022年4月26日のDiscussion#181まで遡れます。この時点でのテーマは「SSR向けのキャッシュヘッダーは分かりづらいので、Astro側で高レベルなAPIを用意できないか」というものでした。提案内容もAstro.cache()maxAgerevalidateprivateなどを宣言する、どちらかと言うとheader helperに近い色味が強かったように見えます。

ただ、この初期のDiscussionを読むと、単なるヘッダー補助だけでは収まらない雰囲気も見えてきます。返信では「それだと実際にサーバー側でキャッシュしてくれるのかと誤解しやすい」という反応があり、Next.jsなどとの機能比較も出ていました。まだ後のroute cachingのようにinvalidationprovider、Nodeのランタイム内キャッシュまで明確ではないものの、「ヘッダーを楽に書けるだけでは足りないのでは」という空気はすでにあったように感じます。

Discussion #1131 / Issue #1140(2025年〜)

大きく方向性が固まったのが、2025年2月28日のDiscussion#1131と、同年3月19日のStage 2 proposalであるissue#1140です。ここでは要約として、platform-agnosticなroute caching APIをAstro SSR pagesに導入し、ルートごとの宣言的な設定、middlewareやAPI handlersからの上書き、Node.js / Vercel / Netlify / Cloudflareをまたいだ一貫したDX、path/tagによるinvalidationまで含めた構想が示されています。2022年のheader helper的な議論から、一気に包括的なキャッシュ機能へ昇華したことが分かります。

さらにこの段階ではnon-goalsもかなり明確に書かれていて、route cachingが何でもかんでもを対象にする機能ではないことも早い時点で線引きされています。スコープとして意識的に切り分けられているのは次のあたりです。

  • 対象はあくまでOn-demand Rendering(SSR)のルートであり、Prerender済みページは対象外
  • ページ断片やコンポーネント単位の部分キャッシュではなく、基本的にはルートレスポンス単位のキャッシュ
  • 主眼はCDNやサーバー側のキャッシュ制御であり、ブラウザキャッシュそのものを全面的に置き換えるものではない
  • Node.jsでの分散キャッシュまでを最初から解決するわけではない

つまりStage 2の時点で、Astroがやろうとしているのは「SSRを使うときに毎回ゼロからキャッシュ戦略を書かなくてよくする」ための仕組みであり、同時に「どこまでをroute cachingの責務にして、どこから先をCDNやホスティングサービスに委ねるか」を明確にしようとしていたことが読み取れます。SSGを否定するのではなく、SSGではカバーしきれない更新性の領域にだけ、明示的な足し算としてキャッシュ制御を持ち込む形です。

RoadmapへのPR #1245 (2025年〜)

その後、この構想は2025年10月15日のStage 3 proposalであるPR#1245でさらに具体化されています。この段階では、Astro.cache()のようなcallableな形ではなくAstro.cache.set() / Astro.cache.invalidate()を持つオブジェクトとして整理されたり、複数回のcache.set()のマージ挙動、cache.set(false)による明示的な無効化、providerの整理、configとroute codeの優先順位など、実際に使うときのディテールがかなり詰められています。Stage 2で大きな方向性を固め、Stage 3でAPIの細部と責務分界を磨いた、という流れで見るのが自然そうです。

最終的にこの提案はAstro本体の#15579で実装に進み、Astro v6.0.0でExperimentalとして入りました。公式のAstro v6リリース記事でも、「SSRレスポンスのキャッシュはホストごとにやり方が違いすぎるので、Astroから単一のAPIで扱えるようにする」という位置づけで紹介されていました。

なお、ここまでの議論にはproposal段階とRFC段階の内容が混在していますが、実際にAstro v6で使えるExperimental APIとは細部が異なる部分があります。使うときは最新の公式ドキュメントも合わせて確認した方が安全です。

route cachingがどのように設計されているか

実際の設計を見ると、route cachingは「どこでキャッシュを処理するか」と「ルート側で何を宣言するか」を分離しているのが特徴です。

まず有効化はastro.config.mjs側で行います。ここではキャッシュプロバイダを設定します。

import { defineConfig, memoryCache } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
  // ここでcacheを宣言的にEnableにできる
  experimental: {
    cache: {
      provider: memoryCache(),
    },
  },
});

キャッシュの実体をどこで処理するかというのは、上記のprovider部分で設定します。公式ドキュメントでは、プロバイダは大きく以下の2種類に分かれていて、Astroが最初からキャッシュの処理方法を固定しない設計です。

  • CDN側にヘッダーを渡してキャッシュさせるCDN provider
  • ランタイム側でレスポンスを保持するRuntime provider

つまり、ルートのコードはmaxAgetagsのような意図だけを宣言し、最終的にそれをヘッダーへ落とすのか、ランタイム内キャッシュとして扱うのかはプロバイダ側に委ねる構造になっています。この抽象化が「platform-agnostic1」と言っている部分です。

その上で、各ルートでは.astroページならAstro.cache、API routesやmiddlewareならcontext.cacheを使います。最も基本的な例はこんな感じです。

---
// Prerenderingを無効化するため
export const prerender = false;

// ルート単位でキャッシュの内容を設定する(route caching)
Astro.cache.set({
  maxAge: 120,
  swr: 60,
  tags: ['home'],
});
---

<html>
  <body>Cached page</body>
</html>

このcache.set()が受け取る主なオプションは次の通りです。

  • maxAge: freshとして扱う秒数
  • swr: stale-while-revalidateの秒数
  • tags: 後でピンポイントに無効化するためのタグ
  • lastModified: 更新日時ベースの条件付きリクエスト向け情報
  • etag: エンティティタグ

ここで個人的に理解しやすいと思うのは、Astroが独自概念を増やしすぎず、max-agestale-while-revalidateのようなHTTPキャッシュの語彙に寄せているところです。Astro専用の謎パラメータを覚えるというより、既存のキャッシュの考え方をそのまま持ち込んでいる気がします。

さらに、明示的にキャッシュさせないためのcache.set(false)もあります。これは、設定ファイル側で広くrouteRulesをかけつつ、一部のルートだけ除外したい場合に役立てることができます。たとえばパーソナライズされたダッシュボードやCMS連携しているブログプロジェクトのプレビュー閲覧用ページなどで、使用できるのではないかと思います。

設定ファイルでデフォルトのキャッシュルールと設定できるexperimental.routeRulesも、かなり重要な設計要素です。

import { defineConfig, memoryCache } from 'astro/config';

export default defineConfig({
  experimental: {
    cache: {
      provider: memoryCache(),
    },
    routeRules: {
      '/api/*': { swr: 600 },
      '/products/*': { maxAge: 3600, tags: ['products'] },
      '/blog/[...slug]': { maxAge: 300, swr: 60 },
    },
  },
});

このrouteRulesと各ルート内のcache.set()は競合したらエラーになったり機能しないのではなくマージされます。公式ドキュメントでは、複数回のcache.set()も含めて次のように整理されています。

  • maxAgeswrは、configファイルで共通設定しつつ、ルート側で必要に応じて上書きできる
  • lastModifiedは、複数箇所で設定された場合に「より新しい更新日時」が使われる
  • tagsは上書きではなく、指定したものが追加されていく

このルールのおかげで、middleware、page本体、設定ファイルがそれぞれ少しずつ責務分担しやすくなっています。全部を1箇所に押し込まなくてよいのは、Astroのレイヤー構造と相性が良さそうです。

無効化APIも同じcacheオブジェクトに寄せられていて、タグまたはパスでinvalidateできます。

export async function POST(context) {
  await context.cache.invalidate({ tags: ['data'] });
  await context.cache.invalidate({ path: '/api/data' });

  return Response.json({ purged: true });
}

cache.set()で付けたtagscache.invalidate()の責務が同じオブジェクトにまとまっているので、読む側としても「このリクエストのキャッシュポリシーはここを見る」という見通しが立ちやすいです。

まとめ

色々複雑な情報を並べてきましたが、今回のメモのまとめとしては、以下を抑えればいいかなと思います。

  • SSRにおけるキャッシュの議論は2022〜始まっている
  • 当初は、header helper、2025年〜の議論では、包括的なroute cache機能に昇華し、Stage 2→3へとPassしている
  • Experimentalで導入される前に、キャッシュの思想〜APIのデザインまで様々な議論がなされている
  • 現状の導入状況(結論)としては、プラットフォームに深く依存しないで済むように最低限の抽象化されたAPIを提供している
  • 主にastro.configファイルで宣言し、各.astroファイルやAPIルートで細かく制御・上書きされる設計
  • 設定に使用する値もHTTPキャッシュで使用されるような概念・単語とほぼ等価なので、理解しやすい

現状はExperimentalとある通り、まだ実験段階であり、プラットフォームごとのバグや公式がリリースしているアダプタ、Live content collectionsとの連携など、色々探り探り進めている段階かと思います。使用する概念も便利ながらも未知の固有概念ではなく、ブラウザAPIの必要最低限の抽象化に感じるのはAstroっぽい色があって個人的ないいなと思います。

中締め

ここまでで、route cachingが「なぜ必要とされたか」と「どんなAPIとして切られているか」はだいぶ整理できたかなと思います。まとめるとSSRのキャッシュをAstro流に無理なく扱えるようにした、という印象ですかね〜。

次のメモ#3では、ここで見たAPIが実際にどんな用途を想定しているのか、逆にどんなケースでは使わない方がよさそうかを掘っていこうと思います。

参考資料

Footnotes

  1. アプリコードから見たキャッシュAPIをプラットフォーム非依存にする、という意味です。

記事を共有する