Firestoreトランザクションの不親切をセキュリティルールで解決

## はじめに

この記事は[Firebase Advent Calendar](https://qiita.com/advent-calendar/2018/firebase)2018の13日目の記事です。

Firestoreのトランザクション機能を利用していく中で、めんどうなケースがあったため共有します。
今回の記事で触れたいのはサインイン処理などでよくある下記のようなケースです。

**あるドキュメントIDのデータを確認し、存在しない場合のみ複数ドキュメントを作成したい**
**・ *ドキュメントIDが重複する場合*：理由をユーザに伝える**
**・ *通信そのものが失敗した場合*　：リトライ**

この処理をFirestoreトランザクションで素直に書こうとすると、**エラー内容が不明瞭**で利用できません。
これを
**1. セキュリティルールで意図しない書込みを防ぐ**
**2. データ取得前に確認処理を挟む**
ことで解決しました。

## Firebaseにおけるトランザクション処理

Firebaseでは`トランザクション処理/一括書き込み`という便利なものがあります
参考：[公式：トランザクションと一括書き込み](https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja)

> トランザクションでは、書き込みが部分的に適用されることはありません。成功したトランザクションの完了時にすべての書き込みが実行されます。

とあるように、トランザクション処理は、

- データ書き込みの漏れをなくす
- 同時書き込みに対する対処

などを考える上で重要な機能です。

## データが存在しない場合のみデータを書き込みたい

`あるドキュメントIDのデータを確認し、存在しない場合のみデータを書き込みたい`

というのを、公式ドキュメントを参考に、トランザクション処理を用いて単純に書くと以下のようなコードになります

```transaction.swift
let sfReference = db.collection("users").document(userID)
db.runTransaction({ (transaction, errorPointer) -> Any? in
    let sfDocument: DocumentSnapshot
    do {
        try sfDocument = transaction.getDocument(sfReference)
    } catch let fetchError as NSError {
        errorPointer?.pointee = fetchError
        return nil
    }
    
    if sfDocument.exists {
        print("すでにuserIDが登録されています")
    }else {
        transaction.setData(userData, forDocument: sfReference)
    }

    return nil
}) { (object, error) in
    if let error = error {
        print("Transaction failed: \(error)")
    } else {
        print("Transaction successfully committed!")
    }
}
```

しかし、このコードでは
` print("すでにuserIDが登録されています")`
が呼ばれることはなく、データが存在する場合はcatchされてしまいます。

## トランザクション処理では書き込み失敗時の理由の判別ができない

理由は存在しないドキュメントIDにアクセスすると
`Foundation._GenericObjCError.NilError`
と、なんとNilErrorをcatchしています。
[Swift Developer Japan](https://medium.com/swift-column/discord-ios-20d586e373c0)にて質問したところ、おそらく[この部分](https://github.com/firebase/firebase-ios-sdk/blob/master/Firestore/Source/Remote/FSTDatastore.mm#L129)のエラーが伝搬してるのだろうということです

DocumentSnapshotには`exists`プロパティがあるのでそこで確認できるのが嬉しいのですが、
トランザクションでは利用することができません。
これでは**ドキュメントIDが重複するか否か**を判別することができず要件を叶えることができません。

## どう対処したか

まず、今回の要件でFirebase トランザクションの機能を利用することを一部諦めています。
具体的には、トランザクション内でのドキュメントの読み込み、IDの存在確認をやめ、
書き込み機能のみの**一括書き込み**のみを使用してます。

そして、前述の解決するために以下の2つの施策を取っています。
**1. セキュリティルールで意図しない書込みを防ぐ**
**2. データ取得前に確認処理を挟む**

## セキュリティルールで意図しない書込みを防ぐ

セキュリティルールでデータの上書きそのものを禁止しています。
ちょうど同じFirebase アドベントカレンダーの昨日の[【改訂版】 Firebase Cloud Firestore rules tips](https://tech-blog.sgr-ksmt.org/2018/12/11/194022/)が参考になります。

今回のセキュリティルールでは、すでに存在するドキュメントの作成を禁止しています。

```
match /users/{userID} {
      allow read: if request.auth.uid != null;
      allow create: if request.auth.uid == userID
                    && request.resource.data.createdAt != null
      allow update: if request.auth.uid == userID
      	            && request.resource.data.createdAt == resource.data.createdAt;
```

**追記：この辺りを詳しく説明した記事を書きました**
**[Firestoreセキュリティルールで存在するドキュメントの上書き作成を防ぐ ](https://qiita.com/nade/items/b06761ad6dc564c01536)**

## データ取得前に確認処理を挟む

下記のような**書き込もうとしているdocumentIDがすでにcollection上に存在しているかどうか**の確認処理
をトランザクション処理の前に挟むことで、ユーザに失敗理由を伝えています。

```isExist.swift
static func exists(path: String, completion: @escaping ((Bool) -> Void)) {
    firestore.document(path).getDocument(completion: { snapshot, _ in
        completion(snapshot?.exists ?? false)
    })
}
```

仮に同じドキュメントIDに同タイミングで書き込もうとした場合でもセキュリティルールによりトランザクションが失敗します。
そのため、**再度確認処理からリトライ**することで、原因を特定しユーザに理由を伝えることができます。

## まとめ

今回の知見をまとめるとこんなかんじです。

- **トランザクション中でエラーの内容により処理を分けることはできない。「失敗は失敗」**
- **セキュリティルール側で処理を失敗させた上で原因を特定する工夫が必要**
- **setメソッドでの同一パスへの書き込みはUpdate扱いとなるため、フィールドを用いたセキュリティルールの記述が要る**

意見、感想などありましたら書き込んでいただけると幸いです。

## 追記
Firestore 5.16.0でエラーにならずに exists で判定できるように改善されたようです!

>Breaking change: FIRTransaction.getDocument() has been changed to return a non-nil FIRDocumentSnapshot with exists equal to false if the document does not exist (instead of returning a nil FIRDocumentSnapshot). Code that includes if (snapshot) { ... } must be changed to if (snapshot.exists) { ... }.

https://firebase.google.com/support/release-notes/ios#5.16.0

@mono0926 さんにコメントいただきました！
ありがとうございます！