noah.plus

FirebaseとAMPでCDNをフル活用したWebサイトを作る(Google I/O 2018)

2018-08-04

Google I/O ‘18ですごいと思ったセッションを振り返る。(実際ほかの動画も全部すごい)

このセッションではタイトルの通り、FirebaseとAMPを駆使して高速なWebサイトを作る方法を紹介している。

セッションのまとめ

🚀イントロ

  • 何はともあれ表示速度が大事

  • ウェブサイトには2種類ある

CASE #1 メールクライアント

case1_EMAIL_CLIENT

  • エントリーポイントが1つ
  • 認証が必要
  • ユーザーが1日中アクセスし、絶え間なく更新される
  • インタラクティブ

CASE #2 コンテンツサイト

case2_CONTENT_SITE

  • 特定の記事へのディープリンク
  • 誰でもアクセス可能
  • 個々の記事ページは頻繁に更新されない
  • 読むことがメイン

「コンテンツサイト」の高速化が今回のトピック

  • 初期ページ読み込みの最適化
  • ネットワークのラウンドトリップを最小化
  • レンダリングをブロックするJavaScript / CSSを最小化
  • ネットワークのレイテンシを最小化

⚡️AMP(Accelerated Mobile Pages)

AMP HTMLは従来のHTMLと完全に異なるものではない。むしろ従来のHTMLから読み込みが遅くなる要素を徹底的に排除したものだと考えられる。

  • 制限を設けることで強制的に高速化
  • 検索エンジンやSNSでキャッシュ可能
  • AMPでコンテンツサイトの高速化が可能

🔥Firebase

Firebaseでコンテンツサイトを配信するとき、3つのアプローチが考えられる。

第3のアプローチとして紹介されたEvented Renderingでは、他のアプローチのデメリットを打ち消しつつメリットを最大化している。

1. Static Compilation

  • 静的サイトジェネレーターなどで事前にビルド
  • Firebase Hostingにアップロード
  • Firebase HostingのCDNからキャッシュを配信

メリット / デメリット

  • 😀リクエスト時の処理が不要

  • 😀キャッシュが極めて効率的

  • 😩頻繁な更新には不向き

  • 😩エンジニア以外には難しい

2. Dynamic Rendering

  • Cloud Firestoreでデータを管理

  • Cloud FunctionsでCloud Firestoreのデータをもとにサーバサイドレンダリング

  • Firebase HostingでリクエストをCloud Functionsにプロキシ

  • Firebase HostingはSSRの結果をキャッシュ可能

  • SSR時にamp-toolboxを使えばAMPが行う最適化を自分のサーバ上でも行え、CDNのキャッシュからの表示がさらに高速に(しかしvalidなAMPではなくなる)

    • 最適化せずにCDNから配信するときよりもFMP(First Meaningful Paint)を1秒短縮(3G通信時)
    • 検索エンジンやSNSが配信するAMPのキャッシュよりも高速!

メリット / デメリット

  • 😀最新のコンテンツを即座に提供できる
  • 😀馴染みのあるアーキテクチャ
  • 😩非効率 / コンピュートパワーを多く消費する
  • 😩キャッシュされていないコンテンツは表示が遅い
  • 😩キャッシュをする場合、その後に更新されたコンテンツとの差が生じる(キャッシュが捨てられるまで)

3. Evented Rendering

evented_rendering

  • Static CompilationとDynamic Renderingのいいとこ取り
  • できるかぎりCDNのキャッシュ使って配信するので非常に高速
  • Cloud Firestore、Cloud Functions、Firebase Hosting、そしてCloud Storageを利用する
  • Dynamic Renderingの結果をあらかじめ静的ファイルとしてCloud Storageに書き出し、Firebase HostingのCDNからキャッシュを配信する
  • Firestoreのデータが変更されたときには再び静的ファイルを生成し、配信中のキャッシュを破棄する

メリット / デメリット

  • 😀最新のコンテンツを即座に提供できる
  • 😀レンダリングコストは最初の1回のみ
  • 😀コンテンツが編集されるまでずっとCDNのキャッシュが有効
  • 😩現状では実験的な試み

Evented Renderingのデモとして、最寄りの脱出ゲームができる場所を探せるサイトが紹介された。このデモサイトでは、キャッシュ有効時にはわずか10msでドキュメントを読み込んでおり、まさにBlazing Fastだった。

DEMOコードを読む

デモに使われたサイトのコードが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を使った場合は何にでも応用できそう。

非常にトリッキーな手法ではあるが、とても面白かった。


noah.plus