SPA(Single Page Application) のリビジョンのズレに対応してみた

NuxtJS のサンプルアプリを作って、SPA でリビジョンのズレを解消する方法を解説します。
2022.09.08

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

プロフィールビューアーサービス Proflly(プロフリー)では、SPA(Single Page Application)を採用しています。 SPA の仕組み上、フロントエンドをリリースした際に、クライアント側(ブラウザ)とサーバー側でリビジョンがズレた状態となることがあります。リリースする内容によっては、意図していない不具合が発生することがあります。
今回は、リビジョンがズレた状態を解消するための対策を実装してみたので紹介します。

実装方法の検討

Profllyでは、ホスティングおよび CI/CD に Amplify Console を採用しており、NuxtJS で実装した SPA をホスティングしています。 リリース時は、環境ブランチ(例えば production)に push することで、デプロイが自動で実行されるように設定しています。 このアーキテクチャに組み込む方法を検討していきます。

リビジョンIDの生成

現在リリース済みのリビジョンを特定するために、リビジョンIDを生成する必要があります。
以下のようなIDを検討しました。

  • バージョン番号を生成
    • 1.0.0, 1.0.1, …
  • 単純に数値をインクリメント
    • 1, 2, …
  • UUID や ULID のようなユニークな文字列
    • f23469c8-a675-4643-b0a6-85b2922de856, 535182f1-c636-4207-be2f-a44f5bd832ad, …
    • 01GBPXQ1SQK5YSYVYQCYQZH2BM, 01GBPXQA713VH310VWN20BD3V8, …

今回実現したいことは、クライアント側(ブラウザ)にダウンロードされているアプリケーションと、リリース済みの最新のアプリケーションに差異があるか?を確認したいので、一意の文字列があれば十分です。 ですので、容易に生成することができる ULID を採用することにしました。

リビジョンIDの取得

リビジョンIDは、クライアント側(ブラウザ)にダウンロードされているアプリケーションが保持しているリビジョンIDと、リリース済みアプリケーションのリビジョンIDを取得できる必要があります。 前者は、アプリケーションのビルド時に環境変数から設定することにしました。
後者は、ブラウザからアクセスできる場所に公開する必要があります。容易に公開できるように CloudFront + S3 で公開することにしました。

リビジョンIDを比較するタイミング

比較対象のリビジョンIDは取得することができるようになりましたが、どのタイミングで比較するのが良いかを検討する必要があります。

  • 既存の API リクエスト時にリビジョンIDも取得し比較する
  • ポーリングで定期的にリビジョンIDを取得し比較する

などで対応できそうでしたので、組み込みやすさ、パフォーマンスへの影響などを考慮して、ポーリング で実装することにしました。例えば、30秒に1回最新のリビジョンIDを取得し、アプリケーションに保持しているリビジョンIDと比較する。といった実装です。

実装イメージ

実装

実装方法の検討で決めた内容に応じて実装していきます。

実行環境

  • Node.js: 16.15.1
  • NuxtJS: 2.15.8
  • Vue.js: 2.6.14

Amplify Console のビルドコマンドを作成

リビジョンIDは、デプロイ毎に更新したいので、Amplify Console でデプロイを実行する都度、アプリケーションへの組み込みとリビジョンID確認用のファイルを更新する必要があります。 ですので、リビジョンIDの生成や組み込みを Amplify Console のビルドコマンドで実行されるように実装します。

リビジョンIDを生成する

リビジョンIDは、ULID を利用するので、インストールして、コマンドを実行するだけです。生成したリビジョンIDは、アプリケーションへの組み込みやリビジョンID確認用ファイルの生成時に利用するので、変数に保持するようにしています。

amplify.yml

...
    preBuild:
      commands:
        - yarn global add ulid
        - REVISION_ID=`ulid`
        - yarn
...

リビジョンIDをアプリケーションに組み込む

生成したリビジョンIDは、アプリケーションで保持する必要があるので、アプリケーションの環境変数(APP_REVISION_ID)でアクセスできるように組み込みます。

amplify.yml

...
    build:
      commands:
        - APP_REVISION_ID=$REVISION_ID yarn generate
...

リビジョンID確認用のファイルを配置する

リビジョンID確認用のファイルは、このようなフォーマットで作成します。

revision.json

{
    "revision": "01GBPXQ1SQK5YSYVYQCYQZH2BM"
}

作成したファイルは、S3 バケットの /application/revision.json に配置します。 S3 の前段に CloudFront を利用していますので、今回作成して配置したファイルが参照されるようにキャッシュを削除します。 今回の場合、Amplify Console のビルド時に AWS CLI を使って、AWS リソースの操作を行うので、必要な権限を付与したサービスロールを利用する必要があります。

amplify.yml

...
    postBuild:
      commands:
        - cp -rf static/* dist/
        - printf '{"revision":"%s"}' $REVISION | aws s3 cp - s3://$BUCKET_NAME/application/revision.json --content-type "application/json"
        - aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/application/*"
...

デプロイを実行する

ビルドコマンドができましたので、実際にビルドを実行してみましょう。以下のビルドコマンドは、NuxtJS のアプリケーションをデプロイする場合のサンプルとなります。

amplify.yml

version: 0.1
frontend:
  phases:
    preBuild:
      commands:
        - yarn global add ulid
        - REVISION_ID=`ulid`
        - yarn
    build:
      commands:
        - APP_REVISION_ID=$REVISION_ID yarn generate
    postBuild:
      commands:
        - cp -rf static/* dist/
        - printf '{"revision":"%s"}' $REVISION_ID | aws s3 cp - s3://$BUCKET_NAME/application/revision.json --content-type "application/json"
        - aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/application/*"
  artifacts:
    baseDirectory: dist/
    files:
      - '**/*'

ビルドとデプロイが成功すると、ドメインに記載されているURLでホスティングされているアプリケーションにアクセスすることができます。

リビジョンIDを比較する

リビジョンIDのアプリケーションへの組み込み、リビジョンID確認用のファイルを配置、CI/CDへの組み込みが完了したので、リビジョンIDの比較処理を実装します。

アプリケーションに組み込まれているリビジョンIDを取得

環境変数(APP_REVISION_ID) に組み込んであるので、単純に環境変数から取得します。NuxtJS を利用している場合は、コンポーネント内で環境変数にアクセスしたい場合は、nuxt.config の env に追加する必要があります。

...
  computed: {
    appRevisionId(): string {
      return process.env.APP_REVISION_ID ?? ''
    },
  },
...

リビジョンID確認用のファイルからリビジョンIDを取得

リビジョンID確認用のファイルは、HTTP でアクセスできる場所(CloudFront + S3)に配置しているので、fetchを使って取得するように実装しました。 また、詳細は解説しませんが、今回のように fetch を利用してアクセスする場合、CORS の設定が必要になる場合があります。CORS の設定は、サーバー側に設定する必要がありますので、今回の構成だと CloudFront や S3の設定を変更する必要があるかもしれません。こちらの記事が参考になります。

...
  methods: {
    async getRevisionId(): Promise<void> {
      try {
        const res = await fetch(
          `${process.env.REVISION_URL}/application/revision.json`
        )
        const revision = await res.json()
        this.serverRevision = revision?.revision ?? ''
      } catch (error) {
        console.error(error)
      }
    },
  },
...

動作確認

実装した内容を実際に動かして、意図した動きになっていることを確認してみます。

<template>
  <div>
    <h2>RevisionUp テストです</h2>
    <p>
      アプリケーションが保持しているリビジョンID:
      <span style="color: blue">{{ appRevisionId }}</span>
    </p>
    <p>
      CloudFront + S3 から取得したリビジョンID:
      <span style="color: blue">{{ serverRevision }}</span>
    </p>
    <p>
      リビジョンIDを比較:
      <span v-if="appRevisionId === serverRevision" style="color: blue"
        >同じです</span
      ><span v-else style="color: red">異なります!!</span>
    </p>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'IndexPage',
  data(): any {
    return {
      serverRevision: '',
      intervalId: 0,
    }
  },
  computed: {
    appRevisionId(): string {
      return process.env.APP_REVISION_ID ?? ''
    },
  },
  methods: {
    async getRevisionId(): Promise<void> {
      try {
        const res = await fetch(
          `${process.env.REVISION_URL}/application/revision.json`
        )
        const revision = await res.json()
        this.serverRevision = revision?.revision ?? ''
      } catch (error) {
        console.error(error)
      }
    },
  },
  mounted() {
    this.getRevisionId()
    this.intervalId = window.setInterval(() => {
      this.getRevisionId()
    }, 5 * 1000) // 5秒に1回ポーリング
  },
  beforeDestroy() {
    window.clearInterval(this.intervalId)
  },
})
</script>

初回アクセス時は、取得したアプリケーションがリリース済みの最新のアプリケーションと同じなので、リビジョンIDが一致しています。

ポーリングについても、定期的に(今回はテスト的に5秒に1回)ポーリングしていることを確認できます。

ポーリングの実装については、

  • どの程度の間隔でポーリングするか?
  • ブラウザ(タブ)が非アクティブになった場合にどうするか?
  • ポーリングの停止方法

などを検討して実装する必要があります。

この状態のまま(ブラウザをリロードしない)、アプリケーションを再度デプロイします。 アプリケーションに組み込まれているリビジョンIDが古いままで、リビジョンID確認用のファイルのリビジョンIDが更新されていることを確認できました。

このように差分を検出した際に、ユーザーにアプリケーションの更新を促すような通知ができるとやりたいことが実現できそうですね。

さいごに

SPA でリビジョンのズレを解決する方法を紹介させていただきました。これで機能の修正などを行ってリリースした際に、クライアント側にアプリケーションの更新を促すことができそうですね。 また、今回のようにリリース毎になんらかの更新や処理が必要な場合は、利用している CI/CD の仕組み(今回の場合は Amplify Console)に組み込むと、確実に対応できるのでおすすめです。 いろいろな対策方法がある中の、手段の1つとして、どなたかの参考になれば幸いです。

参考