做者:Tomasz Szulc,原文連接,原文日期:2016-07-30
譯者:智多芯;校對:Crystal Sun;定稿:CMBgit
同時負責兩個項目是個探索應用架構的好機會,能夠在項目中試驗一下已有的想法或剛學到的知識。我最近學習瞭如何封裝一個網絡層框架,說不定對你有所幫助。github
現在的移動應用幾乎都是「客戶端-服務端(client-server)」架構,在應用裏都會有網絡層,大小不一樣而已。我見過不少種實現方式,但都有一些缺陷。固然這並非說,我最近實現的這個一點缺陷也沒有,但至少在目前的兩個項目上都運行的很不錯。測試覆蓋率也將近百分百。json
本文涉及的網絡層僅限發送 JSON 請求給後端,也不會太複雜。該網絡層會和亞馬遜 AWS 通訊,而後向它發送一些文件。這個網絡層框架能容易地擴展其餘功能。swift
如下是我在開始寫一個網絡層以前會問本身的一些問題:後端
後端 URL 相關的代碼放在哪?數組
端點(endpoint)相關的代碼放在哪?網絡
構建請求的代碼放在哪?session
爲請求準備參數的代碼放在哪?架構
應該把認證令牌(authentication token)保存在哪?app
如何執行請求?
什麼時候何處執行請求?
是否須要考慮取消請求?
是否須要考慮錯誤的後端響應,是否須要考慮一些後端的 bug?
是否須要使用第三方庫?應該使用哪些庫?
是否有任何 Core Data 相關的東西進行傳遞?
如何測試解決方案。
首先,後端 URL 相關的代碼放在哪?系統的其餘部分代碼如何知道在哪裏發送請求?我傾向於建立一個 BackendConfiguration
類用來保存這些信息。
import Foundation public final class BackendConfiguration { let baseURL: NSURL public init(baseURL: NSURL) { self.baseURL = baseURL } public static var shared: BackendConfiguration! }
這樣易於測試,也易於配置。能夠在網絡層的任何地方讀寫靜態變量 shared
,而沒必要處處傳遞。
let backendURL = NSURL(string: "https://szulctomasz.com")! BackendConfiguration.shared = BackendConfiguration(baseURL: backendURL)
在找到一個行得通的辦法以前,我嘗試過配置 NSURLSession
時在代碼中硬編碼端點。也嘗試過新建一個管理端點的虛擬對象,它可以容易地被初始化和注入。不過這些都不是想要的方案。
接着我想到一個辦法,建立一個 Request
對象,這個對象知道向哪一個端點發送請求,知道該用 GET、POST、PUT 仍是其餘方法,也知道如何配置請求的消息體和頭部。
如下代碼就是想到的方案:
protocol BackendAPIRequest { var endpoint: String { get } var method: NetworkService.Method { get } var parameters: [String: AnyObject]? { get } var headers: [String: String]? { get } }
一個遵循了該協議的類可以提供必要的構建請求的基本信息。其中的 NetworkService.Method
只是一個枚舉,包含了 GET
, POST
, PUT
, DELETE
幾種方法。
用下面這段代碼舉例說明映射了某個端點的請求:
final class SignUpRequest: BackendAPIRequest { private let firstName: String private let lastName: String private let email: String private let password: String init(firstName: String, lastName: String, email: String, password: String) { self.firstName = firstName self.lastName = lastName self.email = email self.password = password } var endpoint: String { return "/users" } var method: NetworkService.Method { return .POST } var parameters: [String: AnyObject]? { return [ "first_name": firstName, "last_name": lastName, "email": email, "password": password ] } var headers: [String: String]? { return ["Content-Type": "application/json"] } }
爲了不老是爲 headers
建立字典,能夠爲 BackendAPIRequest
定義一個 extension
。
extension BackendAPIRequest { func defaultJSONHeaders() -> [String: String] { return ["Content-Type": "application/json"] } }
Request
類利用全部必需的參數建立一個可用的請求。要保證把全部必需的參數都傳給了 Request
類,不然無法建立請求。
定義端點就很簡單了。若是端點須要包含一個對象 id,添加也很是簡單,由於實際上只要把這個 id 做爲屬性保存在 SignUpRequest
類中就能夠了:
private let id: String init(id: String, ...) { self.id = id } var endpoint: String { return "/users/\(id)" }
請求方法不變、參數易於構建和維護,頭部也同樣,這樣就很容易對它們進行測試了。
是否須要使用第三方庫和後端通訊?
有不少人都在用 AFNetworking(Objective-C) 和 Alamofire(Swift)。我也用過不少次,但有時候我就不使用它們了。畢竟有 NSURLSession
能夠很好地實現需求,就不必使用第三方庫了。在我看來,這些依賴會致使應用架構愈來愈複雜。
目前的解決方案由兩個類組成:NetworkService
和 BackendService
。
NetworkService
:能夠執行HTTP請求,它內部集成了 NSURLSession
。每一個網絡服務一次只能執行一個請求,也可以取消請求(很大的優點),並且請求成功和失敗時都會有回調。
BackendService
:(不是一個很酷的名字,但恰到好處)用來將請求(就是上面提到的 Request
類)發送給後端。在內部使用了 NetworkService
。在當前使用的版本中,嘗試用 NSJSONSerializer
將後端返回的響應數據序列化成 JSON 格式的數據。
class NetworkService { private var task: NSURLSessionDataTask? private var successCodes: Range<Int> = 200..<299 private var failureCodes: Range<Int> = 400..<499 enum Method: String { case GET, POST, PUT, DELETE } func request(url url: NSURL, method: Method, params: [String: AnyObject]? = nil, headers: [String: String]? = nil, success: (NSData? -> Void)? = nil, failure: ((data: NSData?, error: NSError?, responseCode: Int) -> Void)? = nil) { let mutableRequest = NSMutableURLRequest(URL: url, cachePolicy: .ReloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10.0) mutableRequest.allHTTPHeaderFields = headers mutableRequest.HTTPMethod = method.rawValue if let params = params { mutableRequest.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(params, options: []) } let session = NSURLSession.sharedSession() task = session.dataTaskWithRequest(mutableRequest, completionHandler: { data, response, error in // 判斷調用是否成功 // 回調處理 }) task?.resume() } func cancel() { task?.cancel() } } class BackendService { private let conf: BackendConfiguration private let service: NetworkService! init(_ conf: BackendConfiguration) { self.conf = conf self.service = NetworkService() } func request(request: BackendAPIRequest, success: (AnyObject? -> Void)? = nil, failure: (NSError -> Void)? = nil) { let url = conf.baseURL.URLByAppendingPathComponent(request.endpoint) var headers = request.headers // 必要時設置 authentication token headers?["X-Api-Auth-Token"] = BackendAuth.shared.token service.request(url: url, method: request.method, params: request.parameters, headers: headers, success: { data in var json: AnyObject? = nil if let data = data { json = try? NSJSONSerialization.JSONObjectWithData(data, options: []) } success?(json) }, failure: { data, error, statusCode in // 錯誤處理,並調用錯誤處理代碼 }) } func cancel() { service.cancel() } }
BackendService
能夠在 headers
中設置認證令牌(authentication token)。其中 BackendAuth
只是個簡單的對象,用來將令牌保存到 UserDefaults
中。在必要的時候,也能夠將令牌保存在 Keychain
中。
BackendService
將 BackendAPIRequest
做爲 request(_:success:failure:)
方法的參數從 request
對象中提取出必要的信息,這保持了很好的封裝性。
public final class BackendAuth { private let key = "BackendAuthToken" private let defaults: NSUserDefaults public static var shared: BackendAuth! public init(defaults: NSUserDefaults) { self.defaults = defaults } public func setToken(token: String) { defaults.setValue(token, forKey: key) } public var token: String? { return defaults.valueForKey(key) as? String } public func deleteToken() { defaults.removeObjectForKey(key) } }
NetworkService
,BackendService
和 BackendAuth
三者均可以很容易地測試和維護。
這裏涉及了幾個問題。咱們但願經過什麼方式執行網絡請求?當想要一次執行屢次請求呢?通常狀況下,當請求成功或失敗時,但願以什麼方式通知咱們?
我使用了 NSOperationQueue
和 NSOperation
來執行網絡請求。在繼承 NSOperation
以後,重寫它的 asynchronous
屬性並返回 true
。
public class NetworkOperation: NSOperation { private var _ready: Bool public override var ready: Bool { get { return _ready } set { update({ self._ready = newValue }, key: "isReady") } } private var _executing: Bool public override var executing: Bool { get { return _executing } set { update({ self._executing = newValue }, key: "isExecuting") } } private var _finished: Bool public override var finished: Bool { get { return _finished } set { update({ self._finished = newValue }, key: "isFinished") } } private var _cancelled: Bool public override var cancelled: Bool { get { return _cancelled } set { update({ self._cancelled = newValue }, key: "isCancelled") } } private func update(change: Void -> Void, key: String) { willChangeValueForKey(key) change() didChangeValueForKey(key) } override init() { _ready = true _executing = false _finished = false _cancelled = false super.init() name = "Network Operation" } public override var asynchronous: Bool { return true } public override func start() { if self.executing == false { self.ready = false self.executing = true self.finished = false self.cancelled = false } } /// 只用於子類,外部調用時應使用 `cancel`. func finish() { self.executing = false self.finished = true } public override func cancel() { self.executing = false self.cancelled = true } }
接着,由於想經過 BackendService
執行網絡調用,因此繼承了 NetworkOperation
,並建立了 ServiceOperation
。
public class ServiceOperation: NetworkOperation { let service: BackendService public override init() { self.service = BackendService(BackendConfiguration.shared) super.init() } public override func cancel() { service.cancel() super.cancel() } }
這個類已經在它內部建立了 BackendService
,因此就不必每次都在子類中建立一次。
下面是 SignInOperation
的代碼:
public class SignInOperation: ServiceOperation { private let request: SignInRequest public var success: (SignInItem -> Void)? public var failure: (NSError -> Void)? public init(email: String, password: String) { request = SignInRequest(email: email, password: password) super.init() } public override func start() { super.start() service.request(request, success: handleSuccess, failure: handleFailure) } private func handleSuccess(response: AnyObject?) { do { let item = try SignInResponseMapper.process(response) self.success?(item) self.finish() } catch { handleFailure(NSError.cannotParseResponse()) } } private func handleFailure(error: NSError) { self.failure?(error) self.finish() } }
在 SignInOperation
初始化時建立了登陸請求,隨後在 start
方法中執行它。handleSuccess
和 handleFailure
兩個方法做爲回調傳遞給了服務的 request(_:success:failure:)
方法。我以爲這讓代碼看起來更乾淨,可讀性更強。
將 Operations
傳給 NetworkQueue
對象。NetworkQueue
對象是一個單例,能夠將每一個 Operation
入隊。暫時儘可能讓代碼保持簡潔吧:
public class NetworkQueue { public static var shared: NetworkQueue! let queue = NSOperationQueue() public init() {} public func addOperation(op: NSOperation) { queue.addOperation(op) } }
那麼,在同一個地方執行Operation
都有什麼好處呢?
方便取消全部的網絡請求。
爲了給用戶更好的體驗,當網絡很差的時候,取消全部正在下載圖像或請求非必需數據的操做。
能夠構建一個優先級隊列用於提早執行一些請求,以便更快地獲得結果。
這是我不得不推遲發表這篇文章的緣由。在以前的幾個網絡層版本中,Operation
都會返回 Core Data 對象。接收到的響應會被解析並轉換成 Core Data 對象。但是這種方案遠遠不夠完美。
SignInOperation
須要知道 Core Data 是個什麼東西。因爲我把數據模型獨立出來了,所以網絡庫也須要知曉數據模型。
每一個 SignInOperation
都須要增長一個額外的 NSManagedObjectContext
參數,用來決定在什麼上下文執行操做。
每次接收到響應並準備調用 success
的代碼以前,都會在 Core Data 上下文中查找對象,而後訪問磁盤並將其提取出來。我以爲這是個不足的地方,並非每次都想建立 Core Data 對象。
因此我想到應該把 Core Data 完徹底全地從網絡層中分離出去。因而建立了一箇中間層,其實也就是一些在解析響應時建立的對象。
這樣一來,解析和建立對象就很快了,並且不用訪問磁盤。
再也不須要將 NSManagedObjectContext
傳給 SignInOperation
了。
能夠在 success
代碼塊中使用解析過的數據來更新 Core Data 對象,而後引用以前可能保存在某處的 Core Data 對象——這是我在將 SignInOperation
入隊時會碰到的狀況。
響應映射器的思想主要是將解析邏輯和 JSON 映射邏輯分紅多個有用的單項。
能夠兩種不一樣的解析器區分開來,第一種只解析一個特定類型的對象,第二種用來解析對象數組。
首先定義一個通用協議:
public protocol ParsedItem {}
下面是映射器的映射結果:
public struct SignInItem: ParsedItem { public let token: String public let uniqueId: String } public struct UserItem: ParsedItem { public let uniqueId: String public let firstName: String public let lastName: String public let email: String public let phoneNumber: String? }
再定義一個錯誤類型,以便在解析發生錯誤時拋出。
internal enum ResponseMapperError: ErrorType { case Invalid case MissingAttribute }
Invalid
:當解析到的 JSON 爲 nil 且不應爲 nil,或者是一個對象數組而不是指望的只含單個對象的 JSON 時拋出。
MissingAttribute
:名字自己就能說明它的做用了。當 key 在 JSON 中不存在,或者解析後值爲 nil 且不應爲 nil 時拋出。
ResponseMapper
的實現以下:
class ResponseMapper<A: ParsedItem> { static func process(obj: AnyObject?, parse: (json: [String: AnyObject]) -> A?) throws -> A { guard let json = obj as? [String: AnyObject] else { throw ResponseMapperError.Invalid } if let item = parse(json: json) { return item } else { L.log("Mapper failure (\(self)). Missing attribute.") throw ResponseMapperError.MissingAttribute } } }
其中 process
靜態方法的參數分別是 obj
(也就是從後端返回的JSON)和 parse
方法(該方法會解析 obj
並返回一個 ParsedItem
類型的 A
對象)。
既然有了這個通用的映射器,接着就能夠建立具體的映射器了。先來看看用於解析 SignInOperation
響應的映射器:
protocol ResponseMapperProtocol { associatedtype Item static func process(obj: AnyObject?) throws -> Item } final class SignInResponseMapper: ResponseMapper<SignInItem>, ResponseMapperProtocol { static func process(obj: AnyObject?) throws -> SignInItem { return try process(obj, parse: { json in let token = json["token"] as? String let uniqueId = json["unique_id"] as? String if let token = token, let uniqueId = uniqueId { return SignInItem(token: token, uniqueId: uniqueId) } return nil }) } }
ResponseMapperProtocol
協議爲具體的映射器定義了用於解析響應的方法。
接着,這樣的映射器就能夠用在 operation
的 success
代碼塊中了。並且能夠直接操做指定類型的具體對象,而不是字典。這樣一切均可以很容易地進行測試了。
下面是解析數組的映射器:
final class ArrayResponseMapper<A: ParsedItem> { static func process(obj: AnyObject?, mapper: (AnyObject? throws -> A)) throws -> [A] { guard let json = obj as? [[String: AnyObject]] else { throw ResponseMapperError.Invalid } var items = [A]() for jsonNode in json { let item = try mapper(jsonNode) items.append(item) } return items } }
其中 process
靜態方法的參數分別是 obj
和 mapper
方法,成功解析以後會返回一個數組。若是有某一項解析失敗,能夠拋出一個錯誤,或者更糟地直接返回一個空數組做爲該映射器的結果,你來決定。另外,這個映射器但願傳給它的 obj
參數(從後端返回的響應數據)是個 JSON 數組。
下面是整個網絡層的 UML 圖:
能夠在GitHub上找的示例項目。該項目中用到了僞造的後端 URL,因此任何請求都不會有響應。提供這個示例只是想讓你對這個網絡層的結構有個大體的認識。
我發現用這種方法封裝的網絡層不只簡單並且頗有用:
最大的優勢在於,能夠很容易地新增相似上文提到的 Operation
,而不用關心 Core Data 的存在。
能夠輕易地讓代碼覆蓋率接近100%,而無需考慮如何覆蓋某個難搞的情形,由於根本就不存在這麼難搞的情形!
能夠在其餘相似的複雜應用中很容易地複用它的核心代碼。
本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg。