FirebaseCloudFunctionsとcanvasだけで動的にOGP画像を生成し2020年を生き延びよう

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">一見すると楽だけど実はめんどくさい開発ランキング<br><br>1位: Safariのバウンス対応<br>2位: SPAにおけるOGPの動的生成<br>3位: Safariのモーダル背景スクロールさせない対応</p>&mdash; 統合開発環境 (@sadnessOjisan) <a href="https://twitter.com/sadnessOjisan/status/1239841180981985280?ref_src=twsrc%5Etfw">March 17, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

マジな話、思ってるよりかなりめんどくさいです
OGP芸、2020年フロントエンドエンジニアの試金石になるんじゃないでしょうか

## SPAで動的OGP生成要件が出た
困りますよね。CGMやってると8割ぐらいで発生しそう
CGMってGCMとタイポするとGoogleの新製品みたいですよね。

## 構成
ちょっとググってみると分かる通り、色々な構成が考えられます。
例えば

- Node.js側で生成し、ストレージにあげ、metaタグにURLをセット
- UserAgentを見て、botならCloud Functionsに飛ばして、Cloud Functionsがmetaタグ付きのHTMLを返す

今回は

- metaタグにCloud FunctionsのURLを渡す
  - 例えば`https://asia-northeast1-hoge.cloudfunctions.net/ogp?text=${fuga}`
- Cloud FunctionsではStorageに該当画像があればリダイレクト、なければ生成してアップロードしてからリダイレクトする

で行こうと思います
~~これが一番シンプルなのでは？でもやっている人いなかった、なんか問題あるのか？~~

関係ないですが、Firebase*Nuxt.js関連の記事がめちゃくちゃ多くて、日本だと本当にVueが多いんだなと思いました

## Firebase Cloud Functionsのセットアップ
「爆速で〜」とか「3分で出来る〜」とか全部**フェイク**で、セットアップだけで1時間ぐらい潰れます
代表的な罠を列挙しておきます

- Nodeは8系が推奨されている（10系は使えない）のでnodebrewなりnodenvなりで用意しておく必要がある
- デフォルトでTSLintを使おうとしてくる（コマンドライン対話時に拒否できる）のでESLint使う人は注意
- デフォルトでnpmを使おうとしてくる（コマンドライン対話時に拒否できる）のでyarn使う人は注意

やることは

```shell

mkdir hoge
cd hoge
firebase init functions
```
で後は対話環境なんでよしなにやって下さい。

10分以内にリンタ環境まで整えられれば、あなたはFirebaseマスターです

## ローカルデバッグ
`firebase functions:shell`でローカル対話環境が開きます
そこで`${function名}:get('?text=太郎')`等でデバッグ出来るので、活用していきましょう

注意点として、本番環境だと書き込み可能なストレージは`tmp/`ディレクトリのみです
ローカルだとどこでも書き込めると思うので、いざデプロイするとこれで例外発生したりします

## 実行ファイル
[node-canvas](https://github.com/Automattic/node-canvas)でやります
[node.jsではてなブログ風アイキャッチ画像を動的に生成する](https://qiita.com/ampersand/items/805e75b5a54797923885)を大いに参考にしています

```typescript:index.ts
import * as fs from 'fs'
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { createCanvas, loadImage } from 'canvas'

admin.initializeApp(functions.config().firebase)
const bucket = admin.storage().bucket() // デフォルトバケットじゃない人はここで指定
```
言うまでもないですが、権限は`GOOGLE_APPLICATION_CREDENTIALS`を見ています
ローカルで実行する際はよしなに設定して下さい

```typescript:index.ts
exports.ogp = functions
  .region('asia-northeast1')
  .https.onRequest(async (req, res) => {
    const { text } = req.query
    const isExists = await checkIsExists(text)
    if (isExists) {
      const url = getUrl(`ogps/${text}.png`)
      res.redirect(url)
      return
    }
    const url = await createOgp(text)
    res.redirect(url)
  })
```
これが関数の本体部分になります
見れば分かるので次に行きましょう

```typescript:index.ts
const checkIsExists = async (text: string): Promise<boolean> => {
  const filePath = `ogps/${text}.png`
  const isExists = await bucket.file(filePath).exists()
  return isExists[0]
}

const getUrl = (targetPath: string): string =>
  `https://firebasestorage.googleapis.com/v0/b/hoge/o/${encodeURIComponent(
    targetPath
  )}?alt=media`
```
bucketの`ogps/${text}.png`に画像を格納している想定ですね
`getUrl`はいちいち`getDownloadURL()`するのダルいので使っています
publicに公開されているなら、たぶん動くはずです


```typescript:index.ts
const fontStyle = {
  font: 'bold 85px "Noto Sans CJK JP"',
  lineHeight: 100,
  color: '#FFFFFF'
}

// Return result url
const createOgp = async (text: string): Promise<string> => {
  const loaclTargetPath = `/tmp/target.png`
  const localBasePath = '/tmp/base.png'
  const targetPath = `ogps/${text}.png`
  const basePath = 'ogps/base.png'

  // 背景画像のダウンロード
  await bucket.file(basePath).download({ destination: localBasePath })

  const canvas = createCanvas(1280, 670)
  const ctx = canvas.getContext('2d')
  // 背景画像の描画
  const baseImage = await loadImage(localBasePath)
  ctx.drawImage(baseImage, 0, 0, 1280, 670)

  // 文字列の書き込み
  ctx.font = fontStyle.font
  ctx.fillStyle = fontStyle.color
  ctx.textBaseline = 'top'
  const topPadding = 60
  const leftPadding = 50
  const rightPadding = 600
  const lineWidth = 1280 - leftPadding - rightPadding
  const lines = splitByMeasureWidth(name, lineWidth, ctx)
  const lineCount = lines.length

  for (let index = 0; index < lineCount; index++) {
    const element = lines[index]
    ctx.fillText(
      element,
      leftPadding,
      topPadding + fontStyle.lineHeight * index
    )
  }

  // tmpディレクトリへの書き込み
  const buf = canvas.toBuffer()
  fs.writeFileSync(loaclTargetPath, buf)

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

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

  return getUrl(targetPath)
}

const splitByMeasureWidth = (
  str: string,
  maxWidth: number,
  context: { measureText: CallableFunction }
): Array<string> => {
  // サロゲートペアを考慮した文字分割
  const chars = Array.from(str)
  let line = ''
  const lines = []
  for (let index = 0; index < chars.length; index++) {
    if (maxWidth <= context.measureText(line + chars[index]).width) {
      lines.push(line)
      line = chars[index]
    } else {
      line += chars[index]
    }
  }
  lines.push(line)
  return lines
}
```
この部分はほとんど[node.jsではてなブログ風アイキャッチ画像を動的に生成する](https://qiita.com/ampersand/items/805e75b5a54797923885)に依っています

paddingやフォントはよしなに変更して下さい
Cloud Functionsで日本語フォントがちゃんと動くのか心配だったんですが、問題なく動いているようです

## 結果
良かったですね。

![ogp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/530822/3f614f28-d45e-d5a6-37ce-5bab3831b902.png)


「下線を引きたい」「文字量に応じてフォントサイズを可変にしてほしい」などの要件が来た人は、もっとがんばってくださいね

## いかがでしたか？
このブログは業務のデザイン上がってくるの待ちの時間に書いただけなので、コードや完成品をGitHubで共有したりしません。
皆さんもがんばってOGP芸を身に着け、2020年を生き延びましょうね