如何防止 Firestore 为预订按钮写入竞争条件

Posted

技术标签:

【中文标题】如何防止 Firestore 为预订按钮写入竞争条件【英文标题】:How to prevent Firestore write race conditions for a reservation button 【发布时间】:2021-11-09 00:48:36 【问题描述】:

总结

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

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

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

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

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

MVVM 代码片段

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

ReservationButtonView

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 

WorkoutClassModel

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 
    

应用截图

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

【问题讨论】:

这是一个生命周期问题。当您添加/删除预订时,您的对象仍然存在于某处。您需要在生命周期的早期更新该值。因此,要么追踪它,要么将您的预订按钮设置为只工作一次。我个人会选择后一种选择。一个简单的布尔值,在您点击按钮时设置为 true。 我不认为只让按钮工作一次对我有用,因为我希望用户能够预订、取消预订和再次预订。我可以看到这源于生命周期问题,所以我会进一步研究,看看我是否能想出任何办法。 原来在 Firestore 事务之后直接切换 isDisabled 是个坏主意。在我看来,我的条件依赖于 Firestore 侦听器,该侦听器在事务完成后会更新一点,从而导致竞争条件。这对于为什么我在事务中进行的检查会停止行为是有道理的,但理想情况下,我需要找到一种方法将 isDisabled 布尔值附加到 Firestore 侦听器。 问题解决了吗? 如果你猴子测试这个按钮并按下它很多次,你所做的就是在文档上执行一堆写操作,最终监听器将使用最后一个解析按钮的状态- 执行写入。如果侦听器可以完全控制按钮,则该按钮永远不会脱离状态。至于计数器,我会考虑删除增量器/减量器模式并简单地显示计数的计算,特别是考虑到班级规模永远不会是一个天文数字。 【参考方案1】:

由于这是一个竞争条件,您已经确认使用事务进行更新,这是最理想的,因为这可以确保在允许应用更改按钮状态之前更新成功。 IE。通过使用事务并仅在成功时更新 UI Button 状态,对此进行了解释here

建议将按钮的状态映射到文档中的内容,因此您可能会根据按钮的翻转连续更新同一字段来超过速率limits。 处理这种注册状态跟踪的另一种方法是将一个新文档添加到一个集合中,该文档指示用户的注册状态,该集合是他们正在注册的类。 IE。与其让班级用户注册成为一个文档,不如将其设为一个集合,并且每次注册状态发生变化时,编写一个新文档。这将允许在不使用事务的情况下进行更新,并且当前的注册状态包含在最新文档中。这个最新的文档可以被读取并用作应用程序中按钮的状态,另外还有一个好处是状态将始终更新为 Firestore 中包含的状态。

【讨论】:

@Thomas Burke 发表了答案,有用吗?【参考方案2】:

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

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

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

【讨论】:

以上是关于如何防止 Firestore 为预订按钮写入竞争条件的主要内容,如果未能解决你的问题,请参考以下文章

如何检查某个数据是不是已存在于firestore中

iOS - Firestore 文档同时写入多个

NHibernate 事务和竞争条件

防止共享哈希表中的数据竞争

如何在 Swift 中确认订单后发送电子邮件

如何使用 SwiftUI 将值数组写入 Firestore