Swift中的HTTP(十) 取消

语言: CN / TW / HK

HTTP简介

HTTP基础结构

HTTP请求体

HTTP 加载请求

HTTP 模拟测试

HTTP 链式加载器

HTTP 动态修改请求

HTTP 请求选项

HTTP 重置

HTTP 取消

HTTP 限流

HTTP 重试

HTTP 基础鉴权

HTTP 自动鉴权设置

HTTP 自动鉴权

HTTP 复合加载器

HTTP 头脑风暴

HTTP 总结

取消正在进行的请求是任何网络库的重要功能,也是我们希望在此框架中支持的功能。

配置 Setup

为了支持取消,我们需要对迄今为止构建的 API 进行最后一次重大更改,如下所示:

``` open class HTTPLoader {

func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void)
func reset(with group: DispatchGroup)

} ```

我们看到的局限性是,一旦我们开始加载请求,我们就无法引用该请求的“执行”; 回想一下 HTTPRequest 是一种值类型,因此它可能被复制和复制无数次。

因此,我们需要引入一些状态来跟踪加载和完成 HTTPRequest 的任务。 从 URLSession 中得到启发,我将其称为 HTTPTask

``` public class HTTPTask { public var id: UUID { request.id } private var request: HTTPRequest private let completion: (HTTPResult) -> Void

public init(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) {
    self.request = request
    self.completion = completion
}

public func cancel() {
    // TODO
}

public func complete(with result: HTTPResult) {
    completion(result)
}

} ```

不出所料,我们需要更改 HTTPLoader 才能使用它:

open class HTTPLoader { ... open func load(task: HTTPTask) { if let next = nextLoader { next.load(task: task) } else { // a convenience method to construct an HTTPError // and then call .complete with the error in an HTTPResult task.fail(.cannotConnect) } } ... }

构造一个任务对于客户来说可能有点冗长,所以为了方便起见,我们将保留原始方法:

extension HTTPLoader { ... public func load(request: HTTPRequest, completion: @escaping (HTTPResult) -> Void) -> HTTPTask { let task = HTTPTask(request: request, completion: completion) load(task: task) return task } ... }

这是基本的基础设施。 现在让我们谈谈取消。

禁忌

取消是一个极其复杂的话题。 从表面上看,它似乎很简单,但即使是快速浏览下面也会很快变得混乱。 首先,取消实际上意味着什么? 如果我有某种请求并且我“取消”了它,预期的行为是什么?

如果我在将请求传递给加载程序之前取消请求,完成处理程序是否应该触发? 为什么或者为什么不? 如果我将取消的请求传递给加载程序,加载程序是否应该尝试加载它? 为什么或者为什么不?

如果我在开始加载请求之后但在它到达终端加载程序之前取消请求,当前加载程序是否应该识别它? 是否应将已取消的请求进一步传递到链下? 如果不是,谁负责调用完成处理程序,如果它不是最后一个加载程序?

如果我在请求到达终端加载程序后取消请求,它是否应该停止传出网络连接? 如果我已经开始收到回复怎么办? 如果我已经收到响应但还没有开始执行完成处理程序怎么办?

如果我在完成处理程序执行后取消请求,会发生什么事吗? 为什么或者为什么不?

我如何在仍然允许线程安全的情况下完成所有这些工作?

这些都是复杂的问题,答案更复杂,我绝不声称拥有所有答案,我甚至不声称拥有好的代码来尝试和实现这些答案。 实施正确的取消方案是出了名的困难; 询问任何试图实现自己的 NSOperation 子类的开发人员。

当我在我们的网络库中解释有关取消的概念时,请理解代码和概念是不完整的。 我在第一篇文章中警告过你。 因此,代码中会有很多 // TODO: 注释。

对取消做出反应

所以我们现在在我们的 HTTPTask 上有这个 cancel() 方法,但是我们需要一种方法让各种加载器对它的调用做出反应。 基本上,我们需要一个闭包列表来在任务被取消时运行。 为此,让我们向任务添加一个“取消回调”数组:

``` public class HTTPTask { ... private var cancellationHandlers = Array<() -> Void>()

public func addCancellationHandler(_ handler: @escaping () -> Void>) {
    // TODO: make this thread-safe
    // TODO: what if this was already cancelled?
    // TODO: what if this is already finished but was not cancelled before finishing?
    cancellationHandlers.append(handler)
}

public func cancel() {
    // TODO: toggle some state to indicate that "isCancelled == true"
    // TODO: make this thread-safe
    let handlers = cancellationHandlers
    cancellationHandlers = []

    // invoke each handler in reverse order
    handlers.reversed().forEach { $0() }
}

} ```

在我们用于与 URLSession 交互的加载器中,如果在 HTTPTask 上调用 cancel(),我们现在可以取消我们的 URLSessionDataTask

``` public class URLSessionLoader: HTTPLoader { ...

open func load(task: HTTPTask) {
    ... // constructing the URLRequest from the HTTPRequest
    let dataTask = self.session.dataTask(with: urlRequest) { ... }

    // if the HTTPTask is cancelled, also cancel the dataTask
    task.addCancellationHandler { dataTask.cancel() }
    dataTask.resume()
}

} ```

这为我们提供了取消的基础知识。 如果我们在任务到达终端加载程序后取消,它将取消底层的 URLSessionDataTask 并允许 URLSession 响应机制指示后续行为:我们将通过 .cancelled 代码返回 URLError。

按照目前的情况,如果我们在请求到达终端加载程序之前取消请求,则什么也不会发生。 如果我们在完成加载后取消请求,同样什么也不会发生。

“正确”的行为是您的需求与合理实施相结合的复杂相互作用。 “100%”正确的解决方案将需要一些非常仔细的工作,涉及同步原语(例如 NSRecursiveLock)和非常仔细的状态管理。

不言而喻,没有任何正确取消的解决方案是正确的,除非它还伴随着大量的单元测试。 恭喜! 你已经从地图上掉下来了。

自动取消加载器

我们会在这一点上挥手,并假设我们的取消逻辑“足够好”。 老实说,一个简单的解决方案对于大多数情况来说可能已经“足够好”,所以即使是这个简单的“取消处理程序”数组也能用一段时间。 因此,让我们继续前进,构建一个基于取消的加载器。

我们之前已经确定我们需要能够“重置”加载程序链以提供“从头开始”的语义。 “重新开始”的一部分是取消我们所有的飞行请求; 我们不能“重新开始”并且仍然保留我们之前堆栈的残余。

因此,我们构建的加载器会将“取消”与“重置”的概念联系起来:当加载器收到对 reset() 的调用时,它会立即cancel() 任何正在进行的请求,并且只允许重置完成一次 其中的请求已经完成。

这意味着我们需要跟踪通过我们的任何请求,并在它们完成时忘记它们:

``` public class Autocancel: HTTPLoader { private let queue = DispatchQueue(label: "AutocancelLoader") private var currentTasks = UUID: HTTPTask

public override func load(task: HTTPTask) {
    queue.sync {
        let id = task.id
        currentTasks[id] = task
        task.addCompletionHandler { _ in
            self.queue.sync {
                self.currentTasks[id] = nil
            }
        }
    }

    super.load(task: task)
}

} ```

当任务到来时,我们会将其添加到已知任务的字典中; 我们将根据任务的标识符查找它。 然后当任务完成时,我们将从我们的字典中删除它。 通过这种方式,我们将始终对正在进行但尚未完成的任务进行最新映射。

我们的加载器还需要对 reset() 方法做出反应:

``` public class Autocancel: HTTPLoader { ... public override func reset(with group: DispatchGroup) { group.enter() // indicate that we have work to do queue.async { // get the list of current tasks let copy = self.tasks self.tasks = [:] DispatchQueue.global(qos: .userInitiated).async { for task in copy.values { // cancel the task group.enter() task.addCompletionHandler { _ in group.leave() } task.cancel() } group.leave() } }

    nextLoader?.reset(with: group)
}

} ``` 这个逻辑有点微妙,所以我解释一下:

当 reset() 调用进入时,我们立即进入 DispatchGroup 以指示我们有一些工作要执行。 然后我们将获取当前任务列表(即字典中的任何内容)。

对于每个任务,我们再次进入 DispatchGroup 以将该特定任务的生命周期与整个重置请求联系起来。 当任务“完成”时,该任务将离开组。 然后我们指示任务取消()。

在我们完成指示每个任务取消后,我们让 DispatchGroup 正确地平衡我们最初的 enter() 调用。

此实现是使用 DispatchGroup 作为重置协调机制的优势的主要示例。 我们无法在编译时知道哪个任务将首先完成,或者是否有任何任务要取消。 如果我们使用单个完成处理程序作为发出“完成重置”信号的方式,我们将很难正确实现此方法。 由于我们使用的是 DispatchGroup,因此我们所要做的就是根据需要多次执行 enter() 和 leave() 。

这两种方法意味着当这个加载器包含在我们的链中时,我们将自动取消所有飞行中的请求作为整体“重置”命令的一部分,并且直到所有飞行中的请求完成后重置才会完成。 整洁的!

``` // A networking chain that: // - prevents you from resetting while a reset command is in progress // - automatically cancels in-flight requests when asked to reset // - updates requests with missing server information with default or per-request server environment information // - executes all of this on a URLSession

let chain = resetGuard --> autocancel --> applyEnvironment --> ... --> urlSessionLoader ```

在下一篇文章中,我们将研究如何自动限制传出请求,这样我们就不会不小心对我们的服务器进行 DDOS。