Anton
Anton

Reputation: 3257

Sequence of serial network calls in swift

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.

Upvotes: 1

Views: 1177

Answers (1)

Rob
Rob

Reputation: 437552

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.

Upvotes: 2

Related Questions