logo

アルパカログ

Notion API 画像URLの期限切れ対策を3つ紹介する

Notion APIのように外部で期限付きで発行された画像URL(AWS S3 signed URL)とHTMLのキャッシュは相性が悪く、対策をしなければ簡単に画像が切れた状態のHTMLを配信することになってしまいます。

この問題に頭を悩ませている開発者はたくさんいることでしょう。私もその一人でした。

そんな開発者たちに向けて、この記事ではNotion APIが返す期限付きの画像URLの画像が切れてしまうメカニズムを解説し、その対策を3つ紹介します。

目次

画像が切れてしまうメカニズム

画像が切れてしまうメカニズムを理解するためには、まずCDNの仕組みを理解しておく必要があります。

CDNの仕組み

Webページが表示されるまでのステップを見てみましょう。

画像が読み込まれない場合はページを更新してみてください。
期限付き画像URLを含むHTMLの流れ

ユーザーがWebページをリクエストしたとき、まず最初にCDNにWebページがリクエストされます(上図①)。

CDNはユーザーから物理的に近いサーバー(エッジサーバー)からコンテンツを配信することで高速化を実現するサービスで、サービスを提供しているサーバー(オリジンサーバー)の負荷軽減の用途としても利用されます。

CDNの有名どころとしては Cloudflare や Fastly などがあります。Vercel もCDNに強みを持つ会社の一つです。

CDNにWebページがリクエストされたとき、CDNに対象のWebページのキャッシュがない場合は、そこからさらにオリジンサーバーにリクエストされます(上図②)。

オリジンサーバーは対象のWebページをCDNにレスポンスします(上図③)。

このとき、対象のWebページを事前に生成しておく方式を静的生成リクエストされたときにWebページを生成する方式を動的生成と言います。

WebページにはNotion APIが毎回ランダムに返す画像URL(期限付きURL)が含まれています。

期限付きURLの期限はURLが発行されたとき、つまりWebページを生成するためにAPIリクエストしたときが起点となります。

オリジンサーバーからWebページを受け取ったCDNは、Webページのデータを自身に保存(キャッシュ)し、ユーザーにWebページのデータをレスポンスします(図④⑤)。

CDNがWebページのデータをキャッシュするのは、次に同じWebページがリクエストされた際に高速にレスポンスするためです。

下図をご覧ください。

画像が読み込まれない場合はページを更新してみてください。
CDNはキャッシュがあればキャッシュを返す

CDNはリクエストされた対象のWebページのキャッシュを持っていれば、オリジンサーバーにリクエストすることなくユーザーにレスポンスします。

大幅にステップが減っていますね。

次に、どうして期限付きURLの画像が切れてしまうのか見ていきましょう。

期限付きURLとキャッシュ

先ほど、期限付きURLの期限はWebページが生成されたときから起算されていると言いました。

画像URLの期限が24時間だとします。

もし仮に、CDNが最初にキャッシュを作ったきり更新しないとどうなるでしょうか?

キャッシュはすでに生成されたWebページなので、そこに含まれている画像URLは古くなっています。

生成から24時間以上経過すると画像が切れてしまうというわけです。

実際には、CDNにそういう指示をしない限りキャッシュが更新されないままということはありません。

しかし、いくつかの状況ではそれが起こり得ます。

そのうちの一つがISRで、もう一つはTwitterでURLをシェアした際のアイキャッチ画像(OG画像)のようにサービスにOG情報がキャッシュされてしまうケースです。

ISRで期限付きURLが切れてしまうケース

ISRは Incremental Static Regeneration の略で、日本語では「段階的な静的サイト生成」と訳されます。

ISRは静的生成と動的生成の双方のメリットを取り入れた手法で、仕組みとしては最初にWebページを静的生成しておき、リクエストがあれば静的生成しておいたWebページ(キャッシュ)を返しつつ、Webページが古くなっていれば裏で再生成するというものです。

ポイントは、再生成が完了するまでは古くなったページをとりあえず返すというところで、この考え方を Stale While Revalidate と言います。

💡
ちなみに Vercel 社が React フックとして開発している SWR ライブラリはこの考えを発展させて古くなったページ上で新しいデータを取得してページを更新するというものです。

もうお気付きかと思いますが、ISRではどれだけページが古くなっていてもリクエストされればとりあえず古いページをレスポンスします。

ページに含まれている画像URLの期限が切れてしまっているということが起こり得るわけです。

TwitterなどでOG画像が切れてしまうケース

Notion APIから取得した期限付きの画像URLをOG画像に使っている場合、ページが生成されて間もない頃はTwitterでシェアした際に画像がきちんと表示されるのに、しばらくすると画像が切れてしまうということが起こります。

これは推測ですが、おそらくTwitterでページをシェアした際にページに含まれるOG情報をTwitterが記憶(キャッシュ)しているのではないかと思っています。

もしそうであれば、最初にページをシェアしたときのOG画像URLを持ち続けているために、時間経過によって画像が切れてしまうということが起こり得ます。

ここまで期限付きURLによって画像が切れてしまうケースを説明してきました。

次は、これらの問題への対策としてどうすれば良いかを見ていきましょう。

対策1: Data URIスキームを使う

HTMLで画像を使うには img タグを用いますが、画像ソースを指定する src プロパティに指定できるはURLやパスだけではないことをご存知でしょうか?

URLやパスといった画像の場所ではなく、画像自体のデータをエンコードしてHTMLに埋め込むData URIスキームという手法があります。

画像の場所を期限付きURLで指定しているから開くたびに期限に左右されるのであって、画像データそのものを埋め込んでしまえば期限は関係ないというわけです。

ただし、Data URIスキームにはデメリットもあります。

画像URLであればブラウザやCDNがキャッシュすることでページの高速化が見込めますが、Data URIスキームではデメリットとしてページを開くたびに画像をデコードする処理が必要になることが挙げられます。

Data URIスキームについては下記の記事をご覧ください。

また、OG画像はURLで指定する必要があるためData URIスキームは使えないことにも注意が必要です。

対策2: クライアント側で期限切れを判定して画像URLを取得し直す

ページが表示されたときに、クライアント側のJavaScriptで画像の期限切れを判定して再度画像URLを取得し直すことができれば、一瞬だけ期限切れ画像が表示された後、きちんと画像を表示することができます。

これを実現するには、前述したSWRライブラリが便利です。

筆者が開発している easy-notion-blog でもこの方法を採用しています。

Notion APIは、Notionにアップロードした画像ごとに期限付きURLの期限を返してくれます

この情報を使って画像の期限切れを判定し、必要に応じて画像を取得し直すことができます。

例えば easy-notion-blog ではSWRを使って下記のようにしています。

const isExpired = (block: Block): boolean => {
  const now = Date.now()

  if (block.Type === 'image') {
    const image = block.Image
    if (image.File && image.File.ExpiryTime && Date.parse(image.File.ExpiryTime) < now) {
      return true
    }
  }
  return false
}

const ImageBlock = ({ block: initialBlock }) => {
  const { data: block } = useSWR(isExpired(initialBlock) && initialBlock.Id, fetchBlock, { fallbackData: initialBlock })
  // ...

useSWR フックの第1引数にはリクエストごとにユニークなキーを、第2引数にはデータを取得する関数を渡します。

重要な点として、第1引数が falsy な値の場合にはデータ取得関数が呼ばれません。

上記のコード例では第1引数は isExpired(initialBlock) && initialBlock.Id になっています。

isExpired() メソッドでは画像の期限切れを判定しており、期限が切れていると true を返します。

論理積 && では左側の値から順に評価されます。

期限が切れていない場合には false && ... となり、その時点で論理積の評価が終了し false を返します。

期限が切れている場合には true && initialBlock.Id となり右側の initialBlock.Id が評価されます。

このとき一番右側の評価値が返されます。この場合はブロックIDです。

ブロックIDは falsy な値ではないので、期限切れの場合には第2引数のデータ取得メソッドが実行されます。

この方法の欠点としては、画像の期限情報のようにデータ再取得の要不要を判定するための情報が必要なことが挙げられます。

また、SWRはあくまでJavaScriptによって画像を取得し直す仕組みであるため、OG画像では使えません。

対策3: 標準化したURLで画像を返すAPIを定義する

さて、ここまで挙げた2つはどちらもOG画像が切れてしまう問題の対策にはなりませんでした。

OG画像が切れてしまう原因は、TwitterなどのSNSが画像URLを記録してしまうこと、画像URLに期限があること、そしてそれに付随して画像URLが発行毎に変わることです。

つまり、無期限で固定の画像URLを用意することができればこの問題を解決することができます。

期限付きURLを直接OG画像に指定するのではなく、OG画像用にAPIを新たに定義して、みかけ上は固定されたURLで画像を取得できるようにします(下図)。

画像が読み込まれない場合はページを更新してみてください。
画像URLを固定するためにAPIを定義する

APIでは期限付きURLから画像を取得し画像自体をレスポンスします

画像自体をレスポンスすることで期限を無くしているところは、前述したData URIスキームを使う方法と同じ考え方ですね。

APIでは都度画像を取得するので、画像が期限切れになることはありません。

下記は筆者の easy-notion-blog のコード例です。

リクエストパラメータとして与えられた slug から記事を取得し、その記事のOG画像を取得してバイナリとしてレスポンスしています。

// ...

const post = await getPostBySlug(slug)

// ...

const { rawBody: image, headers: headers } = await got(post.OGImage)

res.setHeader('Content-Type', headers['content-type'])
res.setHeader('Cache-Control', 'public, s-maxage=86400, max-age=86400, stale-while-revalidate=86400')
res.write(image)
res.statusCode = 200
res.end()

// ...
ℹ️
APIレスポンスは上記コード例のようにCDNにキャッシュさせてNotion APIへのリクエストが多くなりすぎないようにします。

これで筆者も長い間悩まされていたOG画像が切れてしまう問題を解決することができました。

おわりに

この記事ではNotion APIが返す期限付きの画像URLの画像が切れてしまうメカニズムを解説し、その対策を3つ紹介しました。

今回は画像を前提として話をしましたが、Notion APIでは画像以外にも動画やPDFも同じように期限付きURLとしてレスポンスされるため、もし期限付きURLの扱いで困っている方はこの記事で紹介した方法をお試しください。