🖼

React製WebサービスにFirebaseをフル活用して動的なOGP生成を実現した話

2022/02/01に公開

はじめに

はじめまして!!!
結.JAPAN でみんなで共同編集できる旅行計画アプリnicody のiOSアプリとWebアプリの開発を行なっている、にーの(@n_shhhin)といいます!

元々、nicodyのiOSアプリの方をメインで開発してたのですが、最近になってWeb周りも担当することになり、初Reactサービス開発ということもあり苦戦しつつ勉強しながら開発しているところです...!👨‍💻

そんな中、最近 「既存ReactサービスをOGP対応する」 という初心者にはなかなか重めの要件が降ってきて、Web周りは右も左もわからない状態だったのでかなーーーり苦戦しました...!!!

そこで本記事では、今後自分みたいなWeb見習いエンジニャーがOGP対応を苦しまずにサクッと実装できるように、OGPを動的に変える方法をつまづいたポイントなどを含め解説してみたいと思います。

この手のOGP対応に関する記事は、「React OGP」「React Cloud functions OGP」などで調べたらいくらでも出てくると思うので、重複した内容になってしまう気もするのですが、初心者の観点からの気付きや知見もまとめておいた方がよさそうと思い記事にしてみます。

OGPとは?

「Open Graph Protocol」の略で、よくTwitterやFacebookなどでリンクを共有する時に目にする、タイトルや写真付きでシェアしたりすることができる仕組みのことです。

例えばnicodyでは、旅行の計画をシェアする時に、こんな感じのリッチな画像付きの共有リンクにしたいというOGPにおける要件がありました。

やっぱり、何の表示もされないURLより、こんな感じで画像があるだけで心惹かれますよね...!

なぜOGP対応は難しいか

そもそもこのタスクの何がそんなに難しいかというと、ReactだけでOGP画像を表示しようとすると、GoogleやTwitterなどのクローラがJavaScriptなどのスクリプトを実行して解釈までをしてくれないので、動的に画像を切り替えることができないという点です。

なので、ページごとで動的にOGPの内容などを変えたい場合は、サーバー側であらかじめメタタグなどを構成した状態で提供しとかないといけないのです。これがSSR(サーバーサイドレンダリング)というものです。

ただ、既存のReactサービスを全てSSR対応するのは、かなりコストがかかりそうな気がしたので(Next.jsなどを導入してコードを丸々書き換えないといけなそうだったので)、他に何か良い方法がないかと模索していると、以下のような今回の要件にピッタりな記事を見つけました。

https://shuheitagawa.com/set-ogp-tags-with-cloudfunctions/#:~:text=に返します。-,Meta tagのみ、SSRする,-今回の記事

この記事は、OGPに必要なメタタグの部分だけをFirebaseのCloud FunctionsでSSRするというもので、これなら実装コストを最小限に抑えられそうかと思い今回はこの方法を選びました。

また、nicodyはFirebaseの恩恵をかなり受けているので、親和性も高いと思ったのもこの記事を参考にした1つです。(ここら辺のFirebase活用事例をまとめた記事は別記事で弊社のiOSエンジニアの @yuto_nakano さんが投稿しているので、是非見てみてください!)

https://zenn.dev/yuto_nakano/articles/nicody_about_firebase_features

そんなこんなで中々難しいOGP対応を、Firebaseを活用して実現していきたいと思います。

今回使った主な技術

簡単に主に使う技術をリスト化するとこんな感じです。

  • React : 今回はそんなにいじる必要はないかも
  • TypeScript : 同上。Cloud Functionsのコードなどもtsで書いた
  • Firebase
    • Firestore: 旅のしおりのタイトルや画像のパスなどを格納しているのでここからOGP画像に必要な情報をひっぱってきている
    • Hosting: 任意のURLにアクセスしたタイミングで、functionsを実行する時のrewrite機能の部分
    • Cloud Storage: タイトルや日付などを描画したOGP画像を格納する場所
    • Cloud Functions: OGP画像を生成する処理+リンクにアクセスした時のメタタグの部分のSSR処理
  • node-canvas: 画像上にテキストをオーバレイする時に使用

実装の流れ

大きく分けて、以下の2つのフェーズに分けて説明していきます。

  1. OGP画像を生成するフェーズ
  2. OGP画像を表示(SSR)するフェーズ

OGP画像を生成するフェーズ

まずは、OGP画像を生成するタイミングである、公開モードがONになったタイミングを取得します。ここら辺は、皆さんの各々の生成するタイミングに合わせればいいと思いますが、nicodyにおける例だとTripドキュメントの更新を監視し、isPublishedフィールドがtrueになったタイミングで、OGP画像生成処理を走らせます。具体的なソースコードは以下のような感じです。

tripOnCreate.ts
import * as functions from "firebase-functions"
import { createOgpImage } from "./createOgpImage"
import { Trip } from "../entities/Trip"

exports.tripOnCreate = functions
  .region("asia-northeast1")
  .firestore.document("public/v1/trips/{tripId}")
  .onUpdate(async (change, context) => {

    // 前後のデータを比較
    const preValue = change.before.data() as Trip
    const newValue = change.after.data() as Trip

    // isPublishedが false -> true に変わったタイミングでOGP画像を生成する
    const onChangeToPublished = !preValue.isPublished && newValue.isPublished

    if (onChangeToPublished) {
      await createOgpImage(newValue)
    }
  })

こんな感じで、Tripドキュメントが更新を監視して公開モードがONになるタイミングでOGP画像の生成関数を呼ぶことができました。

どんどんいきましょう!

次に、OGP画像を生成する関数の

function createOgpImage(trip: Trip) {}

を作っていきます!ここは以下の記事をめちゃくちゃ参考にさせていただきました。

https://qiita.com/_masaokb/items/4e5e7c91d3a582f60e5f


サーバサイドで描画の処理などをする場合は、 node-canvas が便利なのでインストールします。

npm install node-canvas

インストールが終わったら、ソースコードの方を書いていきます。長いので折りたたみでソースコードは載せますが、

createOgpImage.ts
createOgpImage.ts
import * as fs from "fs"
import * as admin from "firebase-admin"
import { createCanvas, loadImage, registerFont } from "canvas"
import { Trip } from "../entities/Trip"

// node-canvasへのフォントの読み込み
registerFont("fonts/フォント名.otf", { family: "フォント名" })

const ogpSize = {
  width: 1200,
  height: 630,
}

const tripFontStyle = {
  font: 'bold 85px "フォント名"',
  color: "#FFFFFF",
}

export async function generateOgp(trip: Trip) {

  const basePath = `coverImages/${trip.id}.jpg`
  const targetPath = `ogpImages/${trip.id}.jpg`
  const localBasePath = "/tmp/base.jpg"
  const loaclTargetPath = "/tmp/target.jpg"
  
  const bucket = admin.storage().bucket()
  await bucket.file(basePath).download({ destination: localBasePath })

  const canvas = createCanvas(ogpSize.width, ogpSize.height)
  const ctx = canvas.getContext("2d")

  // 背景画像の読み込み
  const baseImage = await loadImage(localBasePath)

  // キャンバスのサイズに画像のサイズを合わせて描画
  const dx = canvas.width / baseImage.width
  const dy = canvas.height / baseImage.height
  const resizeMultiple = Math.max(dx, dy)
  const resizeImageWidth = baseImage.width * resizeMultiple
  const resizeImageHeight = baseImage.height * resizeMultiple

  const resizeOriginX = -(resizeImageWidth - canvas.width) / 2
  const resizeOriginY = -(resizeImageHeight - canvas.height) / 2

  ctx.drawImage(
    baseImage,
    resizeOriginX,
    resizeOriginY,
    resizeImageWidth,
    resizeImageHeight
  )

  // オーバレイを描画
  ctx.fillStyle = "rgb(0, 0, 0)"
  ctx.globalAlpha = 0.29
  ctx.fillRect(0, 0, canvas.width, canvas.height) // 黒の透過度0.29の矩形を描画

  // 旅行名を描画
  ctx.globalAlpha = 1.0
  ctx.textBaseline = "top"
  ctx.font = tripFontStyle.font
  ctx.fillStyle = tripFontStyle.color
  ctx.fillText(trip.title, 40, 40) // fillText(文字列, 左上を原点(0,0)とした時のx座標, y座標)
  
  // tmpディレクトリに出力
  const buf = canvas.toBuffer()
  fs.writeFileSync(loaclTargetPath, buf)

  // Storageにアップロード
  await bucket.upload(loaclTargetPath, { destination: targetPath })

  // tmpファイルの削除
  fs.unlinkSync(localBasePath)
  fs.unlinkSync(loaclTargetPath)
}

こんな感じで、Cloud Storageから画像を読み込んで、node-canvasで画像の上に文字などを描画していきます。ここら辺も例で示したものは旅行タイトルだけですが、よしなに実装しちゃってください!

ちなみに、自分が作業していた時は脳死でdev環境にデプロイして、出力画像がどんな感じになったかを確認して、デバッグをして、、、、を繰り返してたのですが、公式ドキュメントを見るとローカルエミュレータを使用すればデプロイせずともデバッグできることを後から知りました!笑

ここでは詳しくは書きませんが、nicodyではdev環境のデータをローカルにインポートしてきて、ローカルエミュレータでFunctionsを実行することでdev環境を汚さずにデバッグすることが可能になりました!(需要がありそうなら別記事にするかもです)

参考:
https://firebase.google.com/docs/functions/local-emulator?hl=ja#swift

OGP画像を表示(SSR)するフェーズ

さて、OGP画像の生成ができるようになったら次はいよいよ画像を読み込んでSSRでメタタグに埋め込むフェーズにいきます!

メタタグを生成する方法なんですが、Firebase Hostingの機能に便利なリライト機能というものがあり、この機能を使えばあるURLパスにアクセスしたタイミングで、指定したCloud Functionsを実行するみたいなことができます!

なので、今回は旅のしおりのページ /trips/* にアクセスしたタイミングで、メタタグをレンダリングする関数を用意してあげればいいのです!

具体的なリライト機能の使い方としては、プロジェクト配下にある firebase.json に以下のような rewrites の項目を追加すると、https://ほげほげ.com/trips/{tripId} にアクセスした時に指定したCloud Functionsを実行することができます!

firebase.json
{
  "hosting": {
    "public": "build",
    "rewrites": [
      {
        "source": "/trips/*", // ここで指定したパスにアクセスした時に
        "function": "shareTrip" // この関数を実行できる
      },
      {
        "source": "**", // それ以外はindex.htmlを返すようにする
        "destination": "/index.html"
      }
    ]
  },
  "functions": {
    "source": "functions",
    "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build"
  }
}

詳しくは公式ドキュメントを見ていただければと!
https://firebase.google.com/docs/hosting/full-config?hl=ja#rewrite-functions

次に上で指定したリライト機能で呼び出す関数をCloud Functionsで作っていきます!

ここでは /trips/{tripId} にアクセスがあったら、旅のしおりの情報をFirestoreから取得し、 本来出力するはずだったindex.htmlのメタタグの部分を任意の旅のしおり情報に差し替えるということをします。

ソースコードは以下のような感じです。

shareTrip.ts
shareTrip.ts
import * as functions from "firebase-functions"
import * as admin from "firebase-admin"
import * as path from "path"
import * as fs from "fs"

export const shareTrip = functions
  .region("us-central1")
  .https.onRequest(async (request, response) => {

    // アクセスする度にFirestoreへの取得処理を走らせたくないので
    // httpsに600秒のキャッシュを持たせる
    response.set("Cache-Control", "public, max-age=600, s-maxage=600")

    // リクエストURLからtripのIDを取得
    const match = request.path.match("/trips/(?<id>.+)")
    if (!match || !match.groups) {
      console.error("Invalid Url Error!")
      response.status(404).end("404 Not Found")
    } 
    let tripId: string
    tripId = match.groups.id
    
    // Reactプロジェクトのビルド後のできたてほやほやのbuild/index.htmlをhostingディレクトリに配置して読み込む
    // ここは詳しく説明します!
    let indexHTML = fs
      .readFileSync(path.join(__dirname, "../hosting", "index.html"))
      .toString()  

    // firestoreからtripIdをもとにtripドキュメントを引っ張ってくる
    const trip = await admin.firestore().collection("trips").doc(tripId).get().catch((error) => {
      console.error(error)
      return null
    })

    if (!trip) {
      console.error("Could not fetch Trip!")
      response.status(404).end("404 Not Found")
      return
    }

    // 生成したOGP画像を取得
    const imageUrl = 'https://firebasestorage.googleapis.com/v0/b/{***projectId***}.appspot.com/o/ogpImages%2F' + tripId + '.jpg?alt=media'

    // メタタグ部分を書き換える
    indexHTML = updateMetaTag(
      indexHTML,
      trip.title,
      trip.description,
      imageUrl
    )

    response.status(200).send(indexHTML)
  })

// 正規表現でマッチしたメタタグの一部を書き換える関数
const updateMetaTag = (html: string, title: string, description: string, imageUrl: string): string => {
  return html
    .replace(/\<title>.*<\/title>/g, '<title>' + title + '</title>')
    .replace(/<\s*meta name="description" content="[^>]*>/g, '<meta name="description" content="' + description + '" />')
    .replace(/<\s*meta property="og:title" content="[^>]*>/g, '<meta property="og:title" content="' + title + '" />')
    .replace(/<\s*meta property="og:description" content="[^>]*>/g, '<meta property="og:description" content="' + description + '" />')
    .replace(/<\s*meta property="og:image" content="[^>]*>/g, '<meta property="og:image" content="' + imageUrl + '" />')
}
index.ts
index.ts
import * as admin from "firebase-admin";
import { shareTrip } from "./shareTrip";

admin.initializeApp()

exports.shareTrip = shareTrip.shareTrip

何をやっているかはコード上のコメントアウトを見ていただければ大体わかると思います!

途中で index.html を読み込んでいると思いますが、ここで示すindex.htmlとはReact側でビルド済みの build/index.html のことです。index.html なんてどれも同じだろうと思って、public/index.html のものなどをおいてはいけません!(自分はまんまと、この沼にハマりました😇 )

あとは、ビルドで出来立てほやほやの index.html でないといけないことも注意です。毎回ビルドする度に読み込んでいるライブラリや画像のパスが変わるからです。

ここで、毎回ビルドしたものを手動でCloud Functionsのhosting に置いて...、とやるのも辛くなければいいと思いますが普通の人は辛いと思うので、既存のReactプロジェクトのルートディレクトリにCloud Functionsのプロジェクトを functions/ を置くようにして、package.json で定義しているbuildコマンドに、 index.html をコピーする処理を追加するのがいいと思います!

なので全体のReactプロジェクトのフォルダの階層はこんな感じになるかと思います!

ファイル階層
ファイル階層
./
├── package.json
├── package-lock.json
├── firebase.json
├── functions/ # Cloud Functionsプロジェクト
│   ├── hosting/
|   │   └── index.html
│   ├── node_modules/
│   ├── package-lock.json
│   ├── package.json
│   ├── build/
│   ├── src/
│   │   ├── index.ts
│   │   └── shareTrip.ts # 旅行情報のメタタグをSSRする関数
│   └── tsconfig.json
├── src/
│   ├── assets/
│   ├── commons/
│   ├── components/
│   ├── entities/
│   ├── firebase/
│   ├── index.module.scss
│   ├── index.tsx
│   ├── pages/
│   └── utils/
├── public/
│   └── index.html
├── node_modules/
└── build/

package.json はこんな感じになります。

package.json
 "scripts": {
    "build": "react-scripts build && cp build/index.html functions/hosting/",
    "build:functions": "cd functions && npm run build && cd ../"

また、OGPの表示をデバッグするにはHostingのrewrite機能と関数の呼び出しが必須なので、Create React Appがデフォルトで定義している npm run start などでは検証できません!

そこでローカルでHostingやFunctionsをエミュレートしてデバッグできる firebase serve コマンドを使ってあげるといいと思います!

参考:
https://firebase.google.com/docs/cli?hl=ja#test-locally

ここでは、package.json のscriptに、

package.json
"serve": "npm run build && npm run build:functions && firebase serve"

みたいに追加で定義してあげると、 npm run serve でlocalhostで検証できるようになります!
実際に、レンダリングされているかの挙動を確かめるには、Chromeなどの検証ツールでメタタグの中身がちゃんとレンダリングされているかを見るのもいいですが、locahostのOGPをチェックするためのリンクを発行できる Localhost OGP チェッカーTiwtter Card ValidatorFacebookのシェアデバッガー を使えば実際の見た目がどうなっているかを検証できると思います!

最後に package.json にデプロイ用のコマンドも定義してあげて、

package.json
"deploy": "npm run build && npm run build:functions && firebase deploy"

npm run deploy してあげればデプロイも1つのコマンドでできると思います!

ちなみにnicodyでは、Github Actionsを使って指定のブランチにマージされたタイミングでデプロイするということをして効率化していますが、ここら辺も長くなるのでまた機会があれば別記事にしたいと思います!

さて、実装の手順は以上となります!!!!!🎉
無事Slackなどに貼ったら展開されると思います(下の画像はOGP開発当時、社内のSlackに投下してテンション上がっている様子)

実際にWeb版のnicodyにもデプロイされているので、Zennの記事でもこんな感じで表示されます!(感動)
https://nicody.jp/itineraries/5B689211-D9E5-4266-8CCD-F83AD7B7E2E6

おわりに

今回は既存のReactアプリにFirebaseをフル活用してOGPの機能をつける解説をさせていただきました。当初は簡単にできそうと思っていた機能が、かなり工数がかかってしまう割と大変なタスクだったので、今回の記事で他の開発者が少しでも楽に開発できるようになればと思います!

最後に少しnicodyの宣伝をさせていただきます。

nicodyは、旅行に行く友達やカップル、同僚、家族などと一緒に旅のしおりが作れるアプリです!

旅のしおりにはスポットの追加や、時間のメモ、旅行の詳細が載ったURLなどを記録することができ、また思い出として振り返るといった面でも自分は可能性を感じながら日々開発しています!

今までは旅行の幹事さんがかなり負担になっていたものを、nicodyの共同編集ができる旅のしおりでかなり緩和できると思っています!

https://apps.apple.com/jp/app/nicody-ニコディ-旅のしおりをオシャレに作成-計画/id1515379808

そんなnicodyは昨年11月末に1万ダウンロード突破しました!まだまだ機能としても実装したい箇所はたくさんあるのですが、日に日にユーザの声も増えてきて嬉しい限りです!

またユーザ数の増加に伴い、nicodyでは新規開発メンバの募集も募集しているので是非Twitterなどで連絡をいただければと思います!

https://twitter.com/n_shhhin

弊社他メンバーも記事を投稿してますので、一緒に読んでいただけると嬉しいです。

  • 弊社代表 中山さんの記事

https://note.com/makuri_94/n/n912ab9257232

  • 弊社取締役 府川さんの記事

https://note.com/fukayu/n/n1cae8851a009

  • 弊社エンジニア 中野さんの記事

https://zenn.dev/yuto_nakano/articles/nicody_about_firebase_features

【nicody開発メンバー募集!】

nicodyを一緒に作り上げていくメンバーを募集中です!

  • iOSエンジニア(Swift)
  • WEBエンジニア(React)

を中心にあらゆる職種を募集しています。弊社代表 中山さん(Twitter, Facebook Messenger)へお問い合わせお願いいたします!

Discussion