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。