AWS AppSync の制約を回避するためのアプリケーション改修その3:フロントエンド編

SCSKの畑です。

前回の投稿に引き続き、3回目として非同期処理部分のフロントエンド実装についてピックアップして説明していきます。

フロントエンドにおける非同期処理実装の方針について

まず前提として、同期処理で実装していた部分を非同期処理に変更しても、画面の遷移/見せ方自体は基本的に同期処理時と同一にする必要があります。例えば、テーブルデータの取得処理を非同期にただ変更するだけだと、データ取得が完了しない内にテーブルデータの表示画面に遷移してしまうため、単純に想像すると取得が完了するまで空の表が表示されることになってしまいます。また、おそらく実際には何らかのエラーが発生してしまう可能性が高いです(同期処理である以上、テーブルデータが取得されている前提で表示画面のロジックが組まれているため)

テーブルの更新差分計算処理など、画面遷移後も引き続きバックグラウンドで処理を継続できるようなものはこの限りではありませんが、全体の割合としては少なかったです。

よって、前回の投稿でも言及した通り AppSync の Subscription を使用する方針としました。非同期処理の進捗状況や完了をプッシュ通知としてリアルタイムで受け取れる方が実装上の都合も良いためです。また昨年度の投稿でも記載している通り、テーブルのステータス(編集状態)を画面上でリアルタイム反映する機能などに Subscription を使用した実績がある点も理由の一つでした。

Web アプリケーションにおける排他制御の実装例(第三回)
作成中の Web アプリケーションにおいて排他制御を実装するための重要なステータスの扱いについて、実装上の考慮点や工夫をまとめました。

ただし、今回非同期に変更する各処理・画面ごとに Subscription を使用するように実装を変更するというのは効率があまりよろしくないため、非同期処理の進捗状況を表示するインジケータ(いわゆるロード画面)を Nuxt.js の component として実装して、各画面から共通して使用できるようにしました。そのあたりの話について次のセクションにて説明していきます。

なお、前回に引き続き広野さんのエントリもそのものズバリな内容であるため再掲します。

AWS AppSync を使って React アプリからキックした非同期ジョブの結果をプッシュ通知で受け取る
非同期ジョブを実行した後、結果をどう受け取るか?というのは開発者として作り込み甲斐のあるテーマです。今回は React アプリが非同期ジョブを実行した後に、AWS AppSync 経由でジョブ完了のプッシュ通知を受け取る仕組みを紹介します。

非同期処理進捗状況表示用の共通 component の実装例

ちょっと悩みましたが部分的に切り出して説明するのも難しいので、実装例をそのまま載せてしまおうかと思います。

<template>
  <UProgress v-model="currentWipValue" :max="TaskStep" :color="getIndicatorColor()">
    <template v-for="(step, index) in TaskStep" :key="index" #[`step-${index}`]="{ step }">
      <span v-if="currentErrorMessage" class="text-red-500">
        <UIcon :name="getIconName(index)"/> 
        {{ getStepText(step) }}
      </span>
      <span v-else>
        <UIcon :name="getIconName(index)"/> 
        {{ getStepText(step) }}
      </span>
    </template>
  </UProgress>
</template>

<script setup lang="ts">
import * as subscriptions from "@/src/graphql/subscriptions";
import * as models from "@/src/API";

// Propsの定義
interface Props {
  task_id: string
  task_step: string[]
}

// Emitの定義
const emit = defineEmits<{
  completed: [info: models.AsyncTask]
  failed: [error: string]
}>()

const props = defineProps<Props>()
const { addErrorInfo } = useErrorInfo()
const client = useNuxtApp().$Amplify.GraphQL.client

const TaskStep = toRef(props, 'task_step')
const currentWipValue = ref<number>(0)
const currentErrorMessage = ref<string>('')
const currentStatus = ref<models.AsyncTaskStatus | null>(null)
const taskSubscription = ref<any>(null)

const getIconName = (index: number) => {
  if (currentStatus.value === models.AsyncTaskStatus.FAILED) {
    return 'material-symbols:error'
  }
  else if (currentStatus.value === models.AsyncTaskStatus.COMPLETED) {
    return 'material-symbols:check'
  }
  else {
    return 'svg-spinners:90-ring-with-bg'
  }
}

const getStepText = (step: string) => {
  if (currentErrorMessage.value) {
    return currentErrorMessage.value
  }
  return step
}

const getIndicatorColor = () => {
  if (currentErrorMessage.value) {
    return 'error'
  }
  return 'primary'
}

// 非同期処理の進捗状況のサブスクライブ
const subscribeAsyncTask = async () => {
  try {
    taskSubscription.value = client
      .graphql({ 
        query: subscriptions.onUpdateAsyncTask, 
        variables: { id: props.task_id } 
      })
      .subscribe({
        next: (data: any) => {
          const asyncTask = data.data.onUpdateAsyncTask
          if (asyncTask) {
            currentWipValue.value = asyncTask.session_info.wip_value || 0
            currentErrorMessage.value = asyncTask.err_msg || ''
            currentStatus.value = asyncTask.status

            // タスクが完了またはエラー状態になった場合
            if (currentStatus.value === models.AsyncTaskStatus.COMPLETED) {
              emit('completed', asyncTask)
              //unsubscribeAsyncTask()
            }
            else if (currentStatus.value === models.AsyncTaskStatus.FAILED) {
              emit('failed', currentErrorMessage.value || '非同期実行処理が何らかの原因で失敗しました。')
              unsubscribeAsyncTask()
            }
          }
        },
        error: (error: any) => {
          emit('failed', error.message || '非同期実行処理サブスクライブ時に何らかのエラーが発生しました。')
          unsubscribeAsyncTask()
        }
      })
  } catch (error: any) {
    emit('failed', error.message || '何らかのエラーが発生しました。')
  }
}

// 非同期処理の進捗状況のアンサブスクライブ
const unsubscribeAsyncTask = () => {
  if (taskSubscription.value) {
    taskSubscription.value.unsubscribe()
    taskSubscription.value = null
  }
}
onMounted(async() => {
  if (props.task_id) {
    await subscribeAsyncTask()
  }
})

onUnmounted(() => {
  try {
    unsubscribeAsyncTask()
  } catch (error: any) {
    addErrorInfo(error)
  }
})
</script>

内容についてもかいつまんで説明します。

  • このコンポーネントを他のページ(画面)から mount した時点で、特定の非同期処理のステータスを subscribe する
  • ページ(画面)ごとに実行する非同期処理の種類自体は異なるため、このコンポーネント内に非同期処理の実行は含まない
    • ページ側で非同期処理を実行した後に返り値として得た ID をprops 経由で本コンポーネントに渡すことで、非同期処理ステータス管理用のテーブルの該当行の subscribe を実現
    • 非同期処理の種類に応じてインジケータのラベルに示す内容(文面)やステップ数が異なるため、同じく props 経由で本コンポーネントに渡す
  • subscribe により更新を検知した場合はインジケータの進捗状況を更新の上、ステータスが完了またはエラーの場合は呼び出し元ページの対応するメソッドを emit 経由で実行して後続処理を進める
    • 完了の場合はコンポーネント側で unsubscribe していないが、呼び出し元ページ側で後続処理が必要&引き続きインジケータにその進捗状況を表示し続けたいケースがあるため

共通 component 呼び出し元ページの実装例

こちらは呼び出し元のページによって実装が大きく異なるので、対象コンポーネント呼び出し部分のみ抜粋します。例えば最新のテーブルデータを Redshift から取得する場合の実装はこんな感じです。AsyncTaskProgress が今回実装例として示したコンポーネント名です。

<div v-if="AsyncTaskID_loadtabledata" class="my-4 max-w-4xl">
  <AsyncTaskProgress 
    :task_id="AsyncTaskID_loadtabledata"
    :task_step="['初期化', 'Redshift上の最新データを取得', 'Redshift上の最新データとの比較', 'Redshift上の最新データをS3に反映', 'S3上のデータをロード']"
    @completed="onLoadTableDataCompleted"
    @failed="onLoadTableDataFailed"
  />
</div>
  • :task_id に最新のテーブルデータを Redshift から取得する非同期処理の ID を渡す
  • :task_step にタスクのステップ数とラベルを定義した文字列型の配列を渡す
  • @completed 及び @failed で、共通 component からemit 経由で実行する呼び出し元ページのメソッドを指定する

なお、最新のテーブルデータを Redshift から取得する非同期処理が完了した時点でテーブルデータを画面上に表示するために、この AsyncTaskProgress コンポーネントは非同期処理の実行中のみ mount(画面に表示)する必要があります。このため、本コンポーネントの外側の div タグの v-if の条件句として AsyncTaskID_loadtabledata を指定の上、処理完了後に同変数を undefined で初期化する実装としています。

まとめ

要件変更(扱うデータ量の長大化)によるアプリケーションの設計・実装変更に伴う、特定処理の改修(同期処理⇒非同期処理)について、フロントエンド/バックエンドそれぞれの観点から2回に渡ってまとめました。全体通しての振り返りは第1回の投稿でまとめてしまった感があるのでここであまり書くことがないのですが、全体方針が決まってからの改修自体はフロントエンド/バックエンド共にそこそこ効率良く実施できたかと思います。

扱うデータ量の長大化によるアプリケーションの改修は今回説明した以外にも主にフロントエンド側で幾つか発生したので、そのあたりの説明についても今後別エントリにて触れていく予定です。

本記事がどなたかの役に立てば幸いです。

タイトルとURLをコピーしました