取消 async/await 网络请求

Cancelling an async/await Network Request

我有一个网络层,当前使用完成处理程序来传递操作完成的结果。

由于我支持多个 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 实现,我无法取消它。

应该如何处理?

如果您想取消,

async/await 可能不是合适的范例。原因是 Swift 中新的结构化并发支持允许您编写看起来 single-threaded/synchronous 的代码,但实际上它是多线程的。

以简单的同步代码为例:

let data = tryData(contentsOf: fileURL)

如果文件很大,那么操作可能需要很长时间才能完成,在此期间无法取消操作,并且调用者线程被阻塞。

现在,假设 Data 导出上述初始化程序的异步版本,您将编写类似于以下代码的异步版本:

let data = try await Data(contentsOf: fileURL)

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

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

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

如果您想从调用方范围存储这些标识符,那么您需要将您的操作分成两部分:初始化和执行。

类似于

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()

您不能将 Combine 与 async/await 杂交。如果您完全拥抱 async/await 并调用其中一种异步下载方法...

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

...那么您调用该方法的任务将可以通过标准结构化并发机制以良好的顺序取消。

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

Swift 的新并发模型可以很好地处理取消。虽然 WWDC 2021 视频侧重于 checkCancellation and isCancelled patterns (e.g., the Explore structured concurrency in Swift video), in this case, one would use withTaskCancellationHandler to create a task that cancels the network request when the task, itself, is canceled. (Obviously, this is only a concern in iOS 13/14, as in iOS 15 one would just use the provided async methods, data(for:delegate) or data(from:delegate:),但它也能很好地处理取消。)

例如,参见 SE-0300: Continuations for interfacing async tasks with synchronous code: Additional Examplesdownload 这个例子有点过时了,所以这里是一个更新的演绎版:

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

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

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

                        continuation.resume(returning: (data, response))
                    }
                }
            }
        }
    }

    private actor SessionTask {
        weak var task: URLSessionTask?

        func start(_ request: URLRequest, on session: URLSession, completion: @Sendable @escaping (Data?, URLResponse?, Error?) -> Void) {
            let task = session.dataTask(with: request, completionHandler: completion)
            task.resume()
            self.task = task
        }

        func cancel() {
            task?.cancel()
        }
    }
}

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

  • 我给出这些名称是为了避免与 iOS 15 方法名称冲突,但添加了 deprecated 消息以通知开发人员使用 iOS 15 再现一旦你放弃 iOS 13/14 支持。

  • 我偏离了 SE-0300 的示例以遵循 data(from:delegate:)data(for:delegate:) 方法的模式(返回带有 Data 和 [=22 的元组=]).

  • actor,不在 original example 中,需要同步对 URLSessionTask.

    的访问

但所有这些都与手头的问题无关。简而言之,使用 withTaskCancellationHandler.

例如这是我在任务组中发起的五个图像请求,由 Charles:

监控

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

(很明显x轴刻度不同。)