取消异步/等待网络请求

7

我有一个网络层,目前使用完成处理程序在操作完成时提供结果。

由于我支持多个iOS版本,因此我在应用程序中扩展了网络层以提供对Combine的支持。我现在想将其扩展为同时支持Async/Await,但我不知道如何以允许我取消请求的方式实现它。

基本实现如下:


protocol HTTPClientTask {
    func cancel()
}

protocol HTTPClient {
    typealias Result = Swift.Result<(data: Data, response: HTTPURLResponse), Error>
    @discardableResult
    func dispatch(_ request: URLRequest, completion: @escaping (Result) -> Void) -> HTTPClientTask
}

final class URLSessionHTTPClient: HTTPClient {
    
    private let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
    
    func dispatch(_ request: URLRequest, completion: @escaping (HTTPClient.Result) -> Void) -> HTTPClientTask {
        let task = session.dataTask(with: request) { data, response, error in
            completion(Result {
                if let error = error {
                    throw error
                } else if let data = data, let response = response as? HTTPURLResponse {
                    return (data, response)
                } else {
                    throw UnexpectedValuesRepresentation()
                }
            })
        }
        task.resume()
        return URLSessionTaskWrapper(wrapped: task)
    }
}

private extension URLSessionHTTPClient {
    struct UnexpectedValuesRepresentation: Error {}
    
    struct URLSessionTaskWrapper: HTTPClientTask {
        let wrapped: URLSessionTask
        
        func cancel() {
            wrapped.cancel()
        }
    }
}

它非常简单地提供了一个抽象,允许我注入一个 URLSession 实例。

通过返回 HTTPClientTask,我可以从客户端调用 cancel 并结束请求。

我在客户端应用程序中使用 Combine 扩展它如下:

extension HTTPClient {
    typealias Publisher = AnyPublisher<(data: Data, response: HTTPURLResponse), Error>

    func dispatchPublisher(for request: URLRequest) -> Publisher {
        var task: HTTPClientTask?

        return Deferred {
            Future { completion in
                task = self.dispatch(request, completion: completion)
            }
        }
        .handleEvents(receiveCancel: { task?.cancel() })
        .eraseToAnyPublisher()
    }
}

如您所见,我现在拥有一个支持取消任务的接口。

然而,使用async/await时,我不确定应该如何实现这个机制来取消请求。

我的当前尝试是:

extension HTTPClient {
    func dispatch(_ request: URLRequest) async -> HTTPClient.Result {

        let task = Task { () -> (data: Data, response: HTTPURLResponse) in
            return try await withCheckedThrowingContinuation { continuation in
                self.dispatch(request) { result in
                    switch result {
                    case let .success(values): continuation.resume(returning: values)
                    case let .failure(error): continuation.resume(throwing: error)
                    }
                }
            }
        }

        do {
            let output = try await task.value
            return .success(output)
        } catch {
            return .failure(error)
        }
    }
}

然而这个方法只提供了async实现,但我无法取消它。

应该如何处理?


1
异步/等待范式在这里并不适用,异步/等待允许您以同步方式编写代码,当您执行同步代码时,无法取消单个语句,必须等待其完成。如果您想要支持取消,您必须将任务存储在其他位置,并通过某个标识符访问该任务,例如。 - Cristik
如下所讨论的,异步/等待和Swift并发在这里非常适合。取消是Swift并发的核心特性。 - Rob
3个回答

12

Swift的新并发模型完美地处理了取消操作。虽然WWDC 2021视频着重介绍了checkCancellationisCancelled模式(例如在Swift中探索结构化并发视频),但在这种情况下,人们可以使用withTaskCancellationHandler创建一个任务,当任务本身被取消时会取消网络请求。(显然,这只是在iOS 13/14中的一个问题,在iOS 15中,人们可以直接使用提供的async方法data(for:delegate)data(from:delegate:), 它们也能很好地处理取消操作。)

请参阅SE-0300: Continuations for interfacing async tasks with synchronous code: Additional Examples以获取示例。那个download示例有点过时了,因此这里是一个更新版本:

extension URLSession {
    @available(iOS, deprecated: 15, message: "Use `data(from:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `data(from:delegate:)` instead")
    func data(with url: URL) async throws -> (URL, URLResponse) {
        try await download(with: URLRequest(url: url))
    }

    @available(iOS, deprecated: 15, message: "Use `data(for:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `data(for:delegate:)` instead")
    func data(with request: URLRequest) async throws -> (Data, URLResponse) {
        let sessionTask = SessionTask(session: self)

        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                Task {
                    await sessionTask.data(for: request) { data, response, error in
                        guard let data, let response else {
                            continuation.resume(throwing: error ?? URLError(.badServerResponse))
                            return
                        }

                        continuation.resume(returning: (data, response))
                    }
                }
            }
        } onCancel: {
            Task { await sessionTask.cancel() }
        }
    }
}

private extension URLSession {
    actor SessionTask {
        var state: State = .ready
        private let session: URLSession

        init(session: URLSession) {
            self.session = session
        }

        func cancel() {
            if case .executing(let task) = state {
                task.cancel()
            }
            state = .cancelled
        }
    }
}

// MARK: Data

extension URLSession.SessionTask {
    func data(for request: URLRequest, completionHandler: @Sendable @escaping (Data?, URLResponse?, Error?) -> Void) {
        if case .cancelled = state {
            completionHandler(nil, nil, CancellationError())
            return
        }

        let task = session.dataTask(with: request, completionHandler: completionHandler)

        state = .executing(task)
        task.resume()
    }
}

extension URLSession.SessionTask {
    enum State {
        case ready
        case executing(URLSessionTask)
        case cancelled
    }
}

以下是我对代码片段的一些小观察:

  • 我给它们取了这些名称,以避免与iOS 15方法名称冲突,但添加了deprecated消息,以通知开发人员在放弃iOS 13/14支持后使用iOS 15版本。

  • 我偏离了SE-0300的示例,以遵循data(from:delegate:)data(for:delegate:)方法的模式(返回一个带有DataURLResponse的元组)。

  • actor不在原始示例中,但需要同步访问URLSessionTask

  • 请注意,根据SE-0304,关于withTaskCancellationHandler

    如果任务在调用withTaskCancellationHandler时已被取消,则立即调用取消处理程序,而不是执行操作块。

    因此,上述actor使用state变量确定请求是否已被取消,并在已取消时立即恢复,如果已取消则抛出CancellationError

但所有这些都与本文讨论的问题无关。简而言之,请使用withTaskCancellationHandler

例如,这里有五个图像请求,我在任务组中启动它们,并由Charles监视:

enter image description here

这里是相同的请求,但这次我取消了整个任务组(并且取消成功地停止了与之关联的网络请求):

enter image description here

(显然,x轴刻度不同。)

如果您需要下载副本(以包装downloadTask),您可以通过以下方式进行补充:

extension URLSession {
    @available(iOS, deprecated: 15, message: "Use `download(from:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `download(from:delegate:)` instead")
    func download(with url: URL) async throws -> (URL, URLResponse) {
        try await download(with: URLRequest(url: url))
    }

    @available(iOS, deprecated: 15, message: "Use `download(for:delegate:)` instead")
    @available(macOS, deprecated: 12, message: "Use `download(for:delegate:)` instead")
    func download(with request: URLRequest) async throws -> (URL, URLResponse) {
        let sessionTask = SessionTask(session: self)

        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                Task {
                    await sessionTask.download(for: request) { location, response, error in
                        guard let location, let response else {
                            continuation.resume(throwing: error ?? URLError(.badServerResponse))
                            return
                        }

                        // since continuation can happen later, let’s figure out where to store it ...

                        let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
                            .appendingPathComponent(UUID().uuidString)
                            .appendingPathExtension(request.url!.pathExtension)

                        // ... and move it to there

                        do {
                            try FileManager.default.moveItem(at: location, to: tempURL)
                        } catch {
                            continuation.resume(throwing: error)
                            return
                        }

                        continuation.resume(returning: (tempURL, response))
                    }
                }
            }
        } onCancel: {
            Task { await sessionTask.cancel() }
        }
    }
}

extension URLSession.SessionTask {
    func download(for request: URLRequest, completionHandler: @Sendable @escaping (URL?, URLResponse?, Error?) -> Void) {
        if case .cancelled = state {
            completionHandler(nil, nil, CancellationError())
            return
        }

        let task = session.downloadTask(with: request, completionHandler: completionHandler)

        state = .executing(task)
        task.resume()
    }
}

那么,无论成功或失败,取消处理程序始终被调用(就像finally子句一样),但我们是否知道KVO通知是否被正确抛出(如NSOperation.cancel)? - MandisaW
  1. cancellationHandler 只有在取消任务时才会被调用。否则,它只是在其自然生命周期内完成。
  2. 没有 KVO 通知。这不是一个 Operation。但是,无论您是取消还是正常完成,任务的 await 都会得到正确满足。
- Rob
在 Xcode 13.3(Beta 2)中,这会产生警告:“在 @Sendable 闭包中捕获具有非可发送类型 'URLRequest' 的 'request'”,“传递给隐式异步调用的非可发送类型 'URLSessionTask' 无法跨越演员边界调用独立实例方法 'start'”。 - Ortwin Gentz
我认为第二个测试版引发的警告不容易解决,因为它没有清楚地识别可发送的对象(例如,一个不可变的URLRequest常量显然是可捕获的,但beta 2不允许这样做)。我建议我们密切关注未来的测试版。现在很难修复。 - Rob
目前,您可以在此扩展之前添加@_predatesConcurrency,然后再导入Foundation。 - Rob

0

你不能将Combine与async/await混合使用。如果你完全采用async/await并调用其中一个异步下载方法...

https://developer.apple.com/documentation/foundation/urlsession/3767353-data

如果你通过标准结构化并发机制调用该方法,则可以很好地取消调用该方法的任务。

因此,如果您想支持Swift 5.5 / iOS 15异步功能,并且还要支持早期版本,您需要两个完全独立的实现来完成此功能。


1
我认为Combine片段旨在作为异步等待中他想要实现的示例,而不是他计划与异步等待一起使用的内容。 - Rob

-1

如果你想要取消操作,async/await可能不是合适的范式。原因在于Swift中的新结构化并发支持允许你编写看起来像单线程/同步的代码,但实际上它是多线程的。

以一个简单的同步代码为例:

let data = tryData(contentsOf: fileURL)

如果文件很大,那么操作可能需要很长时间才能完成,在此期间操作无法取消,并且调用线程被阻塞。
现在,假设 Data 导出了上述初始化程序的异步版本,则可以编写类似于以下代码的异步版本:
let data = try await Data(contentsOf: fileURL)

对于开发人员来说,编码风格是相同的,一旦操作完成,他们要么有一个可用的data变量,要么会收到一个错误。

在这两种情况下,没有内置的取消功能,因为从开发人员的角度来看,操作是同步的。主要区别在于等待调用不会阻塞调用线程,但另一方面,一旦控制流返回,代码可能会在不同的线程上继续执行。

现在,如果您需要支持取消操作,则必须在某个地方存储可识别的数据,以用于取消操作。

如果您想从调用者范围存储这些标识符,则需要将操作分为两个部分:初始化和执行。

大致如下:

extension HTTPClient {
    // note that this is not async
    func task(for request: URLRequest) -> HTTPClientTask {
        // ...
    }
}

class HTTPClientTask {
    func dispatch() async -> HTTPClient.Result {
        // ...
    }
}

let task = httpClient.task(for: urlRequest)
self.theTask = task
let result = await task.dispatch()

// somewhere outside the await scope
self.theTask.cancel()

4
如果你需要取消操作,异步/等待可能不是合适的范例。新的结构化并发模型考虑到了取消操作,有关详细信息在“探索Swift中的结构化并发”WWDC视频中有所涵盖。 - Rob
2
@Rob 我想说的是“来自封闭作用域的取消”,据我所知,在任何具有“await”功能的编程语言中,都没有办法取消等待操作。底层操作可能是可取消的,在这种情况下,等待将报告错误,但您无法取消等待本身。 - Cristik
不确定那如何符合提问者的模型,@Rob,这就是为什么我在我的回答中加入了“取消”声明。 - Cristik
我的观点仅仅是在async-await范式中取消网络请求非常简单。原帖的问题只是在他的async版本中没有正确处理协作取消。这个问题不是“我该如何取消它”,而是“我该如何使它可取消”。 - Rob

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接