🖼️

Satoriを使ってOGP画像生成&キャッシュ on Cloudflare Workers/R2

2023/04/20に公開

BEENOS の三上です。
今回は、Satoriを使ったCloudflare Workers上での OGP 画像生成 + 生成した画像をCloudflare R2を使ってキャッシュする仕組みを検証しました。
その内容を記事として残します。

なお、R2 を使ったキャッシュは、要件次第で不要な可能性も高そうです。
今回は外部でホスティングされている画像を埋め込んだ OGP 画像の生成を想定しています。
この外部ホスティング画像へのアクセスを減らす目的で生成後の OGP 画像をキャッシュしたい欲求があったため、この様な検証をしている次第です。

なお、今回試したコードの最終形は Github repository に公開しています。
適宜ご参照ください。

参考資料

環境

モノ version
Node.js 18.16.0 (2023/04/19 時点での最新の LTS)
npm 9.6.4
Wrangler 2.16.0
Satori 0.4.8

やっていく

プロジェクトの初期構築

前提として、Cloudflare のアカウントは作成済みで、Wrangler を通してログイン(npx wrangler login)も済んでいる状態を想定しています。

まず、 npx wrangler init experimental-ogp-generation でプロジェクトの初期化を行っていきます。
(参考までに公式のドキュメントとしてはこの辺りです。)
promptでいくつか答えていきます。

❯ npx wrangler init experimental-ogp-generation
  ⛅️ wrangler 2.16.0
--------------------
Using npm as package manager.
✨ Created experimental-ogp-generation/wrangler.toml
✔ No package.json found. Would you like to create one? … yes
✨ Created experimental-ogp-generation/package.json
✔ Would you like to use TypeScript? … yes
✨ Created experimental-ogp-generation/tsconfig.json
✔ Would you like to create a Worker at experimental-ogp-generation/src/index.ts? › Fetch handler
✨ Created experimental-ogp-generation/src/index.ts
✔ Would you like us to write your first test with Vitest? … no
npm WARN deprecated rollup-plugin-inject@3.0.2: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.
npm WARN deprecated sourcemap-codec@1.4.8: Please use @jridgewell/sourcemap-codec instead

added 104 packages, and audited 105 packages in 9s

12 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
✨ Installed @cloudflare/workers-types and typescript into devDependencies

To start developing your Worker, run `cd experimental-ogp-generation && npm start`
To publish your Worker to the Internet, run `npm run deploy`

次に作成されたディレクトリに移動して、必要なパッケージ類を導入していきます。

cd experimental-ogp-generation
npm i react satori @resvg/resvg-wasm yoga-wasm-web
npm i -D @types/react @cloudflare/workers-types

最終的に JSX を使うので、その辺りの調整も今のうちの行いましょう。
index.ts ファイルを JSX が取り扱えるように tsx ファイルにしておきます。

mv src/index.ts src/index.tsx

tsconfig.jsonwrangler.toml を編集します。

tsconfig.json
                /* Language and Environment */
                "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
                "lib": [
+                       "dom",
                        "es2021"
                ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
-               "jsx": "react" /* Specify what JSX code is generated. */,
+               "jsx": "react-jsx" /* Specify what JSX code is generated. */,
                // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
                // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
                // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
wrangler.toml
 name = "experimental-ogp-generation"
-main = "src/index.ts"
+main = "src/index.tsx"
 compatibility_date = "2023-04-18"

それらの編集を入れた後、npx wrangler devで local server を立ち上げてみます。

❯ npx wrangler dev
  ⛅️ wrangler 2.16.0
--------------------
 Listening at http://0.0.0.0:8787
- http://127.0.0.1:8787
Total Upload: 0.19 KiB / gzip: 0.16 KiB
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ [b] open a browser, [d] open Devtools, [l] turn on local mode, [c] clear console, [x] to exit                         │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

curl で叩いて動いていることを確認します。

curl http://127.0.0.1:8787
Hello World!

ここまでで、最低限のコードを手元で動かすことができました。👍

フォントファイルの配置

ここからは OGP 画像の生成に向けて作業していきます。
Satori での OGP 画像生成時には、フォントファイルが必要となります。
英数字だけのフォントであればそれなりに軽量なんですが、日本語フォントはサイズが大きくなりがちで、FaaS で画像生成やるときはここが辛いですよね...
今回はフォントファイルを Cloudflare R2 上に配置し、そこから取得する形とします。
(Google Fonts からNoto Sansを使用させて頂きます。リンク先から適宜ダウンロードしておいてください。)

まずは npx wrangler r2 bucket create ogp-generation-test で R2 bucket を作っていきます。

❯ npx wrangler r2 bucket create ogp-generation-test
  ⛅️ wrangler 2.16.0
--------------------
Creating bucket ogp-generation-test.
Created bucket ogp-generation-test.

npx wrangler r2 bucket list で作成した bucket を確認できます。

❯ npx wrangler r2 bucket list
[
  {
    "name": "ogp-generation-test",
    "creation_date": "2023-04-18T18:00:27.312Z"
  }
]

作成したbucketにフォントファイルを格納していきます。
Google Fontsからダウンロードしてきた NotoSansJP-Regular.otf がproject rootにあるものとします。

❯ ❯ tree -L 1 .
.
├── NotoSansJP-Regular.otf
├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── src
├── tsconfig.json
└── wrangler.toml

3 directories, 6 files

❯ npx wrangler r2 object put ogp-generation-test/fonts/NotoSansJP-Regular.otf --file=NotoSansJP-Regular.otf
 ⛅️ wrangler 2.16.0
--------------------
Creating object "fonts/NotoSansJP-Regular.otf" in bucket "ogp-generation-test".
Upload complete.

用意した bucket をコードから使えるように、 wrangler.toml に記載します。
なお、 preview_bucket_name にも同名の bucket を指定していますが、これは開発時に local server を立ち上げた時に利用される bucket となります。
本来は開発用の bucket も別で作成して環境毎に切り替えるべきなので、この点ご注意ください。
詳しい内容はドキュメントのR2 bucketsの項を参照してください。

wrangler.toml
...

+[[r2_buckets]]
+binding = "OGP_GENERATION_TEST"
+bucket_name = "ogp-generation-test"
+preview_bucket_name = "ogp-generation-test"

OGP 画像の生成

ここからは R2 に配置したフォントを使用して、OGP 画像の生成していきます。

コードは下記のようなものを用意します。

index.tsx
import satori, { init } from "satori/wasm";
import initYoga from "yoga-wasm-web";
import { Resvg, initWasm } from "@resvg/resvg-wasm";

// WASMのimport周り、うまいこと設定出来ていないので一旦relative指定かつts-ignoreさせてください
// @ts-ignore
import yogaWasm from '../node_modules/yoga-wasm-web/dist/yoga.wasm';
// @ts-ignore
import resvgWasm from '../node_modules/@resvg/resvg-wasm/index_bg.wasm'

// module類の初期化
init(await initYoga(yogaWasm as WebAssembly.Module));
await initWasm(resvgWasm);

// フォントファイルを格納するための変数
let fontArrBuf: null | ArrayBuffer = null;

// `fetch()` の第2引数として渡ってくる `env` に対してR2 bucketの生やすために、型を拡張して定義しておきます。
type Handler = ExportedHandler<{
  OGP_GENERATION_TEST: R2Bucket
}>;

// 型付けしているのをわかりやすくするために、型定義と導入を上部で行い、ファイル下部で `export default handler` しています。
// `export default { ... } as Handler` とする形もあり得ると思います。
const handler: Handler = {
  fetch: async (request, env) => {
    // フォントファイルをまだ取得していなければ、取得してArrayBufferとして格納
    if (fontArrBuf === null) {
      const fontObj = await env.OGP_GENERATION_TEST.get(
        "fonts/NotoSansJP-Regular.otf"
      );

      if (fontObj === null || typeof fontObj === 'undefined') {
        return new Response("Font nai desu...", {
          status: 500,
          headers: {
            "Content-Type": "text/plain",
          },
        });
      }

      fontArrBuf = await fontObj.arrayBuffer();
    }

    const ZeroMarginParagraph = ({
      children,
    }: {
      children: React.ReactNode;
    }) => <p style={{ margin: 0, padding: 0 }}>{children}</p>;

    const ogpNode = (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          background: "linear-gradient(to bottom, #4481F9, #CC61A4)",
        }}
      >
        <div
          style={{
            padding: "48px 96px",
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "center",
            background: "#25293D",
            fontSize: "76px",
            color: "white",
          }}
        >
          <ZeroMarginParagraph>Chottoshita</ZeroMarginParagraph>
          <ZeroMarginParagraph>OGP Gazou</ZeroMarginParagraph>
          <ZeroMarginParagraph>Desu</ZeroMarginParagraph>
        </div>
      </div>
    );

    // Satoriを使ってsvgを生成する
    const svg = await satori(ogpNode, {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: "NotoSansJP",
          data: fontArrBuf,
          weight: 100,
          style: "normal",
        },
      ],
    })

    // og:imageはsvgは対応していないので、pngに変換する
    const png = (new Resvg(svg))
      .render()
      .asPng();

    return new Response(png, {
      headers: {
        "Content-Type": "image/png",
        // ブラウザでもキャッシュしてもらいましょうか
        "Cache-Control": "max-age=604800",
      },
    });
  },
}

export default handler

npx wrangler publish でデプロイします。

❯ npx wrangler publish
 ⛅️ wrangler 2.16.0
--------------------
Your worker has access to the following bindings:
- R2 Buckets:
  - OGP_GENERATION_TEST: ogp-generation-test
Total Upload: 2207.46 KiB / gzip: 712.04 KiB
Uploaded experimental-ogp-generation (3.95 sec)
Published experimental-ogp-generation (3.95 sec)
  https://experimental-ogp-generation.*****.workers.dev

アクセスして試してみます。(スクショは 127.0.0.1 にアクセスしたものですが、publish した環境でも動くことを確認しています。👍)

OGPを生成した例: 紫グラデーション背景にChottoshita OGP gazou Desuの文字

うまく生成できていますね。🎉

Cloudflare R2 を使ったキャッシュの実装

最後に、生成した画像を R2 でキャッシュする処理を追加します。
キャッシュから返却できているかどうかを検証するために、とってもオリジナルなヘッダーを追加します。
なお、キャッシュする画像の名前には param というクエリパラメータで渡された文字列を使用します。
もしこの仕組みを本番で稼働させる場合には、ここで渡された文字列を元に、OGP 内に描画する情報(文字列や埋め込む画像)を切り替える想定です。

index.tsx
 // `export default { ... } as Handler` とする形もあり得ると思います。
 const handler: Handler = {
   fetch: async (request, env) => {
+    // リクエストURLのパラメータを取得
+    const requestUrl = new URL(request.url)
+    if (!requestUrl.searchParams.has('param')) {
+      return new Response("Hissu parameter ga nai desu...", {
+        status: 500,
+        headers: {
+          "Content-Type": "text/plain",
+        },
+      });
+    }
+    const exampleParam = requestUrl.searchParams.get('param')
+
+    // R2 bucket内にキャッシュされた画像があれば、それを返す
+    const cachedImage = await env.OGP_GENERATION_TEST.get(
+      `ogp-image-caches/${exampleParam}.png`
+    );
+    if (cachedImage !== null && typeof cachedImage !== "undefined") {
+      return new Response(await cachedImage.arrayBuffer(), {
+        headers: {
+          "X-Is-Cached": "true",
+          "Content-Type": "image/png",
+          "Cache-Control": "max-age=604800",
+        },
+      });
+    }
+
     // フォントファイルをまだ取得していなければ、取得してArrayBufferとして格納
     if (fontArrBuf === null) {
       const fontObj = await env.OGP_GENERATION_TEST.get(

...

     // og:imageはsvgは対応していないので、pngに変換する
     const png = new Resvg(svg).render().asPng();
+
+    // 生成した画像をR2 bucketにキャッシュしておく
+    await env.OGP_GENERATION_TEST.put(
+      `ogp-image-caches/${exampleParam}.png`,
+      png
+    );

     return new Response(png, {
       headers: {
         "Content-Type": "image/png",
         // ブラウザでもキャッシュしてもらいましょうか
         "Cache-Control": "max-age=604800",
       },
     });

...

再度 deploy(npx wrangler publish)し、試してみましょう。
一度目のアクセスで R2 bucket に画像が保存されるので、二度アクセスしてキャッシュから返却されていることを確認します。(同じくスクショは127.0.0.1で撮影したものです。)

Cacheから画像が返されている例: headerがで返却されている

とってもオリジナルな謎ヘッダーが見えているので、キャッシュから返されていることがわかりますね。

まとめ

お疲れさまです。
ここまでで、Cloudflare Workers 上で Satori を使って OGP 画像を生成、それを R2 上でキャッシュするところまでが構築出来ました。
一方で、 Production 環境で運用するためにはもう少し深めて実装していく必要があります。
それこそ「外部から画像を読み込んで OGP 画像内に埋め込み」などを考えるのであれば、Workers の最長実行時間の延長などいくつかの検討/検証が必要そうです。
今回紹介した内容は、それぞれの要件に合わせて適宜アレンジして頂ければと。
それでは、本記事をご参照頂きありがとうございました。

Wanted!!

BEENOS グループでは一緒に働いて頂けるエンジニアを強く求めております!
少しでも気になった方は、社内の様子や大事にしていることなどをThe BEENOSにて発信しておりますので、是非ご覧ください。

https://beenos.com/blog/

とても気になった方はこちらで求人も公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな...?」と思った方はオープンポジションとしてご応募頂けると大変嬉しいです。🙌

世界で戦えるサービスを創っていきたい方、是非ご連絡ください!よろしくお願い致します!!

世界で戦えるサービスを創っていく


BEENOS Tech Blog

Discussion