🚉

【TypeScript】Firestoreのトランザクションを活用しまくれるライブラリをオープンソースにしました

2023/08/08に公開

こんにちは! sugitaniと申します。 NUNW株式会社というところでCTOをやっています。

弊社はメインデーターベースとしてFirestoreを利用することが多く、現在もメインプロダクトでFirestoreを活用しています。

Firestoreのトランザクションは癖が強く
読み取りオペレーションは書き込みオペレーションの前に実行する必要がある
という制限があります。

今回は、この癖とうまく付き合うためのバックエンド向け自社ライブラリ @nunw/domain-events-for-firestoreをOSSとして公開しました。

ロジック組み合わせ辛い問題

以下のようなコードがあるとします。

async function updateDocument1() {
  await firestore.runTransaction(async (transaction) => {
    const docRef = firestore
      .collection("example")
      .doc("document1");

    const doc = await transaction.get(docRef);
    const newValue = ((doc.data()?.value) ?? 0) + 1;

    transaction.update(docRef, { value: newValue });
  });
}

async function updateDocument2() {
  await firestore.runTransaction(async (transaction) => {
    const docRef = firestore
      .collection("example")
      .doc("document2");

    const doc = await transaction.get(docRef);
    const newValue = ((doc.data()?.value) ?? 0) + 1;

    transaction.update(docRef, { value: newValue });
  });
}

このとき updateDocument1()updateDocument2()を同一トランザクションで実行したいとき、普通のRDBMSのトランザクションであれば以下のように書けますが、書き込み後にreadを使用しているのでFirestoreでは動きません

async function updateDocument1(transaction: Transaction) {
  const docRef = firestore
    .collection("example")
    .doc("document1");

  const doc = await transaction.get(docRef);
  const newValue = ((doc.data()?.value) ?? 0) + 1;

  transaction.update(docRef, { value: newValue });
}

async function updateDocument2(transaction: Transaction) {
  const docRef = firestore
    .collection("example")
    .doc("document2");

  const doc = await transaction.get(docRef); // ←Firestoreだとここがダメ
  const newValue = ((doc.data()?.value) ?? 0) + 1;

  transaction.update(docRef, { value: newValue });
}

await firestore.runTransaction(async (transaction) => {
  await updateDocument1x(transaction);
  await updateDocument2x(transaction);
})

処理の合成には何かしらの工夫が必要です。

フレームワークにしてしまいましょう

@nunw/domain-events-for-firestoreを利用すると、Firestore処理を安全に合成できるようになります。

まず以下のように準備をします

// Document1更新イベント
class UpdateDocument1Event extends AbstractDomainEvent {
  public readonly target: DocumentReference = firestore
    .collection("example")
    .doc("document1");

  constructor() {
    super();
  }
}

// Document2更新イベント
class UpdateDocument2Event extends AbstractDomainEvent {
  public readonly target: DocumentReference = firestore
    .collection("example")
    .doc("document2");

  constructor() {
    super();
  }
}

// Document1更新イベントのサブスクライバ
const subscriber1: DomainEventSubscriber = {
  onEvent(event: DomainEvent): DomainEventHandler | undefined {
    if (event instanceof UpdateDocument1Event) {
      // サブスクライバはDocument1が来たときに、どういう処理をするのかを返す
      return new (class extends TransactionDomainEventHandler {
        private currentValue: number | undefined = undefined;

        async prepareHandleEvent(context: ReadContext): Promise<void> {
          // ここは読み込み専用の処理
          const result = await context.get(event.target);
          this.currentValue = result.data()?.value;
        }

        async handleEvent(context: WriteContext): Promise<void> {
          // ここは書き込み専用の処理
          context.set(event.target, {
            value: (this.currentValue ?? 0) + 1
          });
        }
      })();
    } else {
      return undefined;
    }
  }
};

// Document2更新イベントのサブスクライバ
const subscriber2: DomainEventSubscriber = {
  onEvent(event: DomainEvent): DomainEventHandler | undefined {
    if (event instanceof UpdateDocument2Event) {
      // ... 似たような処理
    } else {
      return undefined;
    }
  }
};


const publisher = new DomainEventPublisher(firestore);
publisher.addSubscriber(subscriber1);
publisher.addSubscriber(subscriber2);

次のように実行します


// Document1更新だけを実行
await publisher.publish(new UpdateDocument1Event());

// Document1,Document2を更新
await publisher.publish(
  new UpdateDocument1Event(), 
  new UpdateDocument2Event()
);

// 複数のイベントをまとめたエイリアス的なイベントを作ることも可能
class UpdateDocumentsEvent implements CombinedDomainEvent {
  readonly eventName: string = "updateDoc1&2";

  get events(): NormalDomainEvent[] {
    return [new UpdateDocument1Event(), new UpdateDocument2Event()];
  }
}

await publisher.publish(new UpdateDocumentsEvent());

@nunw/domain-events-for-firestoreはと全イベントのデータ読み込み → 全イベントの書き込み処理、という順番で処理を行います。

手間はそこそこ増えますが、複数の処理を組み合わせても安全に実行できます。 途中でエラーが発生した場合はcommitされないので不完全な書き込みが行われることはありません。

おまけですが、リトライで復旧する可能性があるエラーの場合にリトライを試みる処理も入っています。

より詳しい使い方は GitHubをご参照ください

お勧めの構成

サンプルでは全部べた書きしましたが、firestoreへの書き込みロジックは以下のようにRepositoryとして書き出してしまうことをお勧めします

class ExampleRepository {
  async get(readContext: ReadContext, id:string): Promise<number|undefined>{
    // 何かしらの読み込み処理
    
    return 1;
  }
  
  store(writeContext: WriteContext, id:string, value:number){
    // 何かしらの書き込み処理
  }
}

同様にイベント定義とHandler実装は以下のようにまとめると見通しが良いです

class UpdateDocument1Event extends AbstractDomainEvent {
  public readonly target: string = "document1";

  constructor() {
    super();
  }

  static handler(
    event: UpdateDocument1Event,
    repository: ExampleRepository): DomainEventHandler {
    return new (class extends TransactionDomainEventHandler {
      private currentValue: number | undefined = undefined;

      async prepareHandleEvent(context: ReadContext): Promise<void> {
        this.currentValue = await repository.get(context, event.target);
      }

      async handleEvent(context: WriteContext): Promise<void> {
        repository.store(context, event.target, (this.currentValue ?? 0) + 1);
      }
    })();
  }
}

実際に使ってみてどうなのか

弊社ではこの機構を2年・2プロダクト、10名超で利用していますが利用や学習に苦労している様子はありません。目論見通り安全にトランザクションを安全に活用できており、半端なデータが書き込まれる事態は完全に無くせています。

ライブラリ名にdomain-eventsと銘打っているだけあって、弊社ではこのライブラリをDDDを行うための仕組みとして利用しています。

おわりに

ライセンスは3条項BSDライセンスです。そのまま使って頂いてもよいですし、大きなコードではないのでforkしたりコードごと取り込んだりしてカスタマイズして頂いても問題ありません。この記事やコードに変なところがあったら教えて頂けたら幸いです!

どなたかの助けになれば幸いです。

それではよい開発ライフを!

積極採用しています!

NUNW株式会社ではフロントエンドエンジニアを始め、バックエンドエンジニア(TypeScript)、Flutterエンジニア、スクラムマスターを募集しています。

もしご興味があれば、 以下をご覧くださいよろしくお願いします!

株式会社NUNW

Discussion