🔙

フロントでロールバックを実装する

に公開

こんにちは!CastingONEの大沼です。

始めに

弊社ではファイルのアップロードは事前にGCSに上げてからそのURLをPOSTしています。先に上げておくことで登録時にファイルアップロードの処理時間をスキップでき時間を短くすることができます。
しかしこの事前アップロードを並列で行った際、途中でエラーが起きると困ったことが起きます。アップロード先のURLは数が決まっているのでアップロード済みかをフラグで管理していますが、途中で失敗した場合はこれまで成功していたファイルはフラグが立ったままになってしまいます。更にまだ通信中のものはPromise.allではスキップされますが処理そのものがスキップされたわけではないので後で完了処理が行われてしまいます。コード上ではPromise.allは一つでもエラーになったら全てのデータを捨てて処理が止まるので、同じように実行前の状態に戻したり通信をキャンセルしたいです。

2番目に終わる通信がエラーになった時に他の通信でケアしたいこと
async Promise.all([
  // 最初に終わる通信は実行前の状態を戻したい
  uploader.upload(file1, {
    params: { delay: 1000 },
  }),
  // 2番目に終わる通信をわざとエラーにする
  uploader.upload(file2, {
    params: { delay: 3000, error: true },
  }),
  // 3番目に終わる通信はまだ通信中なので中断させないと後で完了してしまう
  uploader.upload(file3, {
    params: { delay: 5000 },
  }),
]);

この考えは基本的にはDBのトランザクションに近いと思うので、フロントでトランザクションっぽい仕組みを作って実行中の処理を中断してロールバックできるようにしてみました。

作ったもの

検証として以下のような動きをするアプリを作りました。

  • 3つのリクエストを並列に送信して全て成功する
  • 3つのリクエストを並列に送信し、2つ目で失敗してそれぞれ以下の対応をする
    • 先に成功した1つ目はロールバックして枠をクリアする
    • 失敗した2つ目は通信も終了しているので何もしない
    • まだ通信中である3つ目は通信をキャンセルする

これをStackBlitzで実装したので、動作確認や詳細のコードはこちらをご参照ください。

フロントでトランザクションを実装する

大枠の実装

トランザクションの設定は出来るだけ手間にならないように、Transaction.runの中で実行したメソッドが自動でキャンセル処理やロールバックされるような作りにしました。

トランザクションの呼び出しイメージ
Transaction.run(async (tx) => {
  // txにキャンセル処理やロールバックを登録する
  // このスコープの中でエラーが発生したら登録しておいたキャンセル処理やロールバックを実行する
})

この構成にするためにTransactionクラスの大枠は以下のようになりました。Transaction.runの中でTransactionクラスを作って出来るだけクラス内で閉じ込める作りなので、可能な限りprivateメソッドにしました。外でnewすることもなさそうだったのでコンストラクタもprivateにしました。

Transactionクラスの大枠
type CancelHandler = () => void;
type Rollback = () => void;

export class Transaction {
  /** キャンセル処理 */
  private cancelHandlers: CancelHandler[] = [];
  /** ロールバック処理 */
  private rollbacks: Rollback[] = [];

  // 外から作られないようにprivateコンストラクタにする
  private constructor() {}

  /**
   * トランザクションを開始する
   */
  static async run<T>(fn: (tx: Transaction) => Promise<T>): Promise<T> {
    const tx = new Transaction();
    try {
      const result = await fn(tx);
      tx.commit();
      return result;
    } catch (err) {
      tx.cancelAll();
      tx.rollback();
      throw err;
    }
  }

  /**
   * トランザクションを確定する
   */
  private commit() {
    this.rollbacks = [];
  }

  /**
   * 処理中のものを全て中断する
   */
  private cancelAll() {
    this.cancelHandlers.forEach((cancel) => {
      cancel();
    });
    this.cancelHandlers = [];
  }

  /**
   * 元の状態になるようにロールバックを実行する
   */
  private rollback() {
    this.rollbacks.forEach((fn) => {
      fn();
    });
    this.rollbacks = [];
  }
}

キャンセル処理、ロールバックの登録

キャンセル処理やロールバックの登録も折角なのでラップして設定しやすくします。

キャンセル処理やロールバックの登録イメージ
Transaction.run(async (tx) => {
  // トランザクションの実行
  await tx.execute(
    (signal) => {
      // signalのイベントを見てキャンセル処理を設定する
      // (通信処理の場合は基本的にはsignalを渡すだけで良い)
    },
    {
      rollback: () => {
        // ロールバック処理を書く
      }
    }
  )
})

これが出来るようにTransactionクラスに追加すると以下のようになります。

executeメソッドを通じてキャンセル処理、ロールバックの登録をする
 export class Transaction {
   /** キャンセル処理 */
   private cancelHandlers: CancelHandler[] = [];
   /** ロールバック処理 */
   private rollbacks: Rollback[] = [];

   // 既出のものは省略

+  /**
+   * トランザクションを実行する
+   * @param exec - 実行メソッド
+   * @param options - オプション
+   */
+  async execute<T>(
+    exec: (signal: AbortSignal) => Promise<T>,
+    options: {
+      /** ロールバック処理 */
+      rollback?: Rollback;
+    } = {}
+  ) {
+    const controller = new AbortController();
+    const cancel = () => {
+      controller.abort();
+    };
+    this.addCancelHandler(cancel);
+
+    try {
+      const result = await exec(controller.signal);
+
+      if (options.rollback) {
+        this.addRollback(options.rollback);
+      }
+
+      return result;
+    } finally {
+      this.removeCancelHandler(cancel);
+    }
+  }

+  /**
+   * キャンセル処理を登録する
+   * @param handler - キャンセル処理
+   */
+  private addCancelHandler(handler: CancelHandler) {
+    this.cancelHandlers.push(handler);
+  }

+  /**
+   * キャンセル処理を削除する
+   * @param handler - キャンセル処理
+   */
+  private removeCancelHandler(handler: CancelHandler) {
+    this.cancelHandlers = this.cancelHandlers.filter((h) => h !== handler);
+  }

+  /**
+   * ロールバック処理を追加する
+   * @param rollback - ロールバック処理
+   */
+  private addRollback(rollback: Rollback) {
+    this.rollbacks.push(rollback);
+  }
 }

Uploaderにトランザクションを組み込む

以上がトランザクションを実現するための実装コードでした。このTransactionクラスを使ってアップローダークラスに組み込むと以下のようになります。今回は検証用なのでファイルではなく単純な文字列(一応base64にすればファイルデータを送ったことにはできる)を送信するコードにしています。ハイライト部分がトランザクションの設定で、.uploadを実行するだけでロールバックやキャンセルの処理を自動でやってくれるようになります。

Uploaderにトランザクション機能を組み込む
 import axios from 'axios';
 import { Transaction } from './Transaction';
 
 /** アップロード枠 */
 type Slot = {
   url: string;
   used: boolean;
   uploading: boolean;
 };
 
 export class Uploader {
   /**
    * アップロード枠
    * @note デバッグ用にpublicで公開する
    */
   public _slots: Slot[];
 
   /**
    * @param usableUrls - アップロード可能なURLリスト
    */
   constructor(usableUrls: string[]) {
     this._slots = usableUrls.map((url) => ({
       url,
       used: false,
       uploading: false,
     }));
   }

   /**
    * データをアップロードする
    * @param data - データ
    * @param params - デバッグ用のクエリパラメータ
    */
   async upload(
     data: string,
     {
+      tx,
       params,
       onSuccess,
     }: {
+      tx: Transaction;
       params: {
         delay?: number;
         error?: boolean;
       };
       onSuccess?: () => void;
     }
   ) {
     const usableSlot = this._slots.find(
       (slot) => !slot.used && !slot.uploading
     );
     if (usableSlot == null) {
       throw new Error('アップロード可能枠がありません');
     }

+    return await tx.execute(
+      async (signal) => {
+        try {
+          usableSlot.uploading = true;
+          await axios.put(
+            usableSlot.url,
+            { data },
+            {
+              params,
+              signal,
+            }
+          );
+        } finally {
+          usableSlot.uploading = false;
+        }
+        usableSlot.used = true;
+        onSuccess && onSuccess();
+        return usableSlot.url;
+      },
+      {
+        rollback: () => {
+          usableSlot.used = false;
+        },
+      }
+    );
+  }
 }

Uploaderは以下のような感じで使います。最初に期待した構造の通り、Transaction.runの中で受け取ったtxを渡すだけでトランザクションを張ることができます。

Uploaderの使用例
const uploadSampleData = async () => {
  const uploader = new Uploader([
    'http://localhost:6000/slots/1',
    'http://localhost:6000/slots/2',
    'http://localhost:6000/slots/3',
  ]);

  await Transaction.run((tx) => {
    return Promise.all([
      uploader.upload('data1', { tx }),
      uploader.upload('data2', { tx }),
      uploader.upload('data3', { tx }),
    ]);
  });
}

終わりに

以上がフロントでロールバック機能を実装する方法でした。ロールバックするということはトランザクションを張るということになりますが、汎用的なTransactionクラスを作ってユースケースに合わせたロールバックやキャンセルを設定しておくことで呼び出し側はシンプルな状態を保ったまま設定することができました。
フロントでロールバックすることはほとんどないと思いますが、もし必要になった時に参考になれば幸いです。

Discussion