コンテンツにスキップ

レンダリングキャッシュ

ハンドラがデータを手に入れた後も、それを HTML に変える分の代金は残ります。コンポーネントは 行を辿り、フィールドをすべてエスケープし、前の訪問者に書いたのと同じバイト列をもう一度書き ます。カタログページ1枚なら処理量はわずかです。しかし、高負荷時にリクエストごとに繰り返されると、 わずかではなくなります。

@cache はそのバイト列を保存して、次から再生します。

@cache(ttl: "5m", scope: "public")
component ProductGrid(rows: Product[]): html { … }

手を出す前に、これが買わないものを知っておいてください。ヒットが飛ばすのは、そのコンポー ネントがやることちょうどで、その上は何も飛ばしません。 ハンドラは ProductGrid を呼ぶ前に rows を計算し終えているので、問い合わせは毎リクエスト走り、再生されるのはマークアップだけ です。この書き方なら効くのはマークアップが高くつくとき —— 長い表、描画済みの記事、入れ子の リストへ展開される木 —— であって、データベース呼び出しが高くつくときではありません。 フィールドを4つ見出しに描くだけのコンポーネントは、やってもいない仕事と引き換えに、キーの 計算とバッファを手に入れます。

ただしこの境界は、アノテーションの性質ではなく仕事がどこに置かれているかの性質です。 取得をコンポーネントの内側へ移せば、同じヒットが取得も飛ばします。下の コンポーネント自身のロードをキャッシュする がそれです。

アノテーションのもう半分、この全体がどれだけ安全かを決める側は、書いても書かなくても既定で 効いています。

重い半分だけをキャッシュするページ

Section titled “重い半分だけをキャッシュするページ”

handlers/catalog.pw.html:

package handlers
type Product {
name: string
price: string
}
@cache(ttl: "5m", scope: "public")
component ProductGrid(rows: Product[]): html {
<ul>{for row in rows}<li>{row.name} — {row.price}</li>{/for}</ul>
}
export component Catalog(rows: Product[]): html {
<main><h1>Catalog</h1><ProductGrid rows={rows} /></main>
}

handlers/catalog.go:

package handlers
import (
"net/http"
"github.com/shibukawa/popcornweb/pw"
)
func ShowCatalog(w http.ResponseWriter, r *http.Request) {
rows, err := store.Products(r.Context())
if err != nil {
pw.WriteProblem(w, r, err)
return
}
pw.WriteHTML(w, r, Catalog(CatalogParams{Rows: rows}))
}

ハンドラはキャッシュに一言も触れていません。アノテーションは生成されたプランに畳み込まれ、 裏側のストアは既定でオンなので、5分以内の2度目のリクエストは ProductGrid を実行せずに 再生します。Catalog のほうは普通に描画され続けます。アノテーションが覆うのはそれを書いた コンポーネントであって、それが載っているページではありません。

キーはコンポーネントの識別子、生成されたプランの指紋、そして宣言されたすべてのパラメータから できています。テンプレートを書き換えれば指紋が変わるので、書き換えたマークアップが前のビルド の書いたエントリで答えられることはありません。パラメータを変えれば、それは古いヒットではなく 別のキーです。

ただし、このページが前段のキャッシュに何を伝えるかは変わっていません。ここでの scope: "public" が言っているのは エントリ が共有だということで、レスポンスのほうは 誰も別のことを宣言していないので private を名乗り続けます。2つが一緒に動くのは、宣言が ドキュメントシェルに載ったときだけです。次の節がそこを扱います。

コンポーネント自身のロードをキャッシュする

Section titled “コンポーネント自身のロードをキャッシュする”

行ではなく識別子を渡して、コンポーネント自身にロードさせます。

package handlers
external LoadProduct(id: string): Product
@cache(ttl: "5m", scope: "public")
component ProductCard(id: string): html {
{val product = LoadProduct(id)}
<article>
<h2>{product.name}</h2>
<p>{product.description}</p>
<p>{product.price}</p>
</article>
}

{val …} が結果に一度だけ名前を付けています。これが無ければ3つのフィールドがそれぞれ別の 呼び出しになります。束縛そのものは val にあります。

キーは id から計算され、保存されるバイト列は描画された部分木の全体です。したがって ヒットはマークアップだけでなく LoadProduct も飛ばします。アノテーションがデータを キャッシュできるようになったからではなく、ロードがコンポーネントのやることの一部になり、 再生されたコンポーネントは何もしないからです。他に設定するものはありません。ここに2つ目の キャッシュは存在しません。

このような場合には、キャッシュを使う価値があります。マークアップだけをキャッシュして省けるのはエスケープ とバッファで、実在はしますが小さい。ロードを含む部分木をキャッシュすれば、リクエストを支配 していた往復が消えます。同じアノテーションを1階層動かしただけで、価値が桁で変わります。

この書き方ができるかどうかは、2つの条件が決めます。

ローダは同期の external である必要があり、同期 external にはエラー結果がありません。 値を返すか、ゼロ値を返すかで、参照が失敗したことをページに伝える手段がありません。空の答えが 意味を持つ読み取りには向き、失敗を読者に見せなければならない読み取りには向きません。 external async にしても救われません。async 呼び出しは await 境界を必要とし、保存する @cache は境界に到達するコンポーネントを拒否するからです。

ロードは描画をブロックします。 await 境界なら仕事の裏でフォールバックをストリームでき ましたが、これはしません。データが届いてからコンポーネントが描画されます。ミス時の初回描画 までの時間を、その後のヒットと引き換えにしたことになります。一覧ページのカードには正しい 取引で、誰も再訪しないページの主要コンテンツには誤った取引です。

どちらかの条件が満たせないなら、取得はハンドラに残して pw.Memo でキャッシュしてください。あちらのストアには こちらに無いものがあります。上流障害の間も答え続ける stale の窓と、エントリを間違いにした 書き込みのための明示的な無効化です。@cache が持つのは TTL だけなので、エントリは期限まで 間違ったままです。

scope"private""public" を取り、省略すると "private" です。

この既定はセキュリティ境界です。キャッシュを原因とする事故には、繰り返し現れる形があります。 たとえば /account という URL はログインした全員で同じですが、HTML には名前、注文履歴、権限 など、その人だけの値が入ります。この出力を public にすると、コンポーネントキャッシュが最初の 人のマークアップを次の人へ再生しえます。ドキュメントのレスポンスまで public なら、CDN や リバースプロキシが URL 単位で保存し、最初にキャッシュされたユーザー画面を後続の多くの人へ 見せることもあります。最初のリクエストでは認証に成功していても、その区別を飛び越えて再利用 するのが共有キャッシュだからです。

そこで Popcorn Web は、宣言を忘れたときの着地点を安全側に置きます。同じ宣言済みパラメータ なら、どの読み手へ渡しても安全だと確認できるまでは private のままにしてください。共通の 商品一覧、公開記事、共有アイコンなど、アカウント、テナント、認可結果、リクエストコンテキスト 由来の隠れた値を含まないコンポーネントだけを public へ昇格します。本当は共有できるものを private にしても、失うのはキャッシュヒットとメモリです。本当は個人用のものを public にすると、 その人の画面を漏らします。この2つは釣り合いません。

private なコンポーネントのキーには、描画した相手の識別子が前置されます。2人の読み手が同じ エントリに届くことはありません。その値は pw.RequestAuthentication(r).Subject —— セッションログインもパスキーもベアラトークンも、ハンドラが走る前に収束する1つのローカル アカウント識別子です。だから同じ人のエントリは、どうログインしたかに関わらず同じ人のもので あり続けますし、ログイン方法を1つ増やしても既存のエントリは分割されません。回転する セッショントークンでないのは意図的です。

匿名リクエストはその識別子を持ちません。識別子のないまま描画された private なコンポーネント は、何も保存しません。これは設計というより退避です。空の識別子で書いたエントリは private の 札を下げた共有エントリであって、ミスのほうがまだましだからです。

同じ宣言が、前段のキャッシュに向けたレスポンスの答えも決めます。そちらは描画ではなく連鎖から 読まれます。読まれるしかありません。Cache-Control は本文の最初の1バイトより前にワイヤへ 出ますが、4階層下にいる読み手依存のコンポーネントが描画されるのはずっと後です。ヘッダそのもの はレスポンスにあります。

よく出てくる形は3つで、たいていはパラメータの並びが選んでくれます。

誰にとっても同じ出力になるコンポーネントは引数を2つとも書きます (@cache(ttl: "5m", scope: "public"))。public と書いてよいのは、出力が宣言された パラメータだけの関数になっているときだけです。ctx から読み手を読む Go 関数を呼んでいるなら、 パラメータはその出力を説明していません。そして生成は、呼び出しグラフからそれを見抜けません。

読み手ごとに変わるコンポーネントttl だけを書き、private を継承します。アカウント ごとのダッシュボード集計は元が取れる側です。描画が重く、読み手は有効期間のうちに戻ってきて、 そのエントリは利用者ごとに分かれます。ただし広く採用する前に、ストアの使用量を見積もってください。private なキーはエントリ数を実働の読み手の数だけ倍にしますが、上限はプロセス全体で1つの数字です。

何も保存しないがスコープは持つコンポーネントscope だけを書きます。ttl がなければ 保存もキー計算も起きないので、保存できない場所に置けます。レイアウト、ドキュメントシェル、 await するページ。

3つ目には、他の2つにはない役割があります。ドキュメントシェルに置いた @cache(scope: "public") は、マーケティングサイトが「このページは共有してよい」と共有キャッシュに伝える手段です。 1つ書けば下のすべてを覆います。

@cache(scope: "public")
export component Document(children: html?): html { … }

向きを逆にすると、静的解析が決して見つけられないものを述べる手段になります。ctx から リクエストの識別子を読む Go 関数を呼ぶコンポーネントは、ツールチェインのどちら側が書ける検査 から見ても共有に見えます。そこに @cache(scope: "private") を1行書くと、作者の知識が呼び出し グラフの運ぶ事実に変わり、上で主張された public を拒否します。

public の主張は外側からしか効きません。 ラッパーは自分より下のすべてを含むので、public 宣言がレスポンスを決めるのは連鎖のいちばん外側の要素にあるときだけです。宣言のないレイアウト の下で public を主張したページは private のままです。そのレイアウト自身のマークアップも レスポンスに入っており、そちらは誰も宣言していないからです。アノテーションはシェルに置いて ください。

private が常に勝ちます。 呼び出しグラフが private 宣言に届くコンポーネントへの @cache(scope: "public") は生成エラーになり、アノテーションの位置で、private を宣言した コンポーネントの名前を告げます。ただし pw.WriteHTMLChain で実行時に組み立てた連鎖は、 どの呼び出しグラフにも現れないので生成は拒否できません。その場合もレスポンスは private に なり、どのコンポーネントがそう決めたかがログに残ります。

WARN chain declaring public rendered private declared_by=pages/account.pw.html:PlanSummary

誰も当てないキャッシュにも代金はかかります。 呼び出しごとにパラメータが変わるコンポー ネントは、キーを計算し、バッファへ描画し、誰も読まないエントリを保存します。レスポンスを見て も何もおかしくありません。だから描画スパンが両側を運びます。pw.render.cache_hitspw.render.cache_misses で、トレーシングにあります。 訪問者の多くがログアウト状態で見るページの private なコンポーネントも同じ形に見えますが、 そちらの理由は「匿名の描画は何も保存しない」ことです。

そもそも保存できないコンポーネントがあります。 保存したバイト列が新しい描画の代わりに ならないものには、保存する形の @cache を置けません。htmlasync のパラメータ、下に ある await 境界、ドキュメントの head、危険な <form>、provider 由来の builtin 要素。 どれも実行時ではなく生成エラーなので、pw generate が宣言の位置とともに報告します。原因が アノテーションを書いたコンポーネント自身でないときは、そちらの名前も告げます。一覧は @cache にあります。

切るとき、大きさを決めるとき

Section titled “切るとき、大きさを決めるとき”
[html]
[html.cache]
enabled = true
max_entries = 1024

ストアはプロセス内にあり、既定でオンです。オプトインの役はアノテーションが果たしているので —— 1つも書かないプロジェクトはストアに届きません —— この鍵はアノテーションを有効にする スイッチではありません。enabled = false は、古い領域を疑ったオペレータが再ビルドせずに その疑いへ答えを出すための切り替えです。

max_entries は、何かを private にした時点で見直す価値があります。既定値はパラメータの 組ごとに1エントリを持つキーを前提にした数です。private なキーはパラメータの組ごと、 かつ読み手ごとに1エントリを持ちますし、追い出しはおおよその挿入順なので、共有前提で 決めた上限は読み手ごとのコンポーネントが1つ入っただけで溢れ始めます。

再描画はページ自身のオプションで描画して同じストアに届くので、ページ上でキャッシュされた コンポーネントは、それを置き換えるレスポンスでもキャッシュされたままです。

アノテーションの完全な仕様は @cache、上の設定鍵と 既定値はアプリケーション設定一覧にあります。