用Swift編寫網(wǎng)絡層:面向協(xié)議方式
在這篇文章中我們會看到怎樣實現(xiàn)用純swift編寫網(wǎng)絡層,而不依靠任何第三方庫。讓我們快去看看吧。相信看完之后我們的代碼能夠做到:
- 面向協(xié)議
- 易用
- 容易實現(xiàn)
- 類型安全
- 用枚舉(enums)來配置終端(endPoints)
下面是一個最終我們網(wǎng)絡層的示例

這個項目的最終目標
通過輸入router.request(. 借助枚舉的力量,我們可以看到所有有效的終端和我們請求的參數(shù))
首先,一些結構
創(chuàng)建任何東西之前,有個結構都是很重要的,這樣后面我們就容易找到需要的東西。我堅定相信文件夾結構對軟件架構至關重要。為了讓我們的文件組織有序,讓我們提前建立好所有的組,我會標記好每一個文件該放的位置。這是一個項目結構總覽。(請注意這里的名字僅僅是建議,你可以按你喜好給你的類和組命名)

項目文件夾結構
終端類型(EndPointType)協(xié)議
我們要做的***件事情就是定義我們的終端類型協(xié)議。這個協(xié)議要包含用于配置終端的所有信息。什么是終端?本質(zhì)上來講它是一個包含各種組件比如頭文件(headers),查詢參數(shù)(query parameters),體參數(shù)(body parameters)的URL請求(URLRequest)。終端類型協(xié)議是我們網(wǎng)絡層實現(xiàn)的基石。我們建一個文件,并命名EndPointType,把它放到服務組中(不是終端組,后面我們分清楚的)。

終端類型協(xié)議
HTTP協(xié)議
為了創(chuàng)建一個完整的終端,我的終端類型協(xié)議里有很多HTTP協(xié)議。讓我們看看這些協(xié)議需要什么。
HTTP方法
創(chuàng)建一個名為HTTPMethod的文件并把它放在服務組中。這個枚舉會用于設置我們請求用的HTTP方法。

HTTPMethod枚舉
HTTP任務
創(chuàng)建一個名為HTTPTask的文件并把它放在服務組中。HTTPTask用于為一個特定的終端配置參數(shù),你可以添加適當數(shù)量的案例(cases)到你的網(wǎng)絡層請求中。我會按下圖建立我的請求,它只包含3個案例

HTTPTask枚舉
在下一章我們會討論參數(shù)和如何處理參數(shù)的編碼。
HTTP頭文件
HTTPHeaders是一個字典的別名(typealias)。你可以在你HTTPTask文件的開頭創(chuàng)建它。
- public typealias HTTPHeaders = [String:String]
參數(shù)與編碼
創(chuàng)建一個名為ParameterEncoding的文件并把它放在編碼組中。我們首先要定義一個參數(shù)的別名,通過它我們可以讓代碼更干凈簡潔。
- public typealias Parameters = [String:Any]
之后用一個靜態(tài)函數(shù)編碼定義一個協(xié)議參數(shù)編碼器(ParameterEncoder)。這種編碼方式含有2個參數(shù),一個inout URLRequest和Parameters。(為了防止混淆,后面我會把函數(shù)參數(shù)稱為參量)。INOUT是一個swift關鍵詞,用于把一個參量定義為引用參量。通常變量作為值類型傳送給函數(shù)。通過在參量的開頭加上inout,我們把它定義為引用類型。要學更多關于雙向參量,你可以點擊這里。參數(shù)編碼器協(xié)議會通過JSONParameterEncoder和URLPameterEncoder實現(xiàn)。
- public protocol ParameterEncoder {
- static func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws
- }
參數(shù)編碼器執(zhí)行編碼參數(shù)的函數(shù),這個方法會失敗,返回一個錯誤,因而我們需要處理它。
能夠返回一個自定的錯誤提示比標準錯誤提示會更有價值。我總是花很多時間去分析Xcode給的一些錯誤提示。有了自定的錯誤提示你就可以定義屬于自己的錯誤信息,就能清楚知道錯誤到底來自哪里。為了做到這些,我創(chuàng)建了一個繼承自Error的枚舉。

NetworkError枚舉
URL參數(shù)編碼器
創(chuàng)建一個名為URLParameterEncoder的文件并把它放在編碼組中。

URL參數(shù)編碼器代碼
上面的代碼含有一些參數(shù),它可以將他們變成URL參數(shù)來安全傳遞。你要知道一些字符在URL中一些字符是禁用的。參數(shù)也被‘&’標記分開,我們需要考慮到所有這些。如果之前沒有設置,我們還要為請求添加合適的頭文件。
這個示例代碼是使用單元測試時應該考慮到的。如果URL沒有正確建立,我們就會有很多不必要的錯誤。如果你在使用一個開放API,你一定不希望自己的請求配額被一堆錯誤測試用完。如果你想學更多關于單元測試內(nèi)容,你可以看S.T.Huang的這篇文章。
JSON參數(shù)編碼器
創(chuàng)建一個名為JSONParameterEncoder的文件,也把它放在編碼組中。

JSON參數(shù)編碼器代碼
類似URL參數(shù)編碼器,不過這里是為JSON編碼參數(shù),同樣要添加合適的頭文件。
網(wǎng)絡路由器
創(chuàng)建一個名為NetworkRouter的文件并把它放在服務組中。我們從為一個完成部分(completion)定義別名開始。
- public typealias NetworkRouterCompletion = (_ data: Data?,_ response: URLResponse?,_ error: Error?)->()
之后我們定義一個協(xié)議網(wǎng)絡路由器

NetworkRouter代碼
一個網(wǎng)絡路由器有一個用于產(chǎn)生請求的終端,一旦請求產(chǎn)生,它會傳遞對完成部分的應答。我加入了一個取消函數(shù),有它當然好,但不是一定要用到。這個函數(shù)可以在一個請求存在周期的任意時刻調(diào)用并取消它。如果你的應用有上傳或下載任務,這會很有用。為了讓我們的路由器能處理任何終端類型,我們這里使用了關聯(lián)類型。如果不用關聯(lián)類型,路由器就不得不有一個具體的終端類型。想對關聯(lián)類型了解更多,建議看NatashaTheRobot的這篇文章。
路由器
創(chuàng)建一個名為Router的文件并把它放在服務組中。我們聲明一個URLSessionTask類型的私有變量任務。這個任務本質(zhì)上是整個工作要做的。我們讓這個變量私有化,因為我們不想任何這個類之外的任何東西會調(diào)整我們的任務。

Router方法存根
請求
這里我們使用共享的會話管理(session)創(chuàng)建URLSession,這是創(chuàng)建URLSession最簡單的辦法,但請記住這不是***的方法。要實現(xiàn)對URLSession更復雜的配置,則要用能夠改變會話管理表現(xiàn)的配置。想了解更多,我推薦讀一讀這篇文章。
這里我們通過調(diào)用buildRequest生成我們的請求,并給它一個終端作為路徑。這個buildRequest的調(diào)用被限制在一個do-try-catch區(qū)塊,因為我們的編碼器可能會報出錯誤。我們僅僅把所有應答,數(shù)據(jù)和錯誤傳送給完成部分。

Request方法代碼
建立請求
在Router中創(chuàng)建一個名為buildRequest的私有函數(shù),這個函數(shù)負責我們網(wǎng)絡層中一切重要工作。本質(zhì)上就是把EndPointType轉化為URLRequest。一旦我們的終端生成請求,我們可以把它傳遞給會話管理。這里有很多工作要做,所以我們將會分別看看每個方法。讓我們分解buildRequest方法:
我們舉了一個URLRequest類型的變量請求的例子。把我們的基礎URL給它,并附上我們要用到的路徑。
我們設定這請求的httpMethod和我們終端的一致。
考慮到我們的編碼器會報告錯誤,我們創(chuàng)建一個do-try-catch區(qū)塊。只要創(chuàng)建一個大的do-try-catch區(qū)塊,我們就不需要為每次嘗試分別建一個。
開啟route.task
根據(jù)任務,調(diào)用合適的編碼器。

buildRequest方法代碼.
配置參數(shù)
在Router中創(chuàng)建一個名為configureParameters的函數(shù)

configureParameters方法的實現(xiàn)
這個函數(shù)負責為我們的參數(shù)編碼。因為我們的API要求所有的bodyParameters都是JSON,并且URLParameters是URL編碼的,我們把合適的參數(shù)傳遞給設計好的編碼器。如果你正在用一個有多種編碼方式的API,我建議修改HTTPTask來使用編碼器枚舉。這個枚舉需要包含所有你需要的不同類型編碼器。之后在configureParameters添加一個關于你編碼枚舉的附加參量。開啟這個枚舉,合適地為參數(shù)編碼。
添加附加頭文件
在Router中創(chuàng)建一個名為addAdditionalHeaders的函數(shù)

addAdditionalHeaders方法的實現(xiàn)
添加所有附加頭文件,讓它們成為請求頭文件的一部分。
取消
取消函數(shù)的實現(xiàn)是這樣的:

cancel方法的實現(xiàn)
實踐
現(xiàn)在讓我們用一個實際例子看看我們建立的網(wǎng)絡層。我們將從TheMovieDB獲取一些電影數(shù)據(jù)到我們的應用。
電影終端(MovieEndPoint)
電影終端與我們在Getting Started with Moya中提到的目標類型很相似。與實現(xiàn)Moya中目標類型不同的是這里我們實現(xiàn)我們自己的終端類型。把這個文件放在終端組中。
- 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
- }
- }
終端類型
電影模式(MovieModel)
因為對TheMovieDB的回應同樣是JSON,我們的電影模式也不會改變。我們用可解碼協(xié)議來把JSON轉化為我們的模式。把這個文件放在模式組中。
- 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)
- }
- }
電影模式
網(wǎng)絡管理員
創(chuàng)建一個名為NetworkManager的文件并把它放在管理員組中。
現(xiàn)在開始我們的網(wǎng)絡管理員將僅有2個靜態(tài)屬性:你的API密碼和網(wǎng)絡環(huán)境(引用MovieEndPoint)。網(wǎng)絡管理員也有一個類型為MovieApi的Router。

NetworkManager代碼
網(wǎng)絡響應
在NetworkManager中創(chuàng)建一個名為NetworkResponse的枚舉。

NetworkResponse枚舉
我們將用這個枚舉處理來自API的響應,并顯示相應的信息。
結果
在NetworkManager中創(chuàng)建一個枚舉Result。

Result枚舉
一個結果枚舉可以用在很多不同事情上,非常有用。我們根據(jù)結果確定我們對API的調(diào)用是成功還是失敗。如果失敗了,我們會返回一個錯誤信息并說明原因。想了解更多面向結果的編程,你可以看這篇對話。
處理網(wǎng)絡響應
創(chuàng)建一個名為handleNetworkResponse的函數(shù),這個函數(shù)有一個參量,即HTTPResponse,并返回一個Result.

這里我們開啟HTTPResponse的狀態(tài)碼,狀態(tài)碼是一個能告訴我們響應狀態(tài)的HTTP協(xié)議?;旧?00-299之間都是成功。
產(chǎn)生調(diào)用
現(xiàn)在我們已經(jīng)為我們的網(wǎng)絡層打下雄厚的基礎。是時候開始調(diào)用了。
我們將會從API獲取一個新電影列表。創(chuàng)建一個名為getNewMovies的函數(shù)。

getNewMovies方法的實現(xiàn)
讓我們分解這個方法的每一步
- 我們定義getNewMovies方法含有2個參量:一個頁碼和一個能返回電影數(shù)組或錯誤信息的完成部分(completion)。
- 我們調(diào)用我們的路由器,輸入頁碼并在一個閉包(closure)內(nèi)處理這個完成部分。
- 如果沒有網(wǎng)絡或者出于一些原因無法調(diào)用API,URLSession會返回錯誤。請注意這并不是API的失敗。這種失敗多是客服端的,很可能是因為網(wǎng)絡連接不好。
- 我們需要把我們的響應轉變?yōu)橐粋€HTTPURLResponse,因為我們需要訪問狀態(tài)碼屬性。
- 我們聲明一個從handleNetworkResponse方法得到的結果,之后在switch-case區(qū)塊檢查這個結果。
- 成功意味著我們成功地和API聯(lián)系,并得到一個適當?shù)捻憫?。之后我們檢查這個響應是否攜帶數(shù)據(jù)。如果沒有數(shù)據(jù)我們就用返回語句退出這個方法。
- 如果攜帶有數(shù)據(jù),我們需要把數(shù)據(jù)編碼成我們的模式,之后我們把編碼好的電影傳遞給完成部分。
- 如果結果是失敗,我們就把錯誤傳遞給完成部分。
這就完成了,這就是我們不依賴Cocoapods和第三方庫的純Swift網(wǎng)絡層。想要測試api請求能否獲取電影,就創(chuàng)建一個帶有Network Manager 的viewController之后在管理員調(diào)用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的示例
迂回網(wǎng)絡(DETOUR- NETWORK)記錄器
我最喜歡的Moya特性之一就是網(wǎng)絡記錄器。它使得調(diào)試變得更容易,并且通過記錄所有網(wǎng)絡通信可以看到關于請求和響應發(fā)生了什么。我決定實現(xiàn)這個網(wǎng)絡層時候就想要有這個特性了。創(chuàng)建一個名為NetworkLogger的文件并把它放在服務組中。我已經(jīng)實現(xiàn)了一個記錄對控制臺請求的代碼。我不會展示我們應該把代碼放到代碼層中的哪里。這是對你的一個挑戰(zhàn),創(chuàng)建一個記錄控制臺響應的函數(shù),并在我們的架構中找到合適的位置放置它們。
提示:靜態(tài)函數(shù)記錄(響應:URLResponse)
小技巧
你在Xcode中遇到過不理解的占位符嗎?比如讓我們看看剛剛為了實現(xiàn)Router寫的代碼

NetworkRouterCompletion是我們實現(xiàn)的。即使我們實現(xiàn)了它,有時候也很難記清它是哪種類型,我們該怎么用它。我們喜歡的Xcode有解決辦法。只要在占位符上雙擊,Xcode就會告訴你。

結論
我們有了一個簡單好用,面向協(xié)議,還可以自己定制的網(wǎng)絡層。我們能完全控制它的功能,完全理解它的機制。通過進行這個練習,我可以說我本人學到不少新事情。所以比起那些只需要裝一個庫就能完成的工作,我對這項工作更感到自豪。希望這篇文章能說明,用Swift創(chuàng)建你自己的網(wǎng)絡層并沒那么難。只要不做這樣的事情就行了:
你可以在我的GitHub上找到源代碼,感謝閱讀。