1

Sometimes I need to make a series of network calls where each one depends on the prior ones, so they must be done in series. Typically, network calls take a completion handler as an argument. A series of calls can be done via nested completion handlers, but that gets difficult to read.

As an alternative, I've been dispatching the process to a global queue and using a Grand Central Dispatch DispatchSemaphore to stall until the network query returns. But it doesn't seem very elegant. Here is some example code:

let networkSemaphore = DispatchSemaphore(value: 0)

var body: some View {
    Text("Hello, world!")
        .padding()
        .onAppear { doChainOfEvents() }
}

func networkFetch(item: String, callback: @escaping (Int) -> Void) {
    print("Loading \(item)...")
    DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(2)) {
        let numLoaded = Int.random(in: 1...100)
        callback(numLoaded)
        networkSemaphore.signal()
    }
    networkSemaphore.wait()
}

func doChainOfEvents() {
    DispatchQueue(label: "com.test.queue.serial").async {
        networkFetch(item: "accounts") { num in
            print("\(num) accounts loaded.")
        }
        networkFetch(item: "history") { num in
            print("\(num) messages loaded.")
        }
        networkFetch(item: "requests") { num in
            print("\(num) requests loaded")
        }
        print("Network requests complete. ✓")
    }
    print("Control flow continues while network calls operate.")
}

and the printed result of doChainOfEvents():

Control flow continues while network calls operate.
Loading accounts...   // (2 second delay)
79 accounts loaded.
Loading history...    // (2 second delay)
87 messages loaded.
Loading requests...   // (2 second delay)
54 requests loaded
Network requests complete. ✓

Can you think of more elegant way to achieve this? It seems to me there ought to be one within using Grand Central Dispatch. I could use a DispatchGroup in place of the semaphore, but I don't think it would buy me anything, and a group with one item at a time seems silly.

Anton
  • 2,512
  • 2
  • 20
  • 36
  • 1
    Yeah, anything where you are calling `wait` is going to be an inherently inefficient and inflexible design. Given that you're using SwiftUI, I would reach for [Combine](https://developer.apple.com/documentation/combine/), which handles dependencies between tasks quite elegantly. – Rob Apr 07 '21 at 20:09
  • You could use `(NS)Operation` too. – Larme Apr 07 '21 at 20:10
  • That having been said, unless you need the results of one query in order to prepare the next, I'd pursue a concurrent pattern. Running these requests sequentially is going to magnify the network latency effects, making the whole process considerably slower than it needs to be. Let them run concurrently where possible. – Rob Apr 07 '21 at 20:11
  • Ah, Combine! Thank you, @Rob. I don't know it really because I've only used it implicitly in SwiftUI's property wrappers. I didn't realize it had dependencies like that. I don't suppose you'd care to offer a solution using Combine? :) (And yes, I would certainly run the network calls concurrently instead if they didn't depend on each other.) – Anton Apr 07 '21 at 20:37

1 Answers1

2

With Combine you can chain a series of requests, e.g., create a property to hold the AnyCancellable object:

var cancellable: AnyCancellable?

And then, assuming you needed “accounts” to fetch the “history”, and needed the “history” to fetch the “requests”, you could do something like:

func startRequests() {
    cancellable = accountsPublisher()
        .flatMap { accounts in self.historyPublisher(for: accounts) }
        .flatMap { history in self.requestsPublisher(for: history) }
        .subscribe(on: DispatchQueue.main)
        .sink { completion in
            print(completion)
        } receiveValue: { requests in
            print(requests)
        }
}

That will run the publishers sequentially, passing the value from one to the next.


It's not relevant the question at hand, but here is my mockup:

struct Account: Codable {
    let accountNumber: String
}

struct History: Codable {
    let history: String
}

struct Request: Codable {
    let identifier: String
}

struct ResponseObject<T: Decodable>: Decodable {
    let json: T
    let origin: String
}

func accountsPublisher() -> AnyPublisher<[Account], Error> {
    URLSession.shared
        .dataTaskPublisher(for: accountRequest)
        .map(\.data)
        .decode(type: ResponseObject<[Account]>.self, decoder: decoder)
        .map(\.json)
        .eraseToAnyPublisher()
}

func historyPublisher(for accounts: [Account]) -> AnyPublisher<History, Error> {
    URLSession.shared
        .dataTaskPublisher(for: historyRequest)
        .map(\.data)
        .decode(type: ResponseObject<History>.self, decoder: decoder)
        .map(\.json)
        .eraseToAnyPublisher()
}

func requestsPublisher(for history: History) -> AnyPublisher<[Request], Error> {
    URLSession.shared
        .dataTaskPublisher(for: requestsRequest)
        .map(\.data)
        .decode(type: ResponseObject<[Request]>.self, decoder: decoder)
        .map(\.json)
        .eraseToAnyPublisher()
}

var accountRequest: URLRequest = {
    let url = URL(string: "http://httpbin.org/post")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try! JSONEncoder().encode([Account(accountNumber: "123"), Account(accountNumber: "789")])
    return request
}()

var requestsRequest: URLRequest = {
    let url = URL(string: "http://httpbin.org/post")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try! JSONEncoder().encode([Request(identifier: "bar"), Request(identifier: "baz")])
    return request
}()

var historyRequest: URLRequest = {
    let url = URL(string: "http://httpbin.org/post")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try! JSONEncoder().encode(History(history: "foo"))
    return request
}()

Now, in the above example, I am using httpbin.org/post to parrot back data to me under the json key. Obviously, this is not a realistic scenario, but it illustrates the pattern of chaining various publishers together (and using Combine to get out of the weeds of writing imperative code).

So do not get lost in the weeds of the mockup, but rather focus on the sequential nature of the requests in this timeline:

timeline of requests


Or see this answer for an example of how to use Combine to perform requests concurrently, but constrain the degree of concurrency to something reasonable. Whenever possible, run requests concurrently because, otherwise, network latency effects will compound and make the overall process much slower.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thank you, Rob!! – Anton Apr 08 '21 at 22:30
  • Is there any way to do similar work but not with URLSession? What to do if I need to use some custom networking? – HammerSlavik Aug 25 '22 at 20:42
  • If your custom networking framework has a publisher API, then it should drop in without any issue. If it is, for example, a completion handler API, then you’ll have to create a publisher for it. Now, all of that assumes you’re already using Combine in your project. If not, you might consider async-await pattern of Swift concurrency, instead. But, in short, there’s not enough information here to answer your question and you might want to post your own question with a [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example). – Rob Aug 25 '22 at 21:01