🌟

【個人開発向け】Next.jsのSSGで公開されたページの再ビルドをWebAPIのデータに変更があったときのみ実行する方法を考えてみた

2022/05/22に公開

はじめに

Next.jsにはSSGやISRなど事前に静的なHTMLをサーバー側に生成してクライアントへの応答速度を早める方法がありますが、SSGはデプロイ時のみビルド、ISRはSSGの機能にプラスしてクライアントからページにアクセスがあった時は、一度古い文書を表示し、裏で新しい文章を作成する方式のため、アクセス数が少ない状態でmicroCMSや外部APIを活用したサイトを作る場合、新しい情報を表示できないことがありました。

そのため、何か良い方法がないか検討したところ、VercelをWebhookからビルドをかけられる仕組みとGASのトリガーを組み合わせれば、SSGでクライアントの表示速度を早めつつ、なるべく最新の情報を表示し、かつ、ビルド回数をなるべく減らすことができるのではないかと思い、試しに実装してみました!

https://zenn.dev/unemployed/articles/vercel-deployhook
https://blog-and-destroy.com/35720

実際の活用方法はサービス運用に使う想定ではなく、個人開発のポートフォリオレベルを想定していますので、試される際は自己責任でよろしくお願いします!

工夫した内容

今回、実現にあたり考慮した内容は、SSGの問題を解決できるとされているISRでは、WebAPIの更新状況に関係なくビルドをかけてしまうため、アクセス数に応じて内容の書き換えが頻繁に行われてしまう可能性があるため、SSGを採用した上で、GAS上でWebAPIの変更をチェックさせる処理を定時実行させて差分があった場合のみVercelのビルド用のWebhookを呼ぶスクリプトを組む方法で対応しました!

また、WebAPIのデータを全て保持するのではなく、hash化したものを保持させ、保存先もGASが提供しているキャッシュに保存することで、GASのスプレッドシート上に保存するのに比べて実行時間をなるべく高速に処理できるようにしました!

サンプル コード

GASスクリプト

以下のスクリプトのcheckTrigger()をGASのトリガーで定期実行することで、
WebAPIのレスポンスデータに差分があった場合のみ、Vercelのビルド用Webhookを呼ぶようにできます!

コード
コード.gs
// WebAPIからjsonを取得する処理を関数化(任意)
function getNotion() {
  const url = 'https://api.notion.com/v1/';
  const databaseId = '{databaseId}';
  const token = '{token}';
  const options = {
    method: 'post',
    headers: {
      Authorization: 'Bearer ' + token,
      'Notion-Version': '2022-02-22',
    },
    payload: {},
  };
  const endpoint = `databases/${databaseId}/query`;
  const res = UrlFetchApp.fetch(url + endpoint, options);

  // request timeなどのオプション情報で差分が発生する場合があるため、
  // 差分確認に必要な情報のみに絞り込みを事前にしておく!
  const jsonData = JSON.parse(res)?.results.map(item=>{
    return {
      id: item.id,
      type: item.properties.type?.select?.name || '',
      last_edited_time: item.last_edited_time
    };
    }).filter(item=>{
      return item.type === 'public';
    })
  return jsonData;
}

function checkTrigger() {
  // SSGで使用するWebAPIのデータを取得
  const newData = getNotion()
  // GASのキャッシュの保存先を分けるために仮で実行しているスクリプトIDを設定
  const cacheKey = ScriptApp.getScriptId()

  // 今回作成したWebAPIの更新検知用のライブラリ
  const objectUpdateHooker = UpdateHooker.init({
    cacheKey: cacheKey,
    simpleWebHookUrl: 'https://api.vercel.com/v1/integrations/deploy/{id}',
  })
  objectUpdateHooker.check(newData)
}

自作したGASライブラリ

スクリプトID:1T_XqXi8-ykWCx-5ybkaeO7hcjib3WJkoq75F2QTgRKU9KcsZgsK1nnOR

使い方

定義済みの関数は2つのみで機能拡張しやすいようにinitに設定するoption値で処理を切り替える仕様にしました!

GASのキャッシュの保持期間が6時間が上限である関係上、時間主導型トリガーの設定は最長でもGASで設定できる『4時間おき』以内で、『1時間おき』に設定しておくのがちょうどいいかと思います!

また、スプレッドシートをデータソースにする場合に、スプレッドシートのセルの編集をトリガーにすることもできますが、頻繁に変更が走ることになるため、本ライブラリとの相性が良くないためその点はご注意いただけますと幸いです🙇🏻‍♂️💦

function checkTrigger() {
  // ライブラリに定義されているクラスをインスタンス化する関数(init)
  const objectUpdateHooker = UpdateHooker.init(option)

  // データの差分をチェックして必要に応じてwebhookを呼ぶ関数(check)
  objectUpdateHooker.check(jsonData)
}

init関数

  • 引数:option設定

    項目名 初期値 備考
    overwriteHookFunction function null WebhookへのGET以外に複雑な処理を定義するためのCallback関数を定義
    simpleWebHookUrl string null WebhookのGET先URLを指定
    triggerChangeData boolean true データの差分があった場合にhookするか、差分がなかった場合にhookするかを選択
    cacheHoldTime number 60 * 60 * 6 キャッシュの保持期間を秒単位で設定(デフォルトが最大値 = 6時間)
    cacheKey string 空白文字 データのhash値の保存先を振り分けるために任意のキャッシュキーを指定

check関数

  • 引数:jsonData

    hash値化するために内部でJSON.stringify(jsonData)を実行しています!

ライブラリ コード

実際のコード
UpdateHooker.gs
// - [クラスキャッシュ| Apps Script | Google Developers](https://developers.google.com/apps-script/reference/cache/cache)
// - [【GAS】HMAC SHA 256を使って、文字列をキーでハッシュ化する方法](https://skill-upupup-future.com/?p=1582)

/**
 * UpdateHookerClassをインスタンス化するためのファクトリメソッド
 * @param {object} option オプションを設定
 * @return {UpdateHooker} ←の{}の中にライブラリのGASプロジェクトと同じ名前を指定する。
 */
function init(option) {
  return new UpdateHookerClass(option)
}

/**
 * データの更新状況を確認します。
 * @param {{
 *    overwriteHookFunction: function,
 *    simpleWebHookUrl: string,
 *    triggerChangeData: boolean,
 *    cacheHoldTime: number,
 *    cacheKey: string,
 *  }} jsonData チェックするデータを設定
 */
function check(jsonData = {}) {
  throw new Error('createHogeを呼び出してから呼び出してください。')
}

;((global) => {
  // クラスを定義する
  function UpdateHookerClass(option) {
    Logger.log(`UpdateHookerClass init`)
    Logger.log(`option : ${JSON.stringify(option, null, '  ')}`)
    // デフォルトの設定を定義する
    this.defaultOption = {
      overwriteHookFunction: null,
      simpleWebHookUrl: null,
      triggerChangeData: true,
      cacheHoldTime: 60 * 60 * 6, // 6時間
      cacheKey: '',
    }
    // initで指定されたオプションを上書きする
    this.option = { ...this.defaultOption, ...option }
  }

  // 文字列データをハッシュ化するための関数
  UpdateHookerClass.prototype.makeHash = function (text, SECRET_KEY) {
    const rowHash = Utilities.computeHmacSignature(Utilities.MacAlgorithm.HMAC_SHA_256, text, SECRET_KEY)
    let txtHash = ''
    for (let i = 0; i < rowHash.length; i++) {
      let hashVal = rowHash[i]
      if (hashVal < 0) {
        hashVal += 256
      }
      if (hashVal.toString(16).length === 1) {
        txtHash += '0'
      }
      txtHash += hashVal.toString(16)
    }
    return txtHash
  }

  // WebHookのトリガーを発火するための関数
  UpdateHookerClass.prototype.triggerHook = function () {
    if (this.option.simpleWebHookUrl) {
      const url = this.option.simpleWebHookUrl
      const reg = /^https?:\/\/[-_.!~*'()a-zA-Z0-9;\/?:\@&=+\$,%#]+$/
      if (reg.test(url)) {
        UrlFetchApp.fetch(url)
        Logger.log(`triggerHook : ${url}`)
      }
    } else if (typeof this.option.overwriteHookFunction === 'function') {
      this.option.overwriteHookFunction()
      Logger.log(`triggerHook : overwriteHookFunction`)
    }
  }

  // キャッシュチェック用の関数
  UpdateHookerClass.prototype.check = function (jsonData = {}) {
    // キャッシュ用のKeyを作成する
    const cacheKey = `cache_data${this.option.cacheKey ? `_${this.option.cacheKey}` : ''}`
    Logger.log(`cacheKey : ${cacheKey}`)
    // hashキーを作成する
    const hashSecretKey = `secret_${cacheKey}`
    // Userキャッシュを取得する
    const cache = CacheService.getUserCache()
    // 引数で与えられたJSONデータをハッシュ化する
    const newDataHash = this.makeHash(JSON.stringify(jsonData), hashSecretKey)
    // キャッシュに保存されているデータのハッシュを取得する
    const oldDataHash = cache.get(cacheKey)
    Logger.log(`DataHash : ${oldDataHash} <=> ${newDataHash}`)
    // ハッシュが一致しているか確認する
    if (oldDataHash !== newDataHash) {
      // ハッシュが一致していない場合は、キャッシュを更新する
      cache.put(
        cacheKey,
        newDataHash,
        this.option.cacheHoldTime ? this.option.cacheHoldTime : this.defaultOption.cacheHoldTime
      )
      // データが変更された場合は、WebHookをトリガーする
      if (this.option.triggerChangeData) {
        this.triggerHook()
      }
      Logger.log('hook done')
    } else {
      // ハッシュが一致している場合は、キャッシュ期間を延長するために同じ値でキャッシュを更新する
      cache.put(
        cacheKey,
        oldDataHash,
        this.option.cacheHoldTime ? this.option.cacheHoldTime : this.defaultOption.cacheHoldTime
      )
      // データが変更されていない場合、かつ、triggerChangeDataがfalseの場合は、WebHookをトリガーする
      if (!this.option.triggerChangeData) {
        this.triggerHook()
      }
      Logger.log('no changes')
    }
  }

  // クラスをグローバルに公開する
  global.UpdateHookerClass = UpdateHookerClass
})(this)

さいごに

今回紹介したライブラリを使用することで少しでもみなさんの開発に少しでもお役に立てればと思いますのでぜひ使用してみたご感想などをお聞かせ頂けると嬉しいです!

また、この記事の内容は、SSGやISRで対応しにくい点を解消するための手段の一つと考えていますのでより簡単な方法や標準設定で実現可能であればコメントいただけるととてもありがたいです✨

最後まで拙い文章でしたがお付き合い頂きありがとうございました。

関連するサービス

https://nextjs.org/
https://vercel.com/
https://www.google.com/script/start/

参考記事

https://zenn.dev/a_da_chi/articles/105dac5573b2f5
https://developers.google.com/apps-script/reference/cache/cache
https://skill-upupup-future.com/?p=1582

GitHubで編集を提案

Discussion