OGP
SPA
vue.js
Firebase
FirebaseCloudFunctions

SNS映えするWebアプリを...!FirebaseとVue.jsでSPAのOGP画像の動的生成をやってみたら案外楽だった

この記事はFirebase Advent Calendar 2018 6日目の記事です。

はじめまして、ゆき(@twitter:yuneco)です。日頃は絵を描いたりちょっとしたWebアプリを個人開発したりして遊んでいます。今日は個人でTwitter連携アプリを開発した際に悩んだSPAの動的な(ページごとの)OGP生成について情報をまとめてみたいと思います。NuxtやSSRは使いません。

作ったもの&OGPのイメージ

今回作った colorinco*カラーインコ はTwitter連携したユーザの投稿画像やお気に入り画像を表示し、そこから自動的にカラーパレットを生成、Twitterでシェアできるサービスです。共有するとタイムラインに↓こんな感じでコンテンツにあわせた画像が大きく表示されます。

colorinco on twitter

:camera: 映える!テンション上がる! :heart_eyes:

SNS連携アプリならOGPは必須

OGP(Open Graph Protocol)はざっくり言うと、決まったmetaタグをhtmlに書いておくと、SNSのタイムライン/ウォールでページタイトルとかイメージとかを良い感じに表示してくれる、アレです。

最近はPeing-質問箱-ためしがきなど、写真や絵を扱っているわけではないサービスでもOGP画像をいい感じで活用するケースが増えて来ているように感じます。

SPAでOGPどうするの問題

colorincoの場合、ページ(シェアされた画像とカラーパレット)ごとに異なるOGP画像を返す必要があります。トップページ以外がシェアされるWebアプリならだいたい同じようなニーズはあるはず。
ただ、これがちょっと難しくて、VueみたいなSPAだと「動的に異なるOGPタグを生成する」の部分ができません。理由はTwitterやFbのクローラがJSを解釈しないから。 クライアント側でタグをいくら書き換えてもSNSには表示されないのです。

  • じゃあSSR(サーバサイドレンダリング)する? → OGPのためだけにやるのは辛い:scream_cat:
  • 割り切って全ページ共通のOGPにする? → タイムラインに同じOGPが並ぶのはむしろマイナスなのでは?:poop:
  • じゃあOGPやめれば? → SNS連携アプリなんだから画像が出ないのはやっぱ辛い:sob:

こんな感じでつらみループ:loop:に陥ったのは私だけじゃない、はず。割り切りで同じOGP画像を全ページに適用しているアプリも多いけど、せっかく流行った時にTLが同じOGPで埋まっちゃうのはやっぱり印象良くないよね、と思うのです。

HostingとFunctionsでOGPだけ動的生成

:angel: でもできる。そう、Firebaseならね :angel_tone1:

と言うわけで、今回はSSRなしで普通のVueアプリでコンテンツに合わせた動的OGPだけをやる方法を考えます。使うのはHostingとFunctionsです。DBはCloubFirestoreでもRealtimeDBでもお好きなものを。

最初にざっくりアプローチを。

  1. OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出し
  2. FunctionsでリクエストURLを元にOGPタグを組み立ててhtmlを出力。body部にscriptで"元URL+α"に飛ばすリダイレクト処理を書く
  3. (JavaScriptを解釈しない)Twitter/Facebook:このOGPタグを読み取って終了
  4. (JavaScriptを解釈する)通常の利用者やGoogleクローラ:
    1. リダイレクト指示に従って"元URL+α"に飛ぶ
    2. "元URL+α"はFunctions呼び出しの対象外なので、普通にVueアプリがロードされて起動
    3. Vueのrouterで"元URL+α"を元URLに書き戻す
    4. 本来のコンポーネントがマウントされて終了

ogp_vue_firebase.png

ポイントだけかいつまんでコードを載せます。

OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出す

Firebase Hostingでは設定でパスのrewriteができ、ここで飛ばし先にFunctionsの指定ができます。Firebaseプロジェクトのルートのfirebase.jsonはおそらく↓こんな感じになっていると思います。

firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": [ /*略*/ ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

ファイルが実在しない全てのURLに対してindex.htmlを返す、という設定ですね。(firebase initするときの「SPA用の設定をするか?」みたいな質問にYで答えるとこうなります)

今回はカラーパレットのストックページ/stock/*のOGPを動的に生成したいので、rewritesを以下のように修正します。

firebase.json
  "rewrites": [
      {
        "source": "/stock/*",
        "function": "stockpage"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
  ]

これで/stock/*の全てのリクエストがstockpageというFunctionの呼び出しになりました。

FunctionsでリクエストURLを元にOGPタグを組み立ててhtmlを出力

次にFunctions側でこのリクエストを受け取って、正しいOGP入りのhtmlを返します。

functions/stockpage.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const db = admin.firestore()

const CONFIG = functions.config()
const app_domain = CONFIG.app.domain
const OGP_IMG_WIDTH = 1200
const OGP_IMG_HEIGHT = 630

const func = functions.https.onRequest((req, res) => {
  const [, , stockid] = req.path.split('/')
  return db.collection('user-stocks').doc(stockid).get().then(snap => {
    if (!snap) {
      res.status(404).end('404 Not Found')
      return
    }
    const stockItem = snap ? snap.data() : {}
    const uname = stockItem.uname || ''
    const html = createHtml(uname, stockid)
    res.set('Cache-Control', 'public, max-age=600, s-maxage=600')
    res.status(200).end(html)
    return
  }).catch((err) => {
    console.warn(err)
    // 略 : エラー時はデフォルトのhtml(固定のOGP)を返す
  })
});

const createHtml = (uname, stockid) => {
  const SITEURL = `https://${app_domain}`
  const PAGEURL = `${SITEURL}/stock/${stockid}`
  const TITLE = `view ${escapeHtml(uname)}'s colorsets on colorinco`
  const DESCRIPTION = 'カラーインコはTwitterでお気に入りしている画像のカラーパレットを表示・ストックできるサービスです。'
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>colorinco</title>
    <meta property="og:title" content="${TITLE}">
    <meta property="og:image" content="${SITEURL}/ogp/stockimg/${stockid}">
    <meta property="og:image:width" content="${OGP_IMG_WIDTH}">
    <meta property="og:image:height" content="${OGP_IMG_HEIGHT}">
    <meta property="og:description" content="${DESCRIPTION}">
    <meta property="og:url" content="${PAGEURL}">
    <meta property="og:type" content="article">
    <meta property="og:site_name" content="colorinco*カラーインコ">
    <meta name="twitter:site" content="${SITEURL}">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="${TITLE}">
    <meta name="twitter:image" content="${SITEURL}/ogp/stockimg/${stockid}">
    <meta name="twitter:description" content="${DESCRIPTION}">
  </head>
  <body>
    <script type="text/javascript">window.location="/_stock/${stockid}";</script>
  </body>
</html>
`
}

module.exports = func

大半がOGPのテンプレですね。ポイントだけ列挙すると

  • functions.https.onRequestの引数からリクエストパスを取得、DBに繋いでOGPのためのデータをとる
  • res.set('Cache-Control', 'public, max-age=XX秒, s-maxage=XX秒')でキャッシュを有効にする
    これを入れないとFunctionsはデフォルトではキャッシュされないので、毎回Functionが呼ばれて死にます。 キャッシュの有効期間はよしなに決めてください。長めでいいと思います。
  • OGPの中身はいい感じに書いてあげてください。og:imageの部分だけあとで説明書きます
    OGPに書くURL類は絶対パスじゃないといけないらしいので、そこだけ注意。
  • <body>内でアプリに飛ばします。上の例では/stock/${stockid}/_stock/${stockid}に飛ばしています

TwitterやFacebookはこのhtmlのOGPだけ読めれば満足なので、bodyの中身は空っぽで構いません。通常のブラウザはこのままだと困るので、scriptで/_stock/${stockid}にリダイレクトしています。このパスはFunctionsの対象にしていないので、普通にindex.htmlが呼ばれ、Vueアプリが起動します:innocent::v:

Vueのrouterで"元URL+α"を元URLに書き戻す

これでなんとかOGPクローラを満足させつつ、Vueアプリまで戻って来ました。
最後に/_stock/${stockid}を元のURLに書きもどして正しいコンポーネントをマウントします。

src/router/index.js
export default new Router({
  mode: 'history',
  routes: [
    // ...略
    {
      path: '/_stock/:stockid',
      redirect: '/stock/:stockid'
    }
    // ...略
  ]
})

ルーターの設定に一つ追加するだけです。このredirectはあくまでVueの画面内のものなので、
リダイレクト先URLにリクエストが飛ぶわけではありません。リダイレクトループにはならないのでご安心を。

OGP画像も動的生成

これでページタイトルや説明等、OGPの基本的な部分はできました。残るOGP画像の動的生成も同様にやっていきます。基本的なアプローチはhtmlの生成と同じです。レスポンスはPNGデータなので、今度はリダイレクトは考えなくてOKです。

画像用:OGPを動的に生成したいパスのみ、Hostingの設定でFunctionsを呼び出す

og:image,twitter:image/ogp/stockimg/${stockid}と指定したので、/ogp/stockimg/*をFunctionsに飛ばすよう、Hostingの設定を追加します。

firebase.json
  "hosting": {
    "public": "dist",
    "ignore": [/*略*/],
    "rewrites": [
      {
        "source": "/stock/*",
        "function": "stockpage"
      },
      { /* 追加↓ */
        "source": "/ogp/stockimg/*",
        "function": "stockimg"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }

FunctionsでリクエストURLを元にOGP画像を生成

ここも画像を動的に生成していること以外は特別なことはしていません。
firebase/functions-samples/image-makerに公式のサンプルがあります。(いつの間にかサンプルがNode8に上がってる...)

functions/stockimg.js
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const db = admin.firestore()
const Canvas = require('canvas-prebuilt')
const Image = Canvas.Image
// カラーセットをタイル状に配置するための座標計算を行うクラス
const TileLayout = require('./src/TileLayout')

// OGP関連の定数
const OGP_IMG_WIDTH = 1200
const OGP_IMG_HEIGHT = 630
const OGP_IMG_ARTWORK_WIDTH = OGP_IMG_WIDTH * 0.5

// "/ogp/stockimg/:sid" の処理本体(最後にexportしています)
const func = functions.https.onRequest((req, res) => {
  const [, , , stockid] = req.path.split('/')
  const canvas = new Canvas(OGP_IMG_WIDTH, OGP_IMG_HEIGHT)
  res.set('Cache-Control', 'public, max-age=600, s-maxage=600')

  // DBからstock itemの情報を取得
  // ここからの処理は全てPromiseチェーンにぶら下げる
  return db.collection('user-stocks').doc(stockid).get().then(snap => {
    if (!snap) {
      res.status(404).end('404 Not Found')
      return null
    }
    return snap.data();
  }).then((stockItem) => {
    // DBから取得したstock itemからカラーセットを取得し、タイル状に色を描画
    if (!stockItem) { return null }
    // ... 座標計算等 ... 
    drawColorset(canvas, colorset, layout, /*...略*/)
    // 画像をダウンロードして描画。非同期なのでPromiseを返す
    return drawImgWithUrl(canvas, imgUrl, /*...略*/)
  }).then((isSuccess) => {
    // エラーなく完了したらCanvasの内容をpngで出力
    if (isSuccess) {
      res.writeHead(200, {'Content-Type': 'image/png'})
      canvas.pngStream().pipe(res)
    } else {
      res.status(500).end('500 Server Internal Error')
    }
    return isSuccess
  }).catch((err) => {
    res.status(500).end('500 Server Internal Error')
  })
})

だいぶ端折っちゃってますが、ここもポイントだけ。

  • Functionsで画像生成するのはcanvas-prebuiltが便利
  • DBデータ取得処理・画像ダウンロード処理など、複数の非同期処理が入るので、途中でPromiseのチェーンがきれないように注意。今ならNode8が使えるのでasync/awaitにした方が幸せになれそう
  • htmlと同様、キャッシュの設定を忘れずに。忘れると課金で(ry
  • Twitter側の画像データはStorageに保存(キャッシュ)した方が性能的にはいいかも。今回はユーザーのデータを不用意に保持したくなかったので、都度Twitter側から取得しています。画像取得自体はAPIではないので180req/15分のAPI制限は関係ない、はず

ここまでの成果

ここまでで一通りOGPを動的に生成することができました。
Twitter Card validatorFacebookシェアデバッガーで設定したOGPが正しく動くことを確認できます。

延長戦:リダイレクトなしで動的にOGPを生成する

これで実用上は十分なのですが、一点だけ残念なのが無駄にややこしいダミーURLへのリダイレクト。
SEO的にどうなの、という話もある(らしい)のと、リクエストが1ターン増えるので初期表示が遅くなるのが気になる(といっても数百msレベルですが)。あと、PCブラウザで見てるとURLの書き換えが一瞬見えてちょっとダサい。

そんなわけでここからは(必須感は薄いですが)より完璧を目指してリダイレクトなしの方法を考えます。

考えかたのポイント:

  • Functionsから返すhtmlがそのままブラウザで使える完全なページならリダイレクトはいらない
  • Vueのhtmlは全画面共通でindex.htmlのみ。このファイルはほとんど編集しない
  • なので、Functionsから返すhtmlをindex.htmlと同じ内容(でOGPだけコンテンツに合わせて生成したもの)にすればよい
  • index.htmlで頻繁に変わるのは自動で挿入されるバンドルされたjs/cssファイル名のみ(キャッシュ回避のためにファイル名にダイジェストがつく = ビルドするたびに名前が変わる)

最後のひとつだけが曲者です。
普段Vueにお任せしていると意識しない部分なので、認識ない方はVueアプリを開いてブラヴサからソースを表示して見てください。

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <meta name=viewport content="width=device-width,initial-scale=1">
  <title>colorinco*カラーインコ</title>
  <!-- GOPのmetaタグがいっぱい -->
  <link href=/static/css/app.78eb8346fb720a49941bfeca9b64356f.css rel=stylesheet>
</head>
<body>
  <div id=splash style="height:100%;font-family:sans-serif;padding:30% 10%;text-align:center;font-size:20pt;color:#aaa;">colorinco</div>
  <div id=app></div>
  <script type=text/javascript src=/static/js/manifest.2ae2e69a05c33dfc65f8.js></script>
  <script type=text/javascript src=/static/js/vendor.4bd743272820dc4398f1.js></script>
  <script type=text/javascript src=/static/js/app.d4cd8aa92b80dfd544ab.js></script>
</body>
</html>

細かい部分は違うと思いますが、cssが一つ、jsが3つ、乱数的な文字列のついたファイル名で読み込まれているはずです。
このランダムな文字列はビルドのたび(内容変更されるたび)に変わるので、Functions側にハードコードするわけには行きません。

解決のアプローチ:

  • Functionsで返しているhtmlからリダイレクトを削除し、本物のindex.htmlと同じ内容(OGPのみ動的に生成されたもの)に変える
  • バンドルされたjs/cssファイル名はデプロイ時にFunctionsの環境変数に設定し、出力htmlに反映させる

大前提として、Functionsは「本物」のindex.htmlの中身を知りません。
HostingとFunctionsは動いている場所が別なので、ちょっとローカルファイルを読んできて...と言うわけにはいかないのです。なので、今回はビルド時にFunctionsの環境変数にファイル名を設定してあげることにします。

具体的なやり方を書いていきます。

バンドルのファイル名を取得する

まずはビルドのたびに変わるバンドルのファイル名を取得します。
ビルド処理なのでWebpackの設定をいじります。(今回はWebpack4なので最新版だとちょっと違うかも。。)

まずwebpack-manifest-pluginnpm i -D webpack-manifest-pluginでインストールして、webpack.config.jsから呼び出します。
プラグインの配列の最後にnew ManifestPlugin()を追加するだけです。

webpack.prod.conf.js
const webpackConfig = merge(baseWebpackConfig, {
  /*...*/
  plugins: [
    // 長い長いプラグインのリスト
    ,
    new ManifestPlugin()
  ]
})

ローカルサーバでデバッグする分には不要な設定なので、追加するのはwebpack.prod.conf.jsの方です。これでビルド時にdist/manifest.jsonが生成されます。内容はこんな感じのシンプルなjsonです↓

dist/manifest.json
{
  "vendor.js": "/static/js/vendor.4bd743272820dc4398f1.js",
  "vendor.js.map": "/static/js/vendor.4bd743272820dc4398f1.js.map",
  "app.js": "/static/js/app.d4cd8aa92b80dfd544ab.js",
  "app.css": "/static/css/app.78eb8346fb720a49941bfeca9b64356f.css",
  // ... 下略
}

バンドルのファイル名をFunctionsの環境変数にセットする

このmanifest.jsonをデプロイ処理の前にFirebaseFunctionsの環境変数に設定します。設定した環境変数が有効になるのは次回Functionをデプロイしたときなのでデプロイ後にセットしてもダメ。

npmスクリプトで呼び出したいので、この辺りの処理も一つのファイルにまとめます。

build/set-functions-envs.js
'use strict'
const childProcess = require('child_process')

const manifest = require('../dist/manifest')
const TARGET_FILES = ['manifest.js', 'app.js', 'vendor.js', 'app.css']

const cmd = 'firebase functions:config:set ' +
  TARGET_FILES
    .map(name => `${name}=“${manifest[name].split('/').pop()}”`)
    .join(' ')

console.log(cmd)
childProcess.exec(cmd, (error, stdout, stderr) => {
  if(error) {
    console.warn(stderr)
  } else {
    console.log(stdout)
  }
})

環境変数の名前は適当に決めてあげてくださしい。
これで環境変数にhosting.manifest.app.js = "app.d4cd8aa92b80dfd544ab.js"のような設定ができました。

これをnodeで実行すると、こんな感じで↓メッセージが出ます。反映するにはこのあとFunctionsのデプロイがいるよ、と忠告してくれてますね。

firebase functions:config:set hosting.manifest.manifest.js="manifest.2ae2e69a05c33dfc65f8.js" hosting.manifest.app.js="app.d4cd8aa92b80dfd544ab.js" hosting.manifest.vendor.js="vendor.4bd743272820dc4398f1.js" hosting.manifest.app.css="app.78eb8346fb720a49941bfeca9b64356f.css"
✔  Functions config updated.

Please deploy your functions for the change to take effect by running firebase deploy --only functions

firebase deploy --only functionsしろと言っていますが、もちろんフルでデプロイしても反映されます。

デプロイ時に環境変数の設定処理を走らせる

以後、デプロイ時には必ずこの環境変数のセットも行わないといけないので、まとめてnpmスクリプトに入れておきます。このあたりはみなさんお好みで。

package.json
"scripts": {
  "release-deploy": "node build/build.js; firebase use release; node build/set-functions-envs.js; firebase deploy; firebase use staging;"
}

ここでは、ビルド→release環境に切り替え→環境変数セット→デプロイ→ステージ環境に戻す、までをまとめてやっています。

Functionsで環境変数を読み取って出力htmlに反映

最後にFunctionsでこの環境変数を使ってhtmlを生成します。
環境変数はfunctions.config()で取得できるので、そこからバンドルのファイル名を取り出してindex.htmlと同じものを組み立てるだけです。

stockpage.js
// ...略... //
const CONFIG = functions.config()
const hosting_manifest = {
  'manifest.js': CONFIG.hosting.manifest.manifest.js,
  'app.js': CONFIG.hosting.manifest.app.js,
  'vendor.js': CONFIG.hosting.manifest.vendor.js,
  'app.css': CONFIG.hosting.manifest.app.css
}

// ...略... //

const createHtml = (uname, stockid) => {
  // ...略... //
  return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>colorinco</title>
    <meta property="og:title" content="${TITLE}">
    /* 略 : metaタグたくさん */
    <link href="/static/css/${hosting_manifest['app.css']}" rel=stylesheet>
  </head>
  <body>
    <div id="splash" style="height:100%;font-family:sans-serif;padding:30% 10%;text-align:center;font-size:20pt;color:#aaa;">colorinco</div>
    <div id=app></div>
    <script type="text/javascript" src="/static/js/${hosting_manifest['manifest.js']}"></script>
    <script type="text/javascript" src="/static/js/${hosting_manifest['vendor.js']}"></script>
    <script type="text/javascript" src="/static/js/${hosting_manifest['app.js']}"></script>    
  </body>
</html>
`
}

ここまでやってビルド・デプロイすれば、リダイレクトなしで動的なOGP生成ができるようになっているはずです。

課題と制約

上の方でもちょろっと書きましたが、この対応を行うと基本的にHostingにデプロイするたびに環境変数の設定とFunctionsのデプロイも必ず必要になります。つまり deploy --only hostingは使えません:cry: Functionsのデプロイはfirebase deployの中でも時間のかかる部分なので、ちょっと歯がゆいところです。

この制約を課してでもリダイレクトを避けるべきなのかはアプリの要件次第な気がするので、お好みで採用していただければ、と。

まとめ:Firebase + VueならリッチなOGPも案外簡単

Vue.js + Firebaseでアプリ開発してる方、結構多いと思うのですがあまりOGP周りの情報がなかったので四苦八苦の結果をまとめてみました。わかってしまえば案外簡単ですし、既存のVueアプリにも後付けできるので、ぜひ試して間違いや改善などフィードバックいただけると嬉しいです。

colorincoもよろしくね