surga Lab

開発したい!!

Flutter Firestoreで複数ドキュメントに渡るTransactionを張る方法

Firestoreのトランザクションを複数のドキュメントに張る方法です。

1つのドキュメントを読み取り、その後同じドキュメントで書くような場合は単純です。(例えばインクリメント)

トランザクション内でgetした後にupdateもしくはsetすればいいだけですね。

final CollectionReference booksRef = _db.collection('/books');

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book1'),
       <String, dynamic>{'title': 'book_title1'});
  }
}).then((value) {
  // 成功した時の処理
  print('ok');
}).catchError((err) {
  // 失敗した時の処理
  print(err);
});

複数のドキュメントに渡らせる

ドキュメントAの値を元にドキュメントBの値を変える場合はどうしましょう。

例えば、本Aのタイトルを本Bにも同じタイトルでセットするとします。
素直にbook1のタイトルを取得してbook2をupdateすればよさそうです。

final book1 = await _db.collection('counters').document('counter1').get();
final titleBook1 = book1['title'];
        
await _db.collection('books').document('book2').updateData(<String, dynamic>{'title': titleBook1});

ではここに「book1のタイトルは時折変更されることがある」と条件をつけた場合はどうしましょう。

仮に上記の例でbook1を取得した直後にbook1のタイトルが変わってしまった場合、
book1とbook2のタイトルが異なってしまいます。

トランザクションを使う

それを防ぐ用途としても、トランザクションは便利です。
トランザクションでは読み取ったドキュメントが変更された場合、処理中のトランザクションを一度ロールバックして初めからやり直します。

それならbook1とbook2で異なってしまうことはありませんね。
シンプルに実装してみます。

final CollectionReference booksRef = _db.collection('/books');

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book2'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
  }
}).then((value) {
  // 成功した時の処理
  print('ok');
}).catchError((err) {
  // 失敗した時の処理
  print(err);
});

しかし残念ながらこれでは動きません。トランザクションが複数回試行された後に、以下のエラーが吐き出されます。

PlatformException(9, Transaction failed all retries.: Every document read in a transaction must also be written in that transaction., null)

トランザクション内でreadしたドキュメントはは必ずその後writeしなければいけないようです。

公式ドキュメントには以下のように記載されています。

トランザクションを使用する場合は、次の点に注意してください。

  • 読み取りオペレーションは書き込みオペレーションの前に実行する必要があります。
  • トランザクションが読み取るドキュメントに対して同時編集が影響する場合は、トランザクションを呼び出す関数(トランザクション関数)が複数回実行されることがあります。
  • トランザクション関数はアプリケーションの状態を直接変更してはなりません。
  • クライアントがオフラインの場合、トランザクションは失敗します。

その中のこの一文について

読み取りオペレーションは書き込みオペレーションの前に実行する必要があります。

私は「書き込みたいならその前に読み取ってね」「読むだけなら別にルールはないよ」としか捉えられなかったのですが、英文だと下記のようになっています。

Read operations must come before write operations.

これならRead後にはwriteが必須と読めなくもないですね。英語大事。

コードに反映してみます。
「何も書かないwrite」の方法がわからなかったので、とりあえず同じ値でupdateしました。

await _db.runTransaction((Transaction tx) async {
  print('Transaction start');
  DocumentSnapshot book1Snapshot = await tx.get(booksRef.document('book1'));

  if (book1Snapshot.exists) {
    await tx.update(booksRef.document('book1'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
    await tx.update(booksRef.document('book2'),
        <String, dynamic>{'title': book1Snapshot.data['title']});
  }
}).then((value) {
  print('ok');
}).catchError((err) {
  print(err);
});

無事Firestoreに反映されました。

ちなみに、私はこのルールに気づかずにハマっていました。
その原因がこちらです。AndroidとiOSで挙動に差があるようです。(Flutter for Androidかcloud_firestoreのバグ?)

blog.hisurga.com

参考にさせていただいたサイト

トランザクションと一括書き込み  |  Firebase

firebase - Update multiple documents in a single transaction with dart and Firestore - Stack Overflow