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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NetworkRouterCompletion是我們實(shí)現(xiàn)的。即使我們實(shí)現(xiàn)了它,有時(shí)候也很難記清它是哪種類型,我們?cè)撛趺从盟?。我們喜歡的Xcode有解決辦法。只要在占位符上雙擊,Xcode就會(huì)告訴你。

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