[譯] Swift 寫網路層:用面向協議的方式

talisk發表於2019-03-04

在本指南中,我們將介紹如何在沒有任何第三方庫的情況下以純 Swift 實現網路層。讓我們快開始吧!閱讀了本指南後,我們的程式碼應該是:

  • 面向協議
  • 易於使用
  • 易於實現
  • 型別安全
  • 使用列舉來配置 endPoints

以下是我們最終通過網路層實現的一個例子:

[譯] Swift 寫網路層:用面向協議的方式

該專案的最終目標。

藉助列舉輸入 router.request(.,我們可以看到所有可用的端點以及該請求所需的引數。

首先,一些關於結構的東西

在建立任何東西時,結構總是非常重要的,好的結構便於以後找到所需。我堅信資料夾結構是軟體架構的一個關鍵貢獻者。為了讓我們的檔案保持良好的組織性,我們事先就建立好所有組,然後記下每個檔案應該放在哪裡。這是一個對專案結構的概述。(請注意以下名稱都只是建議,你可以根據自己的喜好命名你的類和分組。

[譯] Swift 寫網路層:用面向協議的方式

專案目錄結構。

EndPointType 協議

我們需要的第一件事是定義我們的 EndPointType 協議。該協議將包含配置 EndPoint 的所有資訊。什麼是 EndPoint?本質上它是一個 URLRequest,它包含所有包含的元件,如標題,query 引數和 body 引數。EndPointType 協議是我們網路層實現的基石。接下來,建立一個檔案並將其命名為 EndPointType。將此檔案放在 Service 組中。(請注意不是 EndPoint 組,這會隨著我們的繼續變得更清晰)。

[譯] Swift 寫網路層:用面向協議的方式

EndPointType 協議。

HTTP 協議

我們的 EndPointType 具有構建整個 endPoint 所需的大量HTTP協議。讓我們來探索這些協議的含義。

HTTPMethod

建立一個名為 HTTPMethod 的檔案,並把它放到 Service 組裡。這個列舉將被用於為我們的請求設定 HTTP 方法。

[譯] Swift 寫網路層:用面向協議的方式

HTTPMethod 列舉。

HTTPTask

建立一個名為 HTTPTask 的檔案,並把它放到 Service 組裡。HTTPTask 負責為特定的 endPoint 配置引數。你可以新增儘可能多的適用於你的網路層要求的情況。 我將要發一個請求,所以我只有三種情況。

[譯] Swift 寫網路層:用面向協議的方式

HTTPTask 列舉。

我們將在下一節討論引數以及引數的編解碼。

HTTPHeaders

HTTPHeaders 僅僅是字典的 typealias(別名)。你可以在 HTTPTask 檔案的開頭寫下這個 typealias。

public typealias HTTPHeaders = [String:String]
複製程式碼

引數及其編解碼

建立一個名為 ParameterEncoding 的檔案,並把它放到 Encoding 組裡。然後首要之事便是定義 Parameters 的 typealias。我們利用 typealias 使我們的程式碼更簡潔、清晰。

public typealias Parameters = [String:Any]
複製程式碼

接下來,用一個靜態函式 encode 定義一個協議 ParameterEncoderencode 方法包含 inout URLRequestParameters 這兩個引數。inout 是一個 Swift 的關鍵字,它將引數定義為引用引數。通常來說,變數以值型別傳遞給函式。通過在引數前面新增 inout,我們將其定義為引用型別。要了解更多關於 inout 引數的資訊,你可以參考這裡ParameterEncoder協議將由我們的 JSONParameterEncoderURLPameterEncoder 實現。

public protocol ParameterEncoder {
 static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}
複製程式碼

ParameterEncoder 執行一個函式來編碼引數。此方法可能失敗而丟擲錯誤,需要我們處理。

可以證明丟擲自定義錯誤而不是標準錯誤是很有價值的。我總是發現自己很難破譯 Xcode 給出的一些錯誤。通過自定義錯誤,您可以定義自己的錯誤訊息,並確切知道錯誤來自何處。為此,我只需建立一個從 Error 繼承的列舉。

[譯] Swift 寫網路層:用面向協議的方式

NetworkError 列舉。

URLParameterEncoder

建立一個名為 URLParameterEncoder 的檔案,並把它放到 Encoding 組裡。

[譯] Swift 寫網路層:用面向協議的方式

URLParameterEncoder 的程式碼。

上面的程式碼傳遞了引數,並將引數安全地作為 URL 型別的引數傳遞。正如你應該知道,有一些字元在 URL 中是被禁止的。引數需要用「&」符號分開,所以我們應該注意遵循這些規範。如果沒有設定 header,我們也要為請求新增適合的 header。

這個程式碼示例是我們應該考慮使用單元測試進行測試的。正確構建 URL 是至關重要的,不然我們可能會遇到許多不必要的錯誤。如果你使用的是開放 API,你肯定不希望配額被大量失敗的測試耗盡。如果你想了解更多有關單元測試方面的知識,可以閱讀 S.T.Huang 寫的這篇文章

JSONParameterEncoder

建立一個名為 JSONParameterEncoder 的檔案,並把它放到 Encoding 組裡。

[譯] Swift 寫網路層:用面向協議的方式

JSONParameterEncoder 的程式碼。

URLParameter 解碼器類似,但在此,我們把引數編碼成 JSON,再次新增適當的 header。

NetworkRouter

建立一個名為 NetworkRouter 的檔案,並把它放到 Service 組裡。我們來定義一個 block 的 typealias。

public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()
複製程式碼

接下來我們定義一個名為 NetworkRouter 的協議。

[譯] Swift 寫網路層:用面向協議的方式

NetworkRouter 的程式碼。

一個 NetworkRouter 具有用於發出請求的 EndPoint,一旦發出請求,就會將響應傳遞給完成的 block。我已經新增了一個非常好的取消請求的功能,但不要深入探究它。這個功能可以在請求生命週期的任何時候呼叫,然後取消請求。如果您的應用程式有上傳或下載的功能,取消請求可能會是非常有用的。我們在這裡使用 associatedtype,因為我們希望我們的 Router 能夠處理任何 EndPointType。如果不使用 associatedtype,則 router 必須具有具體的 EndPointType。更多有關 associatedtypes 的內容,我建議可以看下 NatashaTheRobot 寫的這篇文章

Router

建立一個名為 Router 的檔案,並把它放到 Service 組裡。我們宣告一個型別為 URLSessionTask 的私有變數 task。這個 task 變數本質上是要完成所有的工作。我們讓變數宣告為私有,因為我們不希望在這個類之外還能修改這個 task 變數。

[譯] Swift 寫網路層:用面向協議的方式

Router 方法的程式碼。

Request

這裡我們使用 sharedSession 建立一個 URLSession。這是建立 URLSession 最簡單的方法。但請記住,這不是唯一的方法。更復雜的 URLSession 配置可用可以改變 session 行為的 configuration 來實現。要了解更多資訊,我建議花點時間閱讀下這篇文章

這裡我們通過呼叫 buildRequest 方法來建立請求,並傳入名為 route 的一個 EndPoint 型別引數。由於我們的解碼器可能會丟擲一個錯誤,這段呼叫用一個 do-try-catch 塊包起來。我們只是單純地把所有請求、資料和錯誤傳給 completion 回撥。

[譯] Swift 寫網路層:用面向協議的方式

Request 方法的程式碼.

建立 Request

Router 裡面建立一個名為 buildRequest 的私有方法,這個方法會在我們的網路層中負責至關重要的工作,從本質上把 EndPointType 轉化為 URLRequest。一旦我們的 EndPoint 發出了一個請求,我們就把他傳遞給 session。這裡做了很多工作,我們來逐一看看每個方法。讓我們分解 buildRequest 方法:

  1. 我們例項化一個 URLRequest 型別的變數請求。傳給它我們的 URL 前半段,並附加我們要使用的特定路徑。
  2. 我們將請求的 httpMethod 設定為和 EndPoint 相同的 httpMethod
  3. 我們建立了一個 do-try-catch 塊,因為我們的編碼器丟擲錯誤。通過建立一個大的 do-try-catch 塊,我們不必每次嘗試建立一個單獨的 do-try-catch。
  4. 開啟 route.task
  5. 根據 task 變數,呼叫適當的編碼器。

[譯] Swift 寫網路層:用面向協議的方式

buildRequest 方法的程式碼。

配置引數

建立一個名為 configureParameters 的方法,並把它放到 Router 裡面。

[譯] Swift 寫網路層:用面向協議的方式

configureParameters 方法的實現。

這個函式負責編碼我們的引數。由於我們的API期望所有 bodyParameters 是 JSON 格式的,以及 URLParameters 是 URL 編碼的,我們將相應的引數傳遞給其指定的編碼器即可。如果您正在處理具有不同編碼風格的 API,我會建議修改 HTTPTask 以獲取編碼器列舉。這個列舉應該有你需要的所有不同風格的編碼器。然後在 configureParameters 裡面新增編碼器列舉的附加引數。適當地呼叫列舉並編碼引數。

新增額外的 header

建立一個名為 addAdditionalHeaders 的方法,並把它放到 Router 裡面。

[譯] Swift 寫網路層:用面向協議的方式

addAdditionalHeaders 方法的實現。

只需將所有附加標題新增為請求標題的一部分即可

取消請求

cancel 方法的實現就像下面這樣:

[譯] Swift 寫網路層:用面向協議的方式

cancel 方法的實現。

實踐

現在讓我們把封裝好的網路層在實際樣例專案中進行實踐。我們將用 TheMovieDB? 獲取一些資料,並展示在我們的應用中。

MovieEndPoint

MovieEndPoint 與我們在 Getting Started with Moya(如果沒看過的話就看看)中的 Target 型別非常相近。Moya 中的 TargetType,在我們今天的例子中是 EndPointType。把這個檔案放到 EndPoint 分組當中。

import Foundation


enum NetworkEnvironment {
    case qa
    case production
    case staging
}

public enum MovieApi {
    case recommended(id:Int)
    case popular(page:Int)
    case newMovies(page:Int)
    case video(id:Int)
}

extension MovieApi: EndPointType {
    
    var environmentBaseURL : String {
        switch NetworkManager.environment {
        case .production: return "https://api.themoviedb.org/3/movie/"
        case .qa: return "https://qa.themoviedb.org/3/movie/"
        case .staging: return "https://staging.themoviedb.org/3/movie/"
        }
    }
    
    var baseURL: URL {
        guard let url = URL(string: environmentBaseURL) else { fatalError("baseURL could not be configured.")}
        return url
    }
    
    var path: String {
        switch self {
        case .recommended(let id):
            return "\(id)/recommendations"
        case .popular:
            return "popular"
        case .newMovies:
            return "now_playing"
        case .video(let id):
            return "\(id)/videos"
        }
    }
    
    var httpMethod: HTTPMethod {
        return .get
    }
    
    var task: HTTPTask {
        switch self {
        case .newMovies(let page):
            return .requestParameters(bodyParameters: nil,
                                      urlParameters: ["page":page,
                                                      "api_key":NetworkManager.MovieAPIKey])
        default:
            return .request
        }
    }
    
    var headers: HTTPHeaders? {
        return nil
    }
}
複製程式碼

EndPointType

MovieModel

我們的 MovieModel 也不會改變,因為 TheMovieDB 的響應是相同的 JSON 格式。我們利用 Decodable 協議將我們的 JSON 轉換為我們的模型。將此檔案放在 Model 組中。

import Foundation

struct MovieApiResponse {
    let page: Int
    let numberOfResults: Int
    let numberOfPages: Int
    let movies: [Movie]
}

extension MovieApiResponse: Decodable {
    
    private enum MovieApiResponseCodingKeys: String, CodingKey {
        case page
        case numberOfResults = "total_results"
        case numberOfPages = "total_pages"
        case movies = "results"
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: MovieApiResponseCodingKeys.self)
        
        page = try container.decode(Int.self, forKey: .page)
        numberOfResults = try container.decode(Int.self, forKey: .numberOfResults)
        numberOfPages = try container.decode(Int.self, forKey: .numberOfPages)
        movies = try container.decode([Movie].self, forKey: .movies)
        
    }
}


struct Movie {
    let id: Int
    let posterPath: String
    let backdrop: String
    let title: String
    let releaseDate: String
    let rating: Double
    let overview: String
}

extension Movie: Decodable {
    
    enum MovieCodingKeys: String, CodingKey {
        case id
        case posterPath = "poster_path"
        case backdrop = "backdrop_path"
        case title
        case releaseDate = "release_date"
        case rating = "vote_average"
        case overview
    }
    
    
    init(from decoder: Decoder) throws {
        let movieContainer = try decoder.container(keyedBy: MovieCodingKeys.self)
        
        id = try movieContainer.decode(Int.self, forKey: .id)
        posterPath = try movieContainer.decode(String.self, forKey: .posterPath)
        backdrop = try movieContainer.decode(String.self, forKey: .backdrop)
        title = try movieContainer.decode(String.self, forKey: .title)
        releaseDate = try movieContainer.decode(String.self, forKey: .releaseDate)
        rating = try movieContainer.decode(Double.self, forKey: .rating)
        overview = try movieContainer.decode(String.self, forKey: .overview)
    }
}
複製程式碼

Movie Model

NetworkManager

建立一個名為 NetworkManager 的檔案,並將它放在 Manager 分組中。現在我們的 NetworkManager 將有兩個靜態屬性:你的 API key 和 網路環境(參考 MovieEndPoint)。NetworkManager 也有一個 MovieApi 型別的 Router

[譯] Swift 寫網路層:用面向協議的方式

Network Manager 的程式碼。

Network Response

NetworkManager 裡建立一個名為 NetworkResponse 的列舉。

[譯] Swift 寫網路層:用面向協議的方式

Network Response 列舉。

我們將用這些列舉去處理 API 返回的結果,並顯示合適的資訊。

Result

NetworkManager 中建立一個名為 Result 的列舉。

[譯] Swift 寫網路層:用面向協議的方式

Result 列舉。

Result 這個列舉非常強大,可以用來做許多不同的事情。我們將使用 Result 來確定我們對 API 的呼叫是成功還是失敗。如果失敗,我們會返回一條錯誤訊息,並說明原因。想了解更多關於 Result 物件程式設計的資訊,你可以 觀看或閱讀本篇

處理 Network 響應

建立一個名為 handleNetworkResponse 的方法。這個方法有一個 HTTPResponse 型別的引數,並返回 Result 型別的值。

[譯] Swift 寫網路層:用面向協議的方式

這裡我們運用 HTTPResponse 狀態碼。狀態碼是一個告訴我們響應值狀態的 HTTP 協議。通常情況下,200 至 299 的狀態碼都表示成功。需要了解更多關於 statusCodes 的資訊可以閱讀 這篇文章.

呼叫

因此,現在我們為我們的網路層奠定了堅實的基礎。現在該去呼叫了!

我們將要從 API 拉取一個新電影的列表。建立一個名為 getNewMovies 的方法。

[譯] Swift 寫網路層:用面向協議的方式

getNewMovies 方法實現。

我們來分解這個方法的每一步:

  1. 我們用兩個引數定義 getNewMovies 方法:一個頁碼和一個成功回撥,它返回 Movie 可選值陣列或可選值錯誤訊息。
  2. 呼叫我們的 Router。傳入頁碼並在閉包內處理回撥。
  3. 如果沒有網路,或由於某種原因無法呼叫 API,URLSession 將返回錯誤。請注意,這不是 API 異常。這樣的異常是客戶端的原因,可能是網路連線有問題。
  4. 因為我們需要訪問 statusCode 屬性,所以我們需要將 response 傳遞給 HTTPURLResponse
  5. 我們宣告 result,這是我們從 handleNetworkResponse 方法得到的。然後我們檢查 switch-case 塊中的結果。
  6. success 意味著我們能夠成功地與 API 進行通訊並獲得適當的響應。然後我們檢查響應是否帶有資料。如果沒有資料,我們只需使用 return 語句退出該方法。
  7. 如果響應返回資料,我們需要將資料解碼到我們的模型。然後我們將解碼的 Movie 傳遞給回撥。
  8. failure 的情況下,我們只是將錯誤傳遞給回撥。

完成了!這是我們用純 Swift 寫的,沒有用到 Cocoapods 和第三方庫的網路層。為了測試獲得電影列表的 API,使用 Network Manager 建立一個 ViewController,然後在 mamager 上呼叫 getNewMovies 方法。

class MainViewController: UIViewController {
    
    var networkManager: NetworkManager!
    
    init(networkManager: NetworkManager) {
        super.init(nibName: nil, bundle: nil)
        self.networkManager = networkManager
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        networkManager.getNewMovies(page: 1) { movies, error in
            if let error = error {
                print(error)
            }
            if let movies = movies {
                print(movies)
            }
        }
    }
}
複製程式碼

MainViewControoler 的例子。

網路日誌

我最喜歡的 Moya 功能之一就是網路日誌。它通過記錄所有網路流量,來使除錯和檢視請求和響應更容易。當我決定實現這個網路層時,這是我非常想要的功能。建立一個名為 NetworkLogger 的檔案,並將其放入 Service 組中。我已經實現了將請求記錄到控制檯的程式碼。我不會顯示應該把這個程式碼放在我們的網路層的什麼位置。作為你的挑戰,請繼續建立一個將響應記錄到控制檯的方法,並在我們的專案結構中找到放置這些函式呼叫的合適位置。[放置 Gist 檔案]

提示static func log(response: URLResponse) {}

彩蛋

有沒有發現自己在 Xcode 中有一個你不太瞭解的佔位符?例如,讓我們看看我們為 Router 實現的程式碼。

[譯] Swift 寫網路層:用面向協議的方式

NetworkRouterCompletion 是需要使用者實現的。儘管我們已經實現了它,但有時很難準確地記住它是什麼型別以及我們應該如何使用它。這讓我們親愛的 Xcode 來拯救吧!只需雙擊佔位符,Xcode 就會完成剩下的工作。

[譯] Swift 寫網路層:用面向協議的方式

結論

現在我們有一個完全可以自定義的、易於使用的、面向協議的網路層。我們可以完全控制其功能並徹底理解其機制。通過這個練習,我可以真正地說我自己學到了一些新的東西。所以我對這部分工作感到自豪,而不是僅僅安裝了一個庫。希望這篇文章證明了在 Swift 中建立自己的網路層並不難。?就像這樣:

你可以到我的 GitHub 上找到原始碼,感謝你的閱讀!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章