最適化はCDNがやればいい
題名に「CDN」と書きましたが、いわゆる「エッジ」のことです。オリジンありきなのであえてCDNと呼びました。とはいえ、オリジン自身がエッジにある可能性もあります。
メタフレームワークを作る
Sonikというメタフレームワークを作っています。まだDevステージなんでどんなことができるか可能性を探っている最中です。
このフレームワークの特徴はとにかくエッジファーストです。
- SSRしたバンドルの大きさを極力小さくする。
- Islandsアーキテクチャを採用する。
- DenoのFreshを参考にする。
- CloudflareのBindingsを扱いやすくする。
- Honoの上に乗せる。
- とりあえずStreamingはサポートしない。
で、こういうのを作っていると、フレームワークは最小限にして、Core Web Vitalsのスコアを上げるために身を削る最後の部分はCDNに任せてしまった方がいいってことです。Sonikはどこでも動かせそうですが、ひとまずCloudflare Workers上で動かすのを主目的としています。「それってエッジってかCDNじゃん」って感じで確かにそうなんですが、フレームワークとは別の機構としてエッジファンクションを挟む感じがいいかなと。
つまり、全てエッジに乗るんですが、その中でもレイヤリングするんですね。例えばユーザーから近い順にするとこう。
- コンテンツ圧縮
- HTMLRewrite(後述)
- アプリ、フレームワーク
こうするとフレームワークは最低限の機能実装に集中できる。当然コードも少なくなる。
チューニングという課題
Sonikはまだ発展途上なんで、チューニングはしていないです。ただ、どれだけパフォーマンスがでるかどうかは把握しながらやりたい。最近の課題はJavaScriptのChaining Requestsで、client.js
がCounter.js
を必要としていたらそれが順番に読み込まれちゃうってやつです。
これをなくす一つの方法はリソースヒントで各アセットのpreload
なりをページに付与すればよい。ただ、問題は現状だとSSRの時点で「自分がどのJavaScriptを必要としているか?」が分からないんですね。そうするとフレームワークレベルでやるのが難しい。できるとしてもそこに実装の時間を割きたくはありません。そこでどうせCloudflare WorkersでやるんだったらHTMLRewriterを使ってみようということです。
HTMLRewriterの威力
CloudflareにはHTMLRewriterという面白いAPIがありまして、実はBunにも実装されてるし、同じ機能を提供するRustやWasmもあります。これはHTMLをいわばjQueryライクに捜索して、書き換えてしまおうというものです。
例えば、リンクの書き換えなんかはこう書けます。
app.get('/pages/*', async (c) => {
const OLD_URL = 'oldhost'
const NEW_URL = 'newhost'
class AttributeRewriter {
constructor(attributeName) {
this.attributeName = attributeName
}
element(element) {
const attribute = element.getAttribute(this.attributeName)
if (attribute) {
element.setAttribute(this.attributeName,
attribute.replace(OLD_URL, NEW_URL))
}
}
}
const rewriter = new HTMLRewriter().on('a', new AttributeRewriter('href'))
const res = await fetch(c.req.raw)
const contentType = res.headers.get('Content-Type')
if (contentType.startsWith('text/html')) {
return rewriter.transform(res)
} else {
return res
}
})
これがエッジというかCDNでできちゃうという。今回のメタフレームワークの件に関わらず便利です。CDNにCloudflareを挟んで、このWorkerをデプロイしておけば、オリジンのコードを変えずにリンクのお引越しができます。他にもバルクリダイレクトとかi18nなんかにも使えます。
機能のレイヤリング
上記のコードをみてお気づきの方もいるかもですが、Honoのミドルウェアになっています。Honoを支えるメンタルモデルの一つがこのミドルウェア構造で、Cloudflare WorkersだとService Bindingsを使えばできますが、ひとつのアプリ内で、機能のレイヤリングができます。
書き方としてはこうなります。
// match any method, all routes
app.use('*', logger())
// specify path
app.use('/posts/*', cors())
// specify method and path
app.post('/posts/*', basicAuth())
フレームワークをHonoで構築する
実はSonikはHonoのアプリです。具体的にはViteを使ってバンドルをするのですが、その手前で、Vite固有のimport.meta.glob
というめちゃくちゃ便利なメソッドを使ってファイルを読み込み、ファイルの場所をベースにパスを作りHonoのインスタンスに登録していきます。いわゆるファイルベースルーティングが簡単にできます。こんなコードがあります。
Object.keys(this.FILES).map((filePath) => {
const path = filePathToPath(filePath, this.root)
const fileDefault = this.FILES[filePath].default
if (typeof fileDefault === 'function') {
app.get(path, (c) => {
const res = h(() => fileDefault(c), {})
return this.toWebResponse(c, res)
})
}
})
フレームワークをHonoの上に乗せるとルーティングやハンドリングをHonoのそれが使えるだけではなく、ミドルウェアが使えるという利点があります。Sonikのアプリを作るにはcreateApp()
を使うのですが、それにベースとなるHonoのインスタンスを渡せるし、返ってきたものもHonoのインスタンスです。
なので、簡単にキャッシュ機構を足すことができます。
import { Hono } from 'hono'
import { createApp } from 'sonik'
const base = new Hono()
base.get(
'*',
cache({
cacheName: 'sonik-example-basic',
cacheControl: 'max-age=3600',
})
)
const app = createApp({ app: base })
app.get('/static/*', serveStatic({ root: './' }))
app.showRoutes()
export default app
Chaining Requestsをなくす
さてこれらを組み合わせて課題だったChaining Requestsをなくしてみましょう。
Viteにはmanifest.json
という出力したファイルとその依存を書いたものを出力する機能があるのですが、それを利用します。
SSRしたHTMLにはロードすべきIslandのコンポーネント名がcomponent-name
という属性値に入っています。このコンポーネントのJavaScriptを初期ロードしたいわけですね。で、アプリがSSRした時点では「このページはこのコンポーネントを持ってる」ことが分からない。
<div component-name="Counter.tsx" data-serialized-props='{"__done":{"K":"l","v":true}}'>
<p>Counter: 0</p>
<button>Increment</button>
</div>
そこで、まずHTMLをSSRしてしまってそれをHTMLRewriterで捜索します。属性がcomonent-name
の値を取得すれば、そのページで使っているコンポーネント名が取得できます。上記で言うCounter.tsx
ですね。で、こいつを予め読み込んでいたmanifest.json
と照らし合わせる。すると依存しているJavaScriptがわかるんです。
{
"app/islands/Counter.tsx": {
"file": "Counter-90f0df36.js",
"imports": [
"node_modules/preact/dist/preact.module.js",
"node_modules/@preact/signals/dist/signals.module.js"
],
"isDynamicEntry": true,
"src": "app/islands/Counter.tsx"
},
"node_modules/@preact/signals/dist/signals.module.js": {
"file": "signals.module-0aed4f04.js",
"imports": [
"node_modules/preact/dist/preact.module.js"
],
"isDynamicEntry": true,
"src": "node_modules/@preact/signals/dist/signals.module.js"
},
"node_modules/preact/dist/preact.module.js": {
"file": "preact.module-f7b8d050.js",
"isDynamicEntry": true,
"src": "node_modules/preact/dist/preact.module.js"
}
}
これによると、呼べばいいファイルは以下の3つになります。
Counter-90f0df36.js
signals.module-0aed4f04.js
preact.module-f7b8d050.js
ここまで分かればお手の物。HTMLのhead
タグ内に挿入してもいいですが、より簡便な方法として同等の効果が得られるLink
ヘッダを吐きましょう。
preloads.forEach((path) => {
c.res.headers.append('Link', `</static/${path}>; rel=modulepreload; as=script`)
})
結果は?
いい感じです!これでフレームワークを書き換えることなく、Chaining Requestsがなくなりました!
最適化はCDNがやればいい
で、何が言いたいかと言いますと「最適化はCDNがやればいい」ということです。今回のリソースヒントの他にも以下のようなユースケースが浮かびます。
- Hero画像のpreload
- 圧縮
- minify
- mangle
- キャッシュ
- ISR的なもの
- サードパーティリソースの埋め込み
- Signed Exchanges
- Micro frontend??
これらをフレームワークで頑張る世界観だったのですがだいぶチェンジングです。
最近のエッジというとそこでフレームワークが動く、KV、データベースが動くといったキラキラしたトピックに目が行きがちですが、「最適化はCDNがやればいい」という視点こそがまず我々が注目すべきエッジのユースケースだと思います。
その他
Smart Hints
まあどうしてもCloudflareの話題になりますが、Cloudflareでは上記を叶えるためのソリューションがすでにいくつかあります。まさに今週は「Speed Week」で以下のSmart Hintsというリソース配信を最適化する機能の発表がありましたので、見てみてください。
Cloudflare Zaraz
これもまたCloudflareのプロダクトですが、Zarazというのがあります。こいつがすごくて、Google AnalyticsやGoogle Adsなどのサードパーティのタグをページに貼らずにCDNで発火させるものです。「ツールを追加する」を押すといろんなタグが出てきます。
Google Analyticsの設置なんて速攻終わりました。それでいてページで実行されない。無料で試せるので、みんなやればいいと思います。
アクセスログ
そうそう、これは以前Fastlyを運用していた時の話なんですが、FastlyのCDNを入れて嬉しかったことのひとつに「アクセスログ」ってのがありました。Datadogに突っ込んでたんですが、CDNだと当然ですが、あるドメイン全てのアクセスを一気に取れて一発で見れます。これがなにげに今までできていなかった。悪質なボットのアクセスや、キャッシュがうまくついてるかなどすぐ分かって便利でした。
500点出すために
フロントエンドのチューニング大会で500点出すためにCloudflare KVを使ったキャッシュを奥の手として使いました。
前書いた記事
なんか似たようなことを以前にも書いてます。
イベント
AWS Dev Day 2023 Tokyo
木曜日にこれに関わる話で「実践エッジユースケース」という題名でAWS Dev Dayで話します。
Cloudflare Workersのイベント
Cloudflare Workersに特化したイベントをやるんで来てください!トークがめちゃくちゃ豪華です。
Discussion