首页 > 解决方案 > 如何防止 Firestore 为预订按钮写入竞争条件

问题描述

概括

我正在开发一个应用程序,用户可以在其中预订和取消课程预订。在ReservationButtonViewI 中,两个按钮分别将用户添加和删除到锻炼课程。目前,我显示的按钮基于用户的 Firebase Auth uid 是否列在 Firestore 文档中。

快速点击预订按钮时遇到问题。具体来说,reservationCnt显示的内容多于或少于为某个课程保留的实际用户会变得不准确。

我发现解决此问题的唯一方法是使用 Firestore 事务来检查用户是否已经在锻炼课程中。如果是,那么addReservation()现在什么都不做。如果他们不是,removeReservation()也不会做任何事情。

起初我以为我可以禁用按钮并通过逻辑仍然在下面的代码(.disabled()),但是当我遇到上述竞争条件时,仅此一项不起作用。我发现的是,即使我要添加的对象分别在那里和不在那里,它仍然成功arrayUnionarrayRemove这意味着我的交易可能不会删除reservedUser不存在的 a 并减少reservationCnt可能会让我说没有保留用户和reservationCnt-1

有没有更好的方法来处理这个预订过程?我可以在没有交易的情况下完成此操作,至少以某种方式移除用户。理想情况下,我希望在添加或删除用户的预订时让微调器替换按钮,以向用户表明应用正在处理请求。也许我需要两个变量来管理disabled()状态而不是一个?

MVVM 代码片段

注意:我提取了一些按钮样式以使代码不那么冗长

预订按钮视图

struct ReservationButtonView: View {
    var workoutClass: WorkoutClass
    @ObservedObject var viewModel: WorkoutClassViewModel
    @EnvironmentObject var authViewModel: AuthViewModel
    var body: some View {
        if checkIsReserved(uid: authViewModel.user?.uid ?? "", reservedUsers: workoutClass.reservedUsers ?? []) {
            Button(action: {
                viewModel.isDisabled = true
                viewModel.removeReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? "")
            }){
                Label(
                    title: { Text("Cancel Reservation")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.minus")
                        .font(.title) }
                )
            }.disabled(viewModel.isDisabled)
        } else{
            Button(action: {
                viewModel.isDisabled = true
                viewModel.addReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? "")
            }){
                Label(
                    title: { Text("Reserve")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.plus")
                        .font(.title) }
                )
            }
            .disabled(viewModel.isDisabled)
        }
    }
}

func checkIsReserved(uid: String, reservedUsers: [reservedUser]) -> Bool {
  return reservedUsers.contains { $0.uid == uid }
}

健身课模型

struct reservedUser: Codable, Identifiable {
    var id: String = UUID().uuidString
    var uid: String
    var photoURL: URL?
    var displayName: String?
    
    enum CodingKeys: String, CodingKey {
        case uid
        case photoURL
        case displayName
    }
}


struct WorkoutClass: Codable,Identifiable {
    @DocumentID var id: String?
    var reservationCnt: Int
    var time: String
    var workoutType: String
    var reservedUsers: [reservedUser]?
    
    enum CodingKeys: String, CodingKey {
        case id
        case reservationCnt
        case time
        case workoutType
        case reservedUsers
    }
}

WorkoutClassViewModel

class WorkoutClassViewModel: ObservableObject {
    
    @Published var isDisabled = false
    private var db = Firestore.firestore()

    func addReservation(documentId: String, reservedUserDetails: [String: Any], uid: String){
        let incrementValue: Int64 = 1
        let increment = FieldValue.increment(incrementValue)
        let addUser = FieldValue.arrayUnion([reservedUserDetails])
        let classReference = db.document("schedules/Redwood City/dates/\(self.stateDate.dbDateFormat)/classes/\(documentId)")
        db.runTransaction { transaction, errorPointer in
            
            let classDocument: DocumentSnapshot
                do {
                    print("Getting classDocument for docId: \(documentId) in addReservedUser()")
                    try classDocument = transaction.getDocument(classReference)
                } catch let fetchError as NSError {
                    errorPointer?.pointee = fetchError
                    return nil
                }

            guard let workoutClass = try? classDocument.data(as: WorkoutClass.self) else {
                    let error = NSError(
                        domain: "AppErrorDomain",
                        code: -3,
                        userInfo: [
                            NSLocalizedDescriptionKey: "Unable to retrieve workoutClass from snapshot \(classDocument)"
                        ]
                    )
                    errorPointer?.pointee = error
                  return nil
                }
            
            let isReserved = self.checkIsReserved(uid: uid, reservedUsers: workoutClass.reservedUsers ?? [])
            
            if isReserved {
                print("user is already in class so therefore can't be added again")
                return nil
            } else {
                transaction.updateData(["reservationCnt": increment, "reservedUsers": addUser], forDocument: classReference)
                return nil
            }
            
        } completion: { object, error in
            if let error = error {
                print(error.localizedDescription)
                self.isDisabled = false
            } else {
                print("Successfully ran transaction with object: \(object ?? "")")
                self.isDisabled = false
            }
        }
    }
    
    func removeReservation(documentId: String, reservedUserDetails: [String: Any], uid: String){
        let decrementValue: Int64 = -1
        let decrement = FieldValue.increment(decrementValue)
        let removeUser = FieldValue.arrayRemove([reservedUserDetails])
        let classReference = db.document("schedules/Redwood City/dates/\(self.stateDate.dbDateFormat)/classes/\(documentId)")
        db.runTransaction { transaction, errorPointer in
            
            let classDocument: DocumentSnapshot
                do {
                    print("Getting classDocument for docId: \(documentId) in addReservedUser()")
                    try classDocument = transaction.getDocument(classReference)
                } catch let fetchError as NSError {
                    errorPointer?.pointee = fetchError
                    return nil
                }

            guard let workoutClass = try? classDocument.data(as: WorkoutClass.self) else {
                    let error = NSError(
                        domain: "AppErrorDomain",
                        code: -3,
                        userInfo: [
                            NSLocalizedDescriptionKey: "Unable to retrieve reservedUsers from snapshot \(classDocument)"
                        ]
                    )
                    errorPointer?.pointee = error
                  return nil
                }
            
            let isReserved = self.checkIsReserved(uid: uid, reservedUsers: workoutClass.reservedUsers ?? [] )
            
            if isReserved {
                transaction.updateData(["reservationCnt": decrement, "reservedUsers": removeUser], forDocument: classReference)
                return nil
            } else {
                print("user not in class so therefore can't be removed")
                return nil
            }
            
        } completion: { object, error in
            if let error = error {
                print(error.localizedDescription)
                self.isDisabled = false
            } else {
                print("Successfully ran removeReservation transaction with object: \(object ?? "")")
                self.isDisabled = false
            }
        }
    }
    
    func checkIsReserved(uid: String, reservedUsers: [reservedUser]) -> Bool {
      return reservedUsers.contains { $0.uid == uid }
    }
}

应用截图

预订按钮是视图底部的绿色/灰色按钮

应用截图

标签: firebasegoogle-cloud-firestoreswiftui

解决方案


我最终通过在决定是否显示“保留”或“取消”按钮的条件之前添加禁用检查条件来解决此问题。

这样,当我的 Firestore 事务正在运行时,用户将看到一个微调器,并且无法对按钮进行猴子测试。微调器有助于显示预订操作正在进行中。当事务达到其完成块时,我禁用isDisabledBool 并且侦听器处于同步状态(然后用户会看到新切换的按钮状态)

if workoutClassVM.isDisabled {
            ProgressView()
                .progressViewStyle(CircularProgressViewStyle(tint: Color("bruinGreenColor")))
} else if checkIsReserved(uid: authVM.user?.uid ?? "", reservedUsers: workoutClass.reservedUsers ?? []) {
...

推荐阅读