取消正在进行的请求是任何网络库的重要功能,也是我们希望在此框架中支持的功能。
配置 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。