👨‍💼

SPAでも動的OGP対応したい! with Cloudflare Workers, KV

2020/12/20に公開

Cloudflare には CDN 上で処理を行える Edge Worker という機能があります。また、key-value store が使える KV という機能もあります。これらを組み合わせて、動的かつそこそこ高速に SPA サイトの OGP を切り替えられるのではないかと思ってやってみました(ここで言う高速とは、Edge Worker から例えば firestore などの Cloudflare から見て外部にデータを取りに行くよりは速いはずという意味です)。

流れ

SPA サイトの /users/moga を Twitter シェアしたときに、moga の名前やプロフィールを OGP として出したいとします。以下の図のような流れで meta を書き換えて、動的に OGP が埋め込まれるようにします。

今回、KV のデータ更新は簡単のために、Firebase Firestore にデータが書き込まれたときに Functions を起動して更新するようにします。それに合わせて HostingService は Firebase Hosting を使います。

Web フロント作る

Cloudflare Workers から取得して meta タグを書き換えるために、どこか適当な Hosting サービスに index.html を置きます。ボクは使い慣れてるので Firebase Hosting に React をデプロイして使いました。OGP の meta タグだけ、後で置換しやすいように以下のようにしておきます。Production ならもっとちゃんとやってくださいね…。

<meta property="og:title" content="PLEASE_REPLACE_TITLE" />
<meta property="og:description" content="PLEASE_REPLACE_DESCRIPTION" />
<meta property="og:image" content="PLEASE_REPLACE_IMAGE_URL" />

KV にデータを突っ込む

REST APIがあるのでそれを叩いてデータを書き込みます。以下のデータが必要になります。

  • account_identifier
    • Cloudflare コンソールのドメインのトップの右側のサイドメニューみたいなところの下の方に Account ID があります
  • namespace_identifier
    • Cloudflare コンソールの Workers KV から Namespace を作成すると見れます

curl だとこういう感じでデータを書き込めます。

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/xxxx/storage/kv/namespaces/xxxx/values/sample-key" \
     -H "X-Auth-Email: user@example.com" \
     -H "X-Auth-Key: xxxx" \
     -H "Content-Type: text/plain" \
     --data '"mogamogamoga"'

書き込んだデータは Cloudflare コンソールから確認することもできます 👇

書き込まれた様子

Firestore の onCreate をトリガーに KV へデータを書き込むようにしてみましょう。

import Axios from 'axios'
import * as functions from 'firebase-functions'

export const onUserWrite = functions
  .region('asia-northeast1')
  .firestore.document('/users/{userID}')
  .onWrite(async (change, context) => {
    // Webページ側のpathをkeyにします。Cloudflare Workers内でこのkeyの値を使います。
    const key = encodeURIComponent(`/users/${context.params.userID}`)
    const data = change.after.data()!
    const value = {
      // OGPで表示したいtitleとdescriptionを設定します。今回はそのまま使う。
      title: data.title,
      description: data.description,
      imageURL: data.imageURL,
    }
    await Axios.put(`https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/storage/kv/namespaces/NAMESPACE_ID/values/${key}`, value, {
      headers: {
        'Content-Type': 'application/json',
        'X-Auth-Key': 'YOUR_KEY',
        'X-Auth-Email': 'YOUR_EMAIL',
      },
    })
  })

Firestore の/users/{userID}にデータが書き込まれると Cloudflare の KV のデータが更新される仕組みです。

Worker を作る

ここから Workers を選択します 👇

そして、Manager Workers を選択します 👇(あとでこの下のAdd routeを設定するのでこの画面を覚えておいてください)。

ここから Create a Worker をおすと Worker を作成できます。エディタが開くのでひとまずSave and Deployします。

この Worker を起動するための Route の設定をします。先程 Manage Workers を押した画面のAdd routeを選択します。すると 👇 のようなポップアップが出てくるので、Worker を起動したい Route の設定を書いて、Worker を選択して Save します。

作成した Worker から KV が見えるようにするための設定をします。作成した Worker を選択して Settings タブから KV Namespace Bingings の Add binging を選択します。今回は 👇 のように設定しました。ここで設定した Variable name は Worker のコードから参照することになります。

Worker の処理を書く

今回は以下のコードにします。めちゃくちゃサボった形にしているのは許してください。HTML は置換しやすいように meta タグを設定しており、そこを KV から取得してきた値で置き換えています。

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

/**
 * Respond to the request
 * @param {Request} request
 */
async function handleRequest(request) {
  // htmlファイルが置いてあるURLをfetchに渡してください
  const response = await fetch(request.url)
  let html = await response.text()

  // pathをそのままkeyとして扱うようにしている(Functionsで書いたものと仕様を合わせる)
  const key = new URL(request.url).pathname
  const data = await KVS.get(key, 'json')

  // Replace ogp meta content
  const newTitle = (data && data.title) || 'no title'
  const newDescription = (data && data.description) || 'no description'
  const newImageURL = (data && data.imageURL) || 'your default image url'
  html = html.replace('content="PLEASE_REPLACE_TITLE"', `content="${newTitle}"`)
  html = html.replace('content="PLEASE_REPLACE_DESCRIPTION"', `content="${newDescription}"`)
  html = html.replace('content="PLEASE_REPLACE_IMAGE_URL"', `content="${newImageURL}"`)

  return new Response(html, {
    headers: { 'Content-Type': 'text/html' },
    status: 200,
  })
}

動かす

ここまで設定できれば、SPA サイトの OGP は動的に出力されるはずです。OGP の設定がうまくいくかはTwitter Card validatorを使って確認するのが簡単です(キャッシュもされないので)。

例えば、Firestore の/users/moga{ titie: 'mogaです!', description: '趣味は麻雀です!よろしくおねがいします!' } を保存して、SPA サイトの/users/moga を Card validator に渡せば、以下のように表示されるはずです。

これをやったきっかけと自問自答

きっかけは、@nabettuさんが勉強会で発表です。

この発表を聞いていて、OGP 対応はちゃんとしたいけど SSR はしたくないよねみたいな話を、ボクが参加しているコミュニティのメンバー(nabettu さん含む)としていたときに、今回書いた内容でできるんじゃないかというふうになり試してみました。その中ででてきた話や今回やってみて思ったことを以下に書いておきます。

  • SSR すれば?
    • 仕事だと SSR することが多いです。Cloudflare を使ったことがなかったので今回やってみた感じです。
  • 今回の実装だとシェア画像うまく表示されないタイミングあるよね?
    • Firestore に create した瞬間に Twitter シェアすると、その段階では KV にデータが入ってないので正しくでません。ちゃんとした対応には工夫が必要ですね。
  • Edge から Firestore 叩けば?
    • SDK 使うと初回リクエストはかなり遅いので、やるにしても REST API を叩くことになるかな〜
  • KV のデータ無限に増えていくよね?
    • それは本当にそのとおり。KV はデータに TTL を設定できるので、それを活用できる構成にしたほうがいいですね。次やるなら、KV に各 Path ごとの HTML のテンプレートを入れておいて(消えてたら Hosting の方に取りに行く)、Firestore などの API を叩いて動的な部分のデータを取ってきて、meta を書き換えるとかを試しそうです。
  • 対応するのが Bot だけでいいならもう少し楽にできるのでは?
    • Edge Worker に 1 つ html 置いておいて、それを使えばいいので簡単になりそうですね。ただ Twitter だけじゃなくて Slack は?LINE は?みたいに対応しなきゃいけない Bot が増えていくので Bot にだけ OGP 対応して返すのはそれはそれで大変そうです。
  • 結局今回の対応は本番で使えるの?
    • KV のデータがひたすら増えてしまうので、使えなさそうですね。Edge Worker で meta 差し替えはかなりアリだなと思いました。
  • OGP の対応めんどくさいね?
    • ほんとそれな!OGP の仕様をもう少し良い形にできたのでは…

Special Thanks

一緒に議論したり実装サポートしてくれた @nabettuさん、@hummerさん、@mikkameさん、ありがとうございます!

Discussion