🤔

On-demand ISR でブログを更新してみる

2022/08/01に公開

現在自身のWebサイトをISRにて実装しているのですが、これをOn-demand ISRに置き換えてみようと思います。

そもそもどんなWebサイト?

ブログや自己紹介を乗っけているよくあるWebサイトです。
ブログは CMS から取得しており、1分という 間隔で revalidate するよう設定をしてISR`をしています。
なぜ1分ごとなのかというと、CMSの予約投稿が分単位で設定できることが影響しています。

なんで置き換えるの?

いくつかありますが、大きくは以下の点です。

・サイトをそこまで頻繁に更新しない
・サイトにそこまで人が来ない
・1分おきに検証しているため、Firestoreの使用料金がかさむ

特に大きい点はFirestoreの使用料金ですかね。。
サイトを頻繁に更新しないのに、1分以上経った後にアクセスする度Firestoreへブログのデータを取りにいくため、無駄なデータフェッチが多くなると予想がつきます。Firestoreはネットワークの帯域量や、取得するデータ数に応じて課金されるため、アクセスは少なければ少ない方がいいです。
他には、サイトに訪れている人に古いキャッシュデータを見せる可能性が低くなることも挙げられます。従来のISRでは、Webページを誰かが表示したこと(ページのデータを取得すること)がトリガーとなり、再検証が行われていました。そのため、トリガーとなるアクセスをした訪問者には、再検証•再生成が完了するまで古いページ内容を見せることになってしまいます。しかし、On-demand ISRでは任意のタイミングで再検証が可能なため、ユーザーが画面を表示する時には既に新しいページが生成され、スムーズに画面を表示できるケースが多くなることが期待されます。

方針を決める

あれこれ理由を書いたので、次はどうやって実装するのか考えます。今回は、ブログ記事の更新・削除のタイミングのみで再生成を実施してみます。
how_to_create

上の画像のように、まずは投稿を更新・削除し、その処理が完了したらWebページを再生成するとします。

やってみる

あれこれ方針を述べたので、次は実装してみます。以下のドキュメントを参考に進めていきます。
https://nextjs.org/docs/basic-features/data-fetching/incremental-static-regeneration#on-demand-revalidation
実装方針ですが、公開しているWebサイトにDynamic API Routesを設置し、そのAPIを叩くことで再生成できる様にしてみます。
具体的には、/pages/api/revalidate/[...slug].tsを作成して、slugから再生成対象のリンクを取得しようという考えです。

ドキュメントを参考(ほぼパクリ)に、作成しました。

import { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const query = req.query
  const slug = query.slug 
	  ? ['', ...(query.slug as string[])].join('/') 
	  : null

  if (!slug) {
    return res.status(404).json({
      message: 'revalidate target not found'
    })
  }

  try {
    // this should be the actual path not a rewritten path
    // e.g. for "/blog/[slug]" this should be "/blog/post-1"
    await res.revalidate(slug)
    return res.json({ revalidated: true })
  } catch (err) {
    // If there was an error, Next.js will continue
    // to show the last successfully generated page
    return res.status(500).send('Error revalidating')
  }
}

再生成したいページのリンクを/api/revalidate/の後に追記することで、そのページを再生成します。指定されたページは、ページ内のgetStaticPropsが実行され再生成が始まります。

// /blog/hello が再生成される関数
const revalidateHelloArticle = () => {
   const url = 'http://localhost:3000/api/revalidate/blog/hello'
   await fetch(url)
}

ここで、getStaticProps内でエラーが出たりすると、再生成がは中断され、res.revalidateはエラーを投げます。失敗すると、再生成を試みたページが全く見れなくなるとかそういうことはなく、再生成前のページを表示し続けます。

検証してみる

簡易にコードを書いたので、実際に検証してみます。
表示するページのgetStaticPropsは以下の様な感じ。
60秒ごとにrevalidateします。

export const getStaticProps = async () => {
  const origin = process.env.ORIGIN
  const data = await fetch(`${origin}/api/post`)
  const posts: Post[] = await data.json()

  return {
    props: {
      posts: JSON.parse(JSON.stringify(posts))
    },
    revalidate: 60
  }
}

公式ドキュメントにて、「on-demand ISRを検証する際は、ビルドしてプロダクションで起動してね」と言われているのでその通りにします。

When running locally with next dev, getStaticProps is invoked on every request. To verify your on-demand ISR configuration is correct, you will need to create a production build and start the production server.Then, you can confirm that static pages have successfully revalidated.

以下のコマンドでビルド・起動します。

$ next build
$ next start

起動するところまで来たので、確かめてみましょう!

① 初回表示(再生成の期限を過ぎている)

初回で表示する際は、ページが生成されていません。この初回表示をきっかけにページを生成し始めます。以下の画像は初回表示時のレスポンスの様子です。

x-nextjs-cacheSTALEとなっています。STALEというステータスは、「キャッシュはあるけど古いから作り直すよ〜」という意味になります。以下ドキュメントからの引用です。

STALE: The response that was served from the edge was stale. A background request to the origin server was made to get a fresh version.

もう少し紐解くと、getStaticPropsに設定したrevalidateの設定によってページの再生成が実行されたということになります。

x-nextjs-cacheは、Vercel上ではx-vercel-cacheと名前を変えて設定されているようです。x-vercel-cache及びx-nextjs-cacheの詳細は以下のリンクで確認できます。
https://vercel.com/docs/concepts/edge-network/caching#x-vercel-cache

② 60秒以内に再表示する(revalidate の期限内に再表示する)

この場合は有効なキャッシュがまだ残っているため、再生成することはなく表示されることが予想されます。レスポンス内容はこんな感じです。

今度は、x-nextjs-cacheの値はHITとなっています。HITは有効なキャッシュがあり、そのキャッシュが返却されたということを示しています。以下ドキュメントからの引用です。

HIT: The response was served from the edge cache.

③ 60秒経過後、res.revalidateを実施してから再表示する

これが今回の検証のメインです。やってみるの章で実装したAPIを呼び出して、res.revalidateを走らせます。

{ revalidate: true }が返却され、再生成が成功しました。この後60秒以内にページにアクセスします。すると、x-nextjs-cacheHITとなっています。

getStaticPropsにおけるrevalidateの期間である60秒を過ぎているにもかかわらず、HITが表示されています。これはres.revalidateが実行された際にあらためてページの再生成が行われたことが影響しており、その時生成されたページが返却されているためです。

以上の検証から、res.revalidateを実施することで訪問者がページにせずとも任意のタイミングでページを再生成できることが確認されました!

気をつけたいポイント

上の検証より、より高速なページの表示やデータベースの使用料金の減量が期待できますが、その裏で気をつけたいポイントを確認しておきます。
On-demand ISRは、任意のタイミングでデータ再取得・ページの再構成ができる機能です。getStaticPropsにおけるrevalidationもそうですが、裏を返せば特定のタイミングでないとページの再生成がされません。
そのため再生成に失敗すると、ある一定期間 「データを更新したのに、反映されていない。。」とか、 「ページを再生成したのに、内容が更新されていない。。」 という、データとページ間の整合性が不安定になるケース が CSR や SSR と比較して多くなると考えられます。
このようなケースを防ぐためにも、再生成失敗時はどのように処理をするのかしっかりと定義して、実装する必要があります。

ブログの更新•削除において考えられるケースとして、以下のパターンが考えられます。

再生成に失敗した場合...

1. 再生成前のデータを表示し続ける
2. 404として、ページを非表示にする
3. 特定のページにリダイレクトさせる

1. 再生成前のデータを表示し続ける

記事データは更新されていますが、ページ上ではその内容が反映されておらず、更新前の内容で表示し続ける方式です。
この方式のメリットは、古い内容でも記事を表示し続けてくれる点です。
デメリットとしては、保存されているデータとページ上の表示内容が異なる点です。また、削除した投稿については特に影響が大きく、「削除したのに表示されている。。」 という想定外の事態も起きてしまいます。
個人的にですが、これを許容するとそもそも記事を管理できてると言えるのか?と思ってしまうため、あまりおすすめできないのかなあと思います。
getStaticPropsrevalidateにて、一定の期間ごとに再生成することで、永続的な不整合は避けられる可能性が高くなります。

2. 404として、ページを非表示にする

個人的にいいなと思う方法です。再生成に失敗した場合、404を表示する方法です。更新に失敗した投稿について、どのように認識するのかによって考え方が変わると思うのですが、私は編集内容が反映されていない投稿はそもそも公開されていないのと変わらないと捉えているので、404を表示するのが妥当な気がしています。
また、削除した投稿についても404として表示されることで、再生成には失敗しているけど結果として非表示になります。

3. 特定のページにリダイレクトさせる

2とほぼ同じ方式です。再生成に失敗した場合に、特定のページにリダイレクトさせる方法です。2との違いといえば、削除した投稿について削除できなかったときに404以外のページに飛ばせることが一つ挙げられます。サイトの訪問者に対して、404の見つかりませんでしたというメッセージ以外に何か伝えたい場合 はリダイレクト処理で解決できるかもしれません。

おわりに

On-demand ISRは便利な機能で、APIを叩くだけで再生成できたり、ページ表示が速くなるなどのユーザー体験の向上が期待できたりと、
試してみる価値はある機能だと感じました。
しかし、記事が更新できなかったなど整合性に欠ける部分があると思うので、採用する場合は再生成に失敗した時にどのように処理するのかをプロダクトごとに考える必要があると思いました。
記事内容には含めませんでしたが、商品情報の更新などは1つのページの更新に収まらない場合もあるので、データを扱うページの範囲の確認なども行わないと行けなかったら、考えることは割と多そうです。

失敗時の処理については、別の記事もしくはこの記事に追加する形で後日書いてみたいと思います。
また、上の記事の内容だけでは予約投稿機能を実現することができません。投稿データの保存時に、バッチの登録等をして再生成用のAPIを叩く必要があります。予約投稿におけるOn-demand ISRについても後ほど書いてみたいと思います。
長々と失礼いたしました🙇‍♂️
個人の見解•調査内容のため、何か疑問点や間違っている点、もしくはこんな場面ならISR使うよ!などありましたらコメントにてお願いいたします🙇‍♂️🙇‍♂️🙇‍♂️

Discussion