- 原文地址:Writing a Network Layer in Swift: Protocol-Oriented Approach
- 原文作者:Malcolm Kumwenda
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:talisk
- 校對者:ALVINYEH,rydensun
在本指南中,我們將介紹如何在沒有任何第三方庫的情況下以純 Swift 實現網路層。讓我們快開始吧!閱讀了本指南後,我們的程式碼應該是:
- 面向協議
- 易於使用
- 易於實現
- 型別安全
- 使用列舉來配置 endPoints
以下是我們最終通過網路層實現的一個例子:
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/dbd42da2168793260666c0a4d5a7dc9473c61031d0e64b7a057eb895a4024513.png)
該專案的最終目標。
藉助列舉輸入 router.request(.,我們可以看到所有可用的端點以及該請求所需的引數。
首先,一些關於結構的東西
在建立任何東西時,結構總是非常重要的,好的結構便於以後找到所需。我堅信資料夾結構是軟體架構的一個關鍵貢獻者。為了讓我們的檔案保持良好的組織性,我們事先就建立好所有組,然後記下每個檔案應該放在哪裡。這是一個對專案結構的概述。(請注意以下名稱都只是建議,你可以根據自己的喜好命名你的類和分組。)
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/196a18c3f8ef2a29c007eda58025c3c82420b5713f6c11447d4b3c3c7508c095.png)
專案目錄結構。
EndPointType 協議
我們需要的第一件事是定義我們的 EndPointType 協議。該協議將包含配置 EndPoint 的所有資訊。什麼是 EndPoint?本質上它是一個 URLRequest,它包含所有包含的元件,如標題,query 引數和 body 引數。EndPointType 協議是我們網路層實現的基石。接下來,建立一個檔案並將其命名為 EndPointType。將此檔案放在 Service 組中。(請注意不是 EndPoint 組,這會隨著我們的繼續變得更清晰)。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/b610927d49029951d8d88bb3a7cfe8f8afb057db95421cac5675d21dea8e3f4d.png)
EndPointType 協議。
HTTP 協議
我們的 EndPointType 具有構建整個 endPoint 所需的大量HTTP協議。讓我們來探索這些協議的含義。
HTTPMethod
建立一個名為 HTTPMethod 的檔案,並把它放到 Service 組裡。這個列舉將被用於為我們的請求設定 HTTP 方法。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/36353b24cc7d803ad2c2b2e2afbbcf286f98db94d470ff970e763397182852ab.png)
HTTPMethod 列舉。
HTTPTask
建立一個名為 HTTPTask 的檔案,並把它放到 Service 組裡。HTTPTask 負責為特定的 endPoint 配置引數。你可以新增儘可能多的適用於你的網路層要求的情況。 我將要發一個請求,所以我只有三種情況。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/fd99b62622a0b7a03dca707faf32defbcebbce841a9bfbb501556715c96ff9d6.png)
HTTPTask 列舉。
我們將在下一節討論引數以及引數的編解碼。
HTTPHeaders
HTTPHeaders 僅僅是字典的 typealias(別名)。你可以在 HTTPTask 檔案的開頭寫下這個 typealias。
public typealias HTTPHeaders = [String:String]
複製程式碼
引數及其編解碼
建立一個名為 ParameterEncoding 的檔案,並把它放到 Encoding 組裡。然後首要之事便是定義 Parameters 的 typealias。我們利用 typealias 使我們的程式碼更簡潔、清晰。
public typealias Parameters = [String:Any]
複製程式碼
接下來,用一個靜態函式 encode 定義一個協議 ParameterEncoder。encode 方法包含 inout URLRequest 和 Parameters 這兩個引數。inout 是一個 Swift 的關鍵字,它將引數定義為引用引數。通常來說,變數以值型別傳遞給函式。通過在引數前面新增 inout,我們將其定義為引用型別。要了解更多關於 inout 引數的資訊,你可以參考這裡。ParameterEncoder協議將由我們的 JSONParameterEncoder 和 URLPameterEncoder 實現。
public protocol ParameterEncoder {
static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
}
複製程式碼
ParameterEncoder 執行一個函式來編碼引數。此方法可能失敗而丟擲錯誤,需要我們處理。
可以證明丟擲自定義錯誤而不是標準錯誤是很有價值的。我總是發現自己很難破譯 Xcode 給出的一些錯誤。通過自定義錯誤,您可以定義自己的錯誤訊息,並確切知道錯誤來自何處。為此,我只需建立一個從 Error 繼承的列舉。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/a9fbe7ec4ab6609c232161770f9e45b86ec2ccd33dd584bc2fd535078f784425.png)
NetworkError 列舉。
URLParameterEncoder
建立一個名為 URLParameterEncoder 的檔案,並把它放到 Encoding 組裡。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/138c83834bdfc21614b805de3a1fd53ee8a92cbb6d798554c61ec17af6064dc6.png)
URLParameterEncoder 的程式碼。
上面的程式碼傳遞了引數,並將引數安全地作為 URL 型別的引數傳遞。正如你應該知道,有一些字元在 URL 中是被禁止的。引數需要用「&」符號分開,所以我們應該注意遵循這些規範。如果沒有設定 header,我們也要為請求新增適合的 header。
這個程式碼示例是我們應該考慮使用單元測試進行測試的。正確構建 URL 是至關重要的,不然我們可能會遇到許多不必要的錯誤。如果你使用的是開放 API,你肯定不希望配額被大量失敗的測試耗盡。如果你想了解更多有關單元測試方面的知識,可以閱讀 S.T.Huang 寫的這篇文章。
JSONParameterEncoder
建立一個名為 JSONParameterEncoder 的檔案,並把它放到 Encoding 組裡。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/6d804b3b2bf03d27137af7796bdb4b98c3ac25855758cd6355f6920c402e746c.png)
JSONParameterEncoder 的程式碼。
與 URLParameter 解碼器類似,但在此,我們把引數編碼成 JSON,再次新增適當的 header。
NetworkRouter
建立一個名為 NetworkRouter 的檔案,並把它放到 Service 組裡。我們來定義一個 block 的 typealias。
public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()
複製程式碼
接下來我們定義一個名為 NetworkRouter 的協議。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/b5a9b2b8f8622acc99bfa146e91cf09e1372fafde4c9a49db5df24c1b3282128.png)
NetworkRouter 的程式碼。
一個 NetworkRouter 具有用於發出請求的 EndPoint,一旦發出請求,就會將響應傳遞給完成的 block。我已經新增了一個非常好的取消請求的功能,但不要深入探究它。這個功能可以在請求生命週期的任何時候呼叫,然後取消請求。如果您的應用程式有上傳或下載的功能,取消請求可能會是非常有用的。我們在這裡使用 associatedtype,因為我們希望我們的 Router 能夠處理任何 EndPointType。如果不使用 associatedtype,則 router 必須具有具體的 EndPointType。更多有關 associatedtypes 的內容,我建議可以看下 NatashaTheRobot 寫的這篇文章。
Router
建立一個名為 Router 的檔案,並把它放到 Service 組裡。我們宣告一個型別為 URLSessionTask 的私有變數 task。這個 task 變數本質上是要完成所有的工作。我們讓變數宣告為私有,因為我們不希望在這個類之外還能修改這個 task 變數。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/1b3537acd2d97128f8a6cb350010f3e28a5c491450d2181526ec2d3c8b1b3b75.png)
Router 方法的程式碼。
Request
這裡我們使用 sharedSession 建立一個 URLSession。這是建立 URLSession 最簡單的方法。但請記住,這不是唯一的方法。更復雜的 URLSession 配置可用可以改變 session 行為的 configuration 來實現。要了解更多資訊,我建議花點時間閱讀下這篇文章。
這裡我們通過呼叫 buildRequest 方法來建立請求,並傳入名為 route 的一個 EndPoint 型別引數。由於我們的解碼器可能會丟擲一個錯誤,這段呼叫用一個 do-try-catch 塊包起來。我們只是單純地把所有請求、資料和錯誤傳給 completion 回撥。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/a048eb6d07a17b7ea589a9f1de06944ef71674e5f036d6591edfee8c2901ccaf.png)
Request 方法的程式碼.
建立 Request
在 Router 裡面建立一個名為 buildRequest 的私有方法,這個方法會在我們的網路層中負責至關重要的工作,從本質上把 EndPointType 轉化為 URLRequest。一旦我們的 EndPoint 發出了一個請求,我們就把他傳遞給 session。這裡做了很多工作,我們來逐一看看每個方法。讓我們分解 buildRequest 方法:
- 我們例項化一個 URLRequest 型別的變數請求。傳給它我們的 URL 前半段,並附加我們要使用的特定路徑。
- 我們將請求的 httpMethod 設定為和 EndPoint 相同的 httpMethod。
- 我們建立了一個 do-try-catch 塊,因為我們的編碼器丟擲錯誤。通過建立一個大的 do-try-catch 塊,我們不必每次嘗試建立一個單獨的 do-try-catch。
- 開啟 route.task。
- 根據 task 變數,呼叫適當的編碼器。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/09323b14670a57c22e4b9a1910f23c850331c0e31e068e5748f9c62259874624.png)
buildRequest 方法的程式碼。
配置引數
建立一個名為 configureParameters 的方法,並把它放到 Router 裡面。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/0c6a938b38f3c17676574b58ffac787201ce322b851a3bbb67d45cbfc5da430d.png)
configureParameters 方法的實現。
這個函式負責編碼我們的引數。由於我們的API期望所有 bodyParameters 是 JSON 格式的,以及 URLParameters 是 URL 編碼的,我們將相應的引數傳遞給其指定的編碼器即可。如果您正在處理具有不同編碼風格的 API,我會建議修改 HTTPTask 以獲取編碼器列舉。這個列舉應該有你需要的所有不同風格的編碼器。然後在 configureParameters 裡面新增編碼器列舉的附加引數。適當地呼叫列舉並編碼引數。
新增額外的 header
建立一個名為 addAdditionalHeaders 的方法,並把它放到 Router 裡面。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/749251aa2161147e210d819a99912d9b4d83abd12c7603280d0bf284a650b537.png)
addAdditionalHeaders 方法的實現。
只需將所有附加標題新增為請求標題的一部分即可
取消請求
cancel 方法的實現就像下面這樣:
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/8ee1168f61a3929801dba86c190d2ef0f11695d9333dbfef733c78d0d5638b9f.png)
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 寫網路層:用面向協議的方式](https://i.iter01.com/images/817c3a427e4e214bb6af7abb491484f7872357afcc657ee289698b022f40bec2.png)
Network Manager 的程式碼。
Network Response
在 NetworkManager 裡建立一個名為 NetworkResponse 的列舉。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/173585f54599852f029b58feb986d73bf981fc059019c668ea5106670407eaa3.png)
Network Response 列舉。
我們將用這些列舉去處理 API 返回的結果,並顯示合適的資訊。
Result
在 NetworkManager 中建立一個名為 Result 的列舉。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/60d578313291ffabfecc53988a157054e213d16d676e4dca59ab5ef6c4c02313.png)
Result 列舉。
Result 這個列舉非常強大,可以用來做許多不同的事情。我們將使用 Result 來確定我們對 API 的呼叫是成功還是失敗。如果失敗,我們會返回一條錯誤訊息,並說明原因。想了解更多關於 Result 物件程式設計的資訊,你可以 觀看或閱讀本篇。
處理 Network 響應
建立一個名為 handleNetworkResponse 的方法。這個方法有一個 HTTPResponse 型別的引數,並返回 Result 型別的值。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/43b4b4030cd04bfe41a5544979616d90400bea2bc3d80e631ad3364b9d044e5c.png)
這裡我們運用 HTTPResponse 狀態碼。狀態碼是一個告訴我們響應值狀態的 HTTP 協議。通常情況下,200 至 299 的狀態碼都表示成功。需要了解更多關於 statusCodes 的資訊可以閱讀 這篇文章.
呼叫
因此,現在我們為我們的網路層奠定了堅實的基礎。現在該去呼叫了!
我們將要從 API 拉取一個新電影的列表。建立一個名為 getNewMovies 的方法。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/5e5a1b16684fbe8f507b231194c09b253b9b9debf3e82d1e86834ce77e6941a1.png)
getNewMovies 方法實現。
我們來分解這個方法的每一步:
- 我們用兩個引數定義 getNewMovies 方法:一個頁碼和一個成功回撥,它返回 Movie 可選值陣列或可選值錯誤訊息。
- 呼叫我們的 Router。傳入頁碼並在閉包內處理回撥。
- 如果沒有網路,或由於某種原因無法呼叫 API,URLSession 將返回錯誤。請注意,這不是 API 異常。這樣的異常是客戶端的原因,可能是網路連線有問題。
- 因為我們需要訪問 statusCode 屬性,所以我們需要將 response 傳遞給 HTTPURLResponse。
- 我們宣告 result,這是我們從 handleNetworkResponse 方法得到的。然後我們檢查 switch-case 塊中的結果。
- success 意味著我們能夠成功地與 API 進行通訊並獲得適當的響應。然後我們檢查響應是否帶有資料。如果沒有資料,我們只需使用 return 語句退出該方法。
- 如果響應返回資料,我們需要將資料解碼到我們的模型。然後我們將解碼的 Movie 傳遞給回撥。
- 在 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 寫網路層:用面向協議的方式](https://i.iter01.com/images/f9345d3112587848de3a4d913254c1d2ccc8589d70fe2cb64630f98104be8528.png)
NetworkRouterCompletion 是需要使用者實現的。儘管我們已經實現了它,但有時很難準確地記住它是什麼型別以及我們應該如何使用它。這讓我們親愛的 Xcode 來拯救吧!只需雙擊佔位符,Xcode 就會完成剩下的工作。
![[譯] Swift 寫網路層:用面向協議的方式](https://i.iter01.com/images/777a045354e4970fa3cde0d3dd3ac81d023b0c839b4eebe546852d58677d8345.png)
結論
現在我們有一個完全可以自定義的、易於使用的、面向協議的網路層。我們可以完全控制其功能並徹底理解其機制。通過這個練習,我可以真正地說我自己學到了一些新的東西。所以我對這部分工作感到自豪,而不是僅僅安裝了一個庫。希望這篇文章證明了在 Swift 中建立自己的網路層並不難。?就像這樣:
你可以到我的 GitHub 上找到原始碼,感謝你的閱讀!
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。