手把手教你封裝網絡層

做者: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

首先,後端 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 能夠很好地實現需求,就不必使用第三方庫了。在我看來,這些依賴會致使應用架構愈來愈複雜。

目前的解決方案由兩個類組成:NetworkServiceBackendService

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

BackendServiceBackendAPIRequest 做爲 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)
    }
}

NetworkServiceBackendServiceBackendAuth 三者均可以很容易地測試和維護。

將請求入隊

這裏涉及了幾個問題。咱們但願經過什麼方式執行網絡請求?當想要一次執行屢次請求呢?通常狀況下,當請求成功或失敗時,但願以什麼方式通知咱們?

我使用了 NSOperationQueueNSOperation 來執行網絡請求。在繼承 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 方法中執行它。handleSuccesshandleFailure 兩個方法做爲回調傳遞給了服務的 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 都有什麼好處呢?

  • 方便取消全部的網絡請求。

  • 爲了給用戶更好的體驗,當網絡很差的時候,取消全部正在下載圖像或請求非必需數據的操做。

  • 能夠構建一個優先級隊列用於提早執行一些請求,以便更快地獲得結果。

和Core Data共處

這是我不得不推遲發表這篇文章的緣由。在以前的幾個網絡層版本中,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 協議爲具體的映射器定義了用於解析響應的方法。

接着,這樣的映射器就能夠用在 operationsuccess 代碼塊中了。並且能夠直接操做指定類型的具體對象,而不是字典。這樣一切均可以很容易地進行測試了。

下面是解析數組的映射器:

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 靜態方法的參數分別是 objmapper 方法,成功解析以後會返回一個數組。若是有某一項解析失敗,能夠拋出一個錯誤,或者更糟地直接返回一個空數組做爲該映射器的結果,你來決定。另外,這個映射器但願傳給它的 obj 參數(從後端返回的響應數據)是個 JSON 數組。

下面是整個網絡層的 UML 圖:

diagram

示例項目

能夠在GitHub上找的示例項目。該項目中用到了僞造的後端 URL,因此任何請求都不會有響應。提供這個示例只是想讓你對這個網絡層的結構有個大體的認識。

總結

我發現用這種方法封裝的網絡層不只簡單並且頗有用:

  • 最大的優勢在於,能夠很容易地新增相似上文提到的 Operation,而不用關心 Core Data 的存在。

  • 能夠輕易地讓代碼覆蓋率接近100%,而無需考慮如何覆蓋某個難搞的情形,由於根本就不存在這麼難搞的情形!

  • 能夠在其餘相似的複雜應用中很容易地複用它的核心代碼。

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg

相關文章
相關標籤/搜索