Swift中的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。