2018-08-04
Google I/O ‘18ですごいと思ったセッションを振り返る。(実際ほかの動画も全部すごい)
このセッションではタイトルの通り、FirebaseとAMPを駆使して高速なWebサイトを作る方法を紹介している。
何はともあれ表示速度が大事
ウェブサイトには2種類ある
CASE #1 メールクライアント
CASE #2 コンテンツサイト
「コンテンツサイト」の高速化が今回のトピック
AMP HTMLは従来のHTMLと完全に異なるものではない。むしろ従来のHTMLから読み込みが遅くなる要素を徹底的に排除したものだと考えられる。
Firebaseでコンテンツサイトを配信するとき、3つのアプローチが考えられる。
第3のアプローチとして紹介されたEvented Renderingでは、他のアプローチのデメリットを打ち消しつつメリットを最大化している。
メリット / デメリット
😀リクエスト時の処理が不要
😀キャッシュが極めて効率的
😩頻繁な更新には不向き
😩エンジニア以外には難しい
Cloud Firestoreでデータを管理
Cloud FunctionsでCloud Firestoreのデータをもとにサーバサイドレンダリング
Firebase HostingでリクエストをCloud Functionsにプロキシ
Firebase HostingはSSRの結果をキャッシュ可能
SSR時にamp-toolboxを使えばAMPが行う最適化を自分のサーバ上でも行え、CDNのキャッシュからの表示がさらに高速に(しかしvalidなAMPではなくなる)
メリット / デメリット
メリット / デメリット
Evented Renderingのデモとして、最寄りの脱出ゲームができる場所を探せるサイトが紹介された。このデモサイトでは、キャッシュ有効時にはわずか10msでドキュメントを読み込んでおり、まさにBlazing Fastだった。
デモに使われたサイトのコードがGitHubに上がっているので、ポイントごとにみていく。
GitHub: escapable-amp
// firebase.json
{
// 省略...
"hosting": {
"rewrites": [{ "source": "**", "function": "app" }],
},
// 省略...
}
ユーザーからのリクエストをFirebase HostingからCloud Functionsにプロキシ。
リクエストはapp
に渡される。app
はExpress.jsで構築されており、レンダリングやルーティング、amp-toolboxを利用した最適化などが行われている。
// functions/src/index.ts
import * as functions from "firebase-functions";
import app from "./app"; // 中身はExpress.js
import { updateRegionPage } from "./materialize";
exports.app = functions.https.onRequest(app);
// Firestoreでの変更があったらページを再生成
exports.onRegionChange = functions.firestore
.document("regions/{id}")
.onWrite((change, context) => updateRegionPage(context.params.id));
Firesotreのデータが変更された場合にはupdateRegionPage()
が呼び出される。
// functions/src/materialize.ts
export async function updateRegionPage(rid: string): Promise<any> {
const amp = RegionPage(await regionPageData(rid));
const optimized = await optimize(amp, `/amp/${rid}`);
await Promise.all([
writeAndPurge(`amp/${rid}`, amp),
writeAndPurge(`${rid}`, optimized)
]);
}
regionPageData()
: 脱出ゲームができる地域のデータを取得
RegionPage
: 地域の一覧ページを生成(AMP化済み)
optimize()
: amp-toolboxが提供するメソッドでAMPをさらに高速化
writeAndPurge()
: ページの書き出しとキャッシュの破棄
// functions/src/materialize.ts
async function writeAndPurge(path: string, data: string): Promise<any> {
const objectPath = path + ".html";
// 保存
await storage
.bucket()
.file(path + ".html")
.save(data, {
gzip: true,
metadata: {
contentType: "text/html; charset=utf-8",
cacheControl: "max-age=300, s-maxage=31536000"
}
});
// キャッシュ破棄
const purgeUrl = `${ORIGIN}/${path}`;
await request(purgeUrl, { method: "PURGE" });
return;
}
Cloud Storageのオブジェクトstorage
を使って、生成したページを静的ファイルとして保存している。
キャッシュの保存期間はs-maxage=31536000
となっており、CDN側のキャッシュが31536000秒間保存されることになっている。この数字に特に意味があるわけではなく、とにかく大きい数字を入れているだけ。キャッシュの破棄は必要なときだけ(データが変更された時だけ)手動でやるので、CDN側で勝手にキャッシュが破棄されないようにする。
Firebase Hostingではmethod: "PURGE"
をつけてリクエストを送ると、キャッシュが破棄されるようになっている。これは公式なAPIではないので近い将来変更されるかもとのこと。
functions/src/app.ts
では、Express.jsを使ってルーティングをしている。
// functions/src/app.ts
app.get("/amp/:region", bucketProxy);
app.get("/:region", bucketProxy);
それぞれのルートへのリクエストに対し、bucketProxy
でCloud Storageとの連携をしている。
// functions/src/app.ts
async function bucketProxy(req: express.Request, res: express.Response) {
const file = storage.bucket().file(req.path + ".html");
const rs = file.createReadStream();
res.set({
"Cache-Control": "max-age=300, s-maxage=31536000", // とにかく大きい数字
"Content-Type": "text/html; charset=utf-8"
});
rs.pipe(res);
rs.on("error", err => {
res.status(404).send('Not Found');
});
}
Cloud Storageのオブジェクトstorage
を使ってファイルを指定し、node.jsのcreateReadStream()
からファイルの読み込みを始めている。
Firebase Hosting、Cloud Functions、Cloud Firestore、Cloud Storageが一挙に集結していて、オールスター感がすごい。
AMP化したHTMLファイルをCloud Storageを使ってサーブするという発想は、目からウロコだった。
Evented Renderingの考え方としては、ContentfulとかのHeadless CMSでデータ管理しつつ、データに変更があればWebhookで再ビルドするという流れに近いと思った。
しかし、このデモでは一連の流れがFirebaseの中で完結しているのがすごい。Contentfulなどでデータを管理すると基本的に管理者側でしかいじれないが、Firestoreを使った場合は何にでも応用できそう。
非常にトリッキーな手法ではあるが、とても面白かった。