CichlidGold
CichlidGold

Reputation: 35

Escape callback hell when using firebase functions

I'm familiar with JavaScript promises, but I'm new to swift and Firebase, and I don't have anyone to ask on my team. I've tried researching different ways of handling async operations without callback hell, but I can't understand how to make it work with firebase functions. Right now I'm using a really complicated mess of DispatchGroups and callbacks to make the code somewhat work, but I really want to make it cleaner and more maintainable.

My code looks something like this (error handling removed for conciseness):

var array = []
let dispatch = DispatchGroup()
db.collection("documentA").getDocuments() { (querySnapshot, err) in
for document in querySnapshot.documents 
    dispatch.enter()
    let dataA = document.data()["dataA"]
    ...
    db.collection("documentB").documents(dataA).getDocuments() { (document, error) in 
        let dataB = document.data()["dataB"]
        ...
        db.collection("documentC").documents(dataB).getDocuments() { (document, error) in
            let dataC = document.data()["dataC"]
            let newObject = NewObject(dataA,dataB,dataC)
            self.array.append(newObject)
            dispatch.leave()
          
        } 
    }
}
//Use dispatch group to notify main queue to update tableView using contents of this array

Does anyone have any recommended learning resources or advice on how I can tackle this problem?

Upvotes: 1

Views: 134

Answers (1)

Daniel T.
Daniel T.

Reputation: 33979

I recommend you consider bringing in the RxFirebase library. Rx is a great way to clean up nested closures (callback hell).

In looking over your sample code, the first thing you have to understand is that only so much can be done. The problem itself has a lot of essential complexity. Also, there's a lot going on in this code that can be broken out. Once you do that, you can boil down the problem to the following:

import Curry // the Curry library is in Cocoapods

func example(db: Firestore) -> Observable<[NewObject]> {
    let getObjects = curry(getData(db:collectionId:documentId:))(db)
    let xs = getObjects("documentA")("dataA")
    let xys = xs.flatMap { parentsAndChildren(fn: getObjects("documentB"), parent: { $0 }, xs: $0) }
    let xyzs = xys.flatMap { parentsAndChildren(fn: getObjects("documentC"), parent: { $0.1 }, xs: $0) }
    return xyzs.mapT { NewObject(dataA: $0.0.0, dataB: $0.0.1, dataC: $0.1) }
}

Note that this is making extensive use of higher order functions, so understanding those will help a lot. If you don't want to use higher order functions, you could use classes instead, but the amount of code you would have to write would at least double and you would likely have problems with memory cycles.


To make the above so simple requires some support code:

func getData(db: Firestore, collectionId: String, documentId: String) -> Observable<[String]> {
    return db.collection(collectionId).rx.getDocuments()
        .map { getData(documentId: documentId, snapshot: $0) }
}

func parentsAndChildren<X>(fn: (String) -> Observable<[String]>, parent: (X) -> String, xs: [X]) -> Observable<[(X, String)]> {
    Observable.combineLatest(xs.map { x in
        fn(parent(x)).map { apply(x: x, ys: $0) }
    })
    .map { $0.flatMap { $0 } }
}

extension ObservableType {
    func mapT<T, U>(_ transform: @escaping (T) -> U) -> Observable<[U]> where Element == [T] {
        map { $0.map(transform) }
    }
}

The getData(db:collectionId:documentId:) function asks for the strings in the collection associated with the document.

The parentsAndChildren(fn:parent:xs:) function is probably the most complex. It will extract the appropriate parent object from the generic X type, get the children from the server and roll them up into a single dimensional array of parents and children. For example if the parents are ["a", "b"], the children of "a" are ["w", "x"] and the children of "b" are ["y", "z"], then the function will output [("a", "w"), ("a", "x"), ("b", "y"), ("b", "z")] (contained in an Observable.)

The Observable.mapT(_:) function allows us to map through an Observable Array of objects and do something to them. Of course, you could just do xyzs.map { $0.map { NewObject(dataA: $0.0.0, dataB: $0.0.1, dataC: $0.1) } }, but I feel this is cleaner.

Here is the support code for the above functions:

extension Reactive where Base: CollectionReference {
    func getDocuments() -> Observable<QuerySnapshot> {
        Observable.create { [base] observer in
            base.getDocuments { snapshot, error in
                if let snapshot = snapshot {
                    observer.onNext(snapshot)
                    observer.onCompleted()
                }
                else {
                    observer.onError(error ?? RxError.unknown)
                }
            }
            return Disposables.create()
        }
    }
}

func getData(documentId: String, snapshot: QuerySnapshot) -> [String] {
    snapshot.documents.compactMap { $0.data()[documentId] as? String }
}

func apply<X>(x: X, ys: [String]) -> [(X, String)] {
    ys.map { (x, $0) }
}

The Reactive.getDocuments() function actually makes the firebase request. Its job is to turn the callback closure into an object so that you can deal with it easier. This is the piece that RxFirebase should give you, but as you can see, it's pretty easy to write it on your own.

The getData(documentId:snapshot:) function just extracts the appropriate data out of the snapshot.

The app(x:ys:) function is what keeps the whole thing in a single dimensional array by copying the X for each child.


Lastly, notice that most of the functions above are easily and independently unit testable and the ones that aren't are exceptionally simple...

Upvotes: 2

Related Questions