🧙‍♀️

Firestoreのtransactionの使いどころと使い方

9 min read 1

この記事は

Firestoreのtransaction(以下、トランザクションと表記)の機能と、具体例を用いたJavaScriptのコードとセキュリティルールの書き方を紹介します。

対象読者

"トランザクション"という言葉になじみのない初心者向けの内容が中心です。
後半のトランザクション使用時のセキュリティルールの書き方は、中級者にも参考になるかと思います。

環境

  • firebase: 9.1.3
  • react: 17.0.2

Firestoreのトランザクションを構成する要素

Firestoreのトランザクションには大きく2つの要素が含まれています。

  • 複数ドキュメントの一括書き込み
  • ドキュメントのロック(排他制御)

それぞれどのようなものか、みていきましょう。

複数ドキュメントの一括書き込み

複数ドキュメントの一括書き込みとは、複数のドキュメントを書き込むとき、すべてのドキュメントが書き込まれるか、すべて書き込まれないかを実現する機能です。言い換えれば中途半端な状態が書き込まれるのを防ぐ機能です。

ロックの使用例として、例えばECサイトで商品が売れた時のことを考えます。このとき、"商品の在庫を減らす"、"受注情報を書き込む"という2つの処理があるとします。書き込み中にエラーが発生した場合、それまでに行った変更は破棄されます。トランザクションを使えば、在庫が減っているのに受注情報がない、またはその逆という状態が起こらないことを保証します。

rollback

ロック(排他制御)

ロック(排他制御)とは、あるドキュメントを複数のユーザーが操作するときに、そのうちの1人のユーザのみに編集を許可する機能です。

ロックの使用例として、例えば、ECサイトで商品が売れた時のことを再び考えます。在庫が10個の商品をAさんとBさんが同時にそれぞれ5個、3個買ったとします。このとき最終的な在庫数は2個になるはずです。

ロックを使用せず、さらにタイミングが悪いと以下のように在庫に不整合がおきます。

no_lock

これを阻止するのがロックです。ロックを使用すると2人のユーザがほぼ同時に更新をしようとしても片方が終わってからもう片方のユーザが更新をすることを保証します。

with_lock

ロックの実装方法として、悲観ロックと楽観ロックがあります。FirestoreのWeb SDK(ブラウザ上のJavaScriptで動かしている場合はこれ)は楽観ロックを採用しています。両方のロックをイメージでざっくりお伝えします。(イメージなので実際の実装とは違います)

悲観ロック

悲観ロックは、データの読み取り時に、ほかのユーザが読み書きを禁止するフラグを立てる方式です。

pesimistic_lock

楽観ロック

楽観ロックはデータの読み取り時にその内容を記憶しておき、更新時にその読み取ったデータと更新直前の値を比較して、違いがなければ書き込みを行う方法です。

optimistic_lock

RDB(リレーショナルデータベース)では、複数ドキュメントの一括書き込みを"トランザクション"と呼ぶことが多いようです。ロックについては、"ロック"または"トランザクション分離レベル"という文脈で語られます。

トランザクションを使用した例

整理券発行アプリ

例として整理券発行アプリを考えましょう。

issue-ticket

ユーザが"整理券を取得する"ボタンを押したとき、

  • "次に獲得する整理券の番号を1増やし"、"獲得した整理券の情報を書き込む"という2つの操作を同時に行います。
  • 同時に複数人が整理券を取得しようとした場合に整理券番号に不整合が起きないよう、ロックをする必要があります。

というわけで整理券を取得する操作にはトランザクションを使用します。

データ構造はこのようになっています。

data_structure

整理券を取得したときはこのようになります。

change_add_value

トランザクションの実装

"整理券を獲得する"ボタンが押されたときに行うことは以下です。

  1. 更新前の整理券番号(nextTicketNum)を取得する
  2. 整理券番号に1を足して更新する
  3. 整理券を新規作成

この3つを1つのトランザクションにまとめて実装しましょう。

トランザクションを使用するための準備

Firebaseへの接続は以下のように初期化されているとします。

firebase.js
import { initializeApp } from "firebase/app"
import { getAuth } from "firebase/auth"
import { getFirestore } from "firebase/firestore"

// ここの設定は自身の設定に置き換える
const firebaseConfig = {
  apiKey: "xxxxxxxxxxxxxxxxxxxx",
  authDomain: "xxxxxxxxxxx.firebaseapp.com",
  projectId: "xxxxxxxxxxx",
  storageBucket: "xxxxxxxxxxx.appspot.com",
  messagingSenderId: "xxxxxxxxxxxxxxxxxx",
  appId: "xxxxxxxxxxxxxxxxxxxx"
};

// Initialize Firebase
const app = initializeApp(firebaseConfig)
export const auth = getAuth()
export const db = getFirestore()

トランザクションの機能を使用するにはまずrunTransaction()を呼び出します。

import { doc, runTransaction } from "firebase/firestore"
import { db, auth } from "./firebase"

const EventTicket = () => {
  const ticketEventId = "xxx"

  const handleIssueTicket = async () => {
    await runTransaction(db, async (transaction) => {
      // TODO ここにトランザクションの内容を書く
    })
  }

  return (
    <button onClick={handleIssueTicket}>整理券を取得する</button>
  )
}

export default EventTicket

ロック(排他制御)のために更新前の値を受け取る

ロックのためにドキュメントを読み込むには、transaction.get()を利用します。

  const handleIssueTicket = async () => {
    await runTransaction(db, async (transaction) => {
      // 更新前の値を取得
      const ticketEventsDocRef = doc(db, "ticket-events", ticketEventId)
      const ticketEventsDocSnap = await transaction.get(ticketEventsDocRef)

      if (!ticketEventsDocSnap.exists()) {
        throw "ticketEvent document does not exist!"
      }
    })
  }

ドキュメントを更新する

トランザクションを利用した際のドキュメントの更新にはtransaction.update()、ドキュメントの作成にはtransaction.set()を利用します。

  const handleIssueTicket = async () => {
    await runTransaction(db, async (transaction) => {
      // 更新前の値を取得
      const ticketEventsDocRef = doc(db, "ticket-events", ticketEventId)
      const ticketEventsDocSnap = await transaction.get(ticketEventsDocRef)

      if (!ticketEventsDocSnap.exists()) {
        throw "ticketEvent document does not exist!"
      }

      const ticketNum = ticketEventsDocSnap.data().nextTicketNum

      // nextTicketNumを更新
      transaction.update(ticketEventsDocRef, { nextTicketNum: ticketNum + 1, })

      // 整理券を作成
      const ticketDocRef = doc(db, "ticket-events", ticketEventId, "tickets", String(ticketNum))
      transaction.set(ticketDocRef, {
        user: auth.currentUser.uid,
      })
    })
  }

以上でJavaScript側の実装ができました。トランザクションを使わない場合と似たようなコードで書けるFirestoreは便利ですね。

更新がトランザクションで行われることを保証するセキュリティルール

更新をトランザクションで行うようにして不整合が起きないようにしたら、同様の内容をセキュリティルールに書き、不正なアクセスによるデータ不整合を防ぎましょう。

整理券を取得するときには

  • nextTicketNum(次に取得される整理券の番号)を1増やす
  • 更新前のnextTicketNumをIDに持った整理券が作成される
    • その整理券のuserはログインしているユーザのuidと一致する

という処理が行われます。

change_add_value

これをセキュリティルールに書きましょう。

はじめにテンプレートを用意しておきましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /ticket-events/{ticketEvent} {
      // TODO ここにticket-eventsの条件を書く
      
      match /tickets/{ticket} {
        // TODO ここにticketsの条件を書く
      }
    }
  }
}

次に、トランザクション実行時に

  • nextTicketNum(次に取得される整理券の番号)を1増やす
  • 更新前のnextTicketNumをIDに持った整理券が作成される
    • その整理券のuserはログインしているユーザのuidと一致する

この条件を満たすようにticket-eventのセキュリティルールを追加します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /ticket-events/{ticketEvent} {
      allow get: if true;

      allow create: if request.auth != null && request.auth.uid == request.resource.data.owner;
      allow update: if request.auth != null &&
        request.resource.data.diff(resource.data).affectedKeys().hasOnly(["nextTicketNum"]) &&
        request.resource.data.nextTicketNum == resource.data.nextTicketNum + 1 &&
        request.auth.uid == getAfter(
          /databases/$(database)/documents/ticket-events/$(ticketEvent)/tickets/$(resource.data.nextTicketNum)
        ).data.user;
        
      match /tickets/{ticket} {
        // TODO ここにticketsの条件を書く
      }
    }
  }
}

ルールを解説します。

  • request.resource.data.diff(resource.data).affectedKeys().hasOnly(["nextTicketNum"])で更新対象のフィールドがnextTicketNum意外は含まないことを保証します。
  • request.resource.data.nextTicketNum == resource.data.nextTicketNum + 1で、nextTicketNumを更新するときは必ず値が1増えることを保証します。
  • getAfter(path)関数は、トランザクション完了時のpathの内容を返します。
  • request.auth.uid == getAfter(/databases/$(database)/documents/ticket-events/$(ticketEvent)/tickets/$(resource.data.nextTicketNum)).data.userで、トランザクション完了時にそのticketEventの下の/tickets/{更新前のnextTicketNum}のuserがログインしているユーザのuidと同じということを保証します。

さらに、ticketのパスにも条件を満たすようにセキュリティルールを記載します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /ticket-events/{ticketEvent} {
      // 上と同じ
        
      match /tickets/{ticket} {
        allow create: if request.auth != null && request.auth.uid == request.resource.data.user &&
	int(request.resource.id) > 0 &&
        request.resource.id == string(
          getAfter(
            /databases/$(database)/documents/ticket-events/$(ticketEvent)
          ).data.nextTicketNum - 1
        );
      }
    }
  }
}

ルールを解説します。

  • request.auth != null && request.auth.uid == request.resource.data.userでユーザがログインしているかつ整理券のuserとログインしているユーザのuidが同じであることが保証されます。
  • int(arg)関数は引数を整数に変換します。
  • request.resource.idは新規作成されたドキュメントのIDです。
  • int(request.resource.id) > 0で作成されるドキュメントのIDが0より大きいことが保証されます。
  • string(arg)関数は引数を文字列に変換します。
  • getAfter(path)関数は、トランザクション完了時のpathの内容を返します。
  • getAfter(/databases/$(database)/documents/ticket-events/$(ticketEvent)).data.nextTicketNumでトランザクション終了時の親ドキュメントのnextTicketNumを取得しています。
  • request.resource.id == string(getAfter(/databases/$(database)/documents/ticket-events/$(ticketEvent)).data.nextTicketNum - 1)で、親ドキュメントの更新後の値から1を引いて文字列に変換すると、ticketのドキュメントIDと等しいことが保証されます。

トランザクション処理を行う場合、関連する2つ以上のドキュメント全てにトランザクションに関するセキュリティルールを記載する必要があります。それを忘れると悪意のあるユーザによって中途半端な状態のデータが書き込まれる危険性があります。

終わりに

整理券アプリは実際に筆者が作成したアプリです。よろしければこちらの記事もどうぞ。

https://zenn.dev/yucatio/articles/f02ec58a4f54ce

Discussion

おはようございます〜

事例を通して「Firestoreのトランザクション、図解付き - ロック(排他制御)、セキュリティルール」に関して書かれてて、すごくわかりやすかったです〜

特にセキュリティルールの記述に関して、迷うことが多いので、解説付きで載せていただきすごく助かります。🙇‍♂️

ありがとうございます。🙇‍♂️

ログインするとコメントできます