Moya
是一個網路抽象層,預設是基於Alamofire
的。網上已經有一些不錯的原理分析及原始碼分析的文章,大家可以參考。在這我們從資料流的角度來粗略的描述一下Moya
的基本實現。我們在此避開各種錯誤導致的分支流程,只講一個正常請求的流程。
相信大家都封裝過網路層。
雖然系統提供的網路庫以及一些著名的第三方網路庫(AFNetworking
, Alamofire
)已經能滿足各種 HTTP/HTTPS
的網路請求,但直接在程式碼裡用起來,終歸是比較晦澀,不是那麼的順手。所以我們都會傾向於根據自己的實際需求,再封裝一個更好用的網路層,加入一些特殊處理。同時也讓業務程式碼更好地與底層的網路框架隔離和解耦。
Moya
實際上做的就是這樣一件事,它在 Alamofire
的基礎上又封裝了一層,讓我們不必處理過多的底層細節。按照官方文件的說法:
It's less of a framework of code and more of a framework of how to think about network requests.
對於應用層開發者來說,一個 HTTP/HTTPS
的網路請求流程很簡單,即客戶端發起請求,服務端接收到請求處理後再將響應資料回傳給客戶端。對於客戶端來說,大體只需要做兩件事:構建請求併傳送、接收響應並處理。如下一個簡單的流程:
我們這裡從普通資料請求的整個流程來看看 Moya
的基本實現。
操控者 MoyaProvider
在梳理流程之前,有必要了解一下 MoyaProvider
。我把這個 MoyaProvider
稱為 Moya
的操控者。在 Moya
層,它是整個資料流的管理者,包括構建請求、發起請求、接收響應、處理響應。也許類似的,我們自己封裝的網路庫也會有這樣一個角色,如 NetworkManager
。我們來看看它和 Moya
中其它類/結構體的關係。
與我們直接打交道最多的也是這個類,不過我們不在這細講,在這裡它不是主角。我們來結合資料流,來看看資料在這個類中怎麼流轉。
構建 Request
一個基本的 HTTP/HTTPS
普通資料請求通常包含以下幾個要素:
- URL
- 請求引數
- 請求方法
- 請求報頭資訊
- 可選的認證資訊
對於 Alamofire
來說,最終是構建一個 Request
,然後使用不同的請求物件,依賴於這些資訊來發起請求。所以,構建請求的終點是 Request
。
不過官方文件給了一個構建 Request
的流程圖:
我們來看看這個流程。
請求的起點 Target
Target
是構建一個請求的起點,它包含一個請求所需要的基本資訊。不過一個 Target
不是定義單一一個請求,而是定義一組相關的請求。這裡先了解一下 TargetType
協議:
public protocol TargetType {
var baseURL: URL { get }
var path: String { get }
var method: Moya.Method { get }
/// Provides stub data for use in testing.
var sampleData: Data { get }
var task: Task { get }
var validationType: ValidationType { get }
var headers: [String: String]? { get }
}
複製程式碼
為了控制篇幅,我把不需要的註釋都刪了,下同。
sampleData
主要是用於本地mock
資料,在文章中不做描述。
可以看到這個協議包含了一個請求所需要的基本資訊:用於拼接 URL
的 baseURL
和 path
、請求方法、請求報頭等。我們自定義的 Target
必須實現這個介面,並根據需要設定請求資訊,這個應該很好理解。
如果只是描述一個請求的話,可能使用 struct
會好一些;而如果是一組的話,那還是用列舉方便些(話說列舉用得好不好,直接體現了 Swift
水平好不好)。來看看官方的例子:
public enum GitHub {
case zen
case userProfile(String)
case userRepositories(String)
}
extension GitHub: TargetType {
public var baseURL: URL { return URL(string: "https://api.github.com")! }
......
}
複製程式碼
這基本是標配。列舉的關聯物件是請求所需要的引數,如果請求引數過多,最好放在一個字典裡面。
至於 task
屬性,其型別 Task
是一個列舉,定義了請求的實際任務型別,比如說是普通的資料請求,還是上傳下載。這個屬性可以關注一下,因為請求的引數都是附在這個屬性上。
在擴充套件 TargetType
時,可以根據不同的介面來配置不同的 baseURL
、path
、method
等資訊。不過可能會導致一個問題:在一個大的獨立工程裡面,通常介面有幾十上百個。如果你把所有的介面都放一個列舉裡面,你可能最後會發現,各種 switch
會把這個檔案撐得很長。所以,還需要根據實際情況來看看如何去劃分我們的介面,讓程式碼分散在不同的檔案裡面(MultiTarget
專門用來幹這事,可以研究一下)。
到這一步,我們得到的資料是一個
Target
列舉,它包含了構建一組請求所需要的資訊。實際上,我們主要的任務就是去定義這些列舉,後面的構建過程,如果沒有特殊需求,基本上就是個黑盒了。
有了 Target
,我們就可以用具體的列舉值來發起請求了,
gitHubProvider.request(.userRepositories(username)) { result in
......
}
複製程式碼
大多數時候,業務層程式碼需要做的就是這些了。是不是很簡單?
下面我們來看看 Moya
的黑盒子裡面做了些什麼?
Endpoint
按理說,我們構建好 Target
並把對應的資訊丟給 MoyaProvider
後,MoyaProvider
直接去構建一個 Request
,然後發起請求就行了。而在從上面的圖可以看到,Target
和 Request
之間還有一個 Endpoint
。這是啥玩意呢?我們來看看。
在 MoyaProvider
的 request
方法中呼叫了 requestNormal
方法。這個方法的第一行就做了個轉換操作,將 Target
轉換成 Endpoint
物件:
func requestNormal(_ target: Target, callbackQueue: DispatchQueue?, progress: Moya.ProgressBlock?, completion: @escaping Moya.Completion) -> Cancellable {
let endpoint = self.endpoint(target)
......
}
複製程式碼
endpoint()
方法實際上呼叫的是 MoyaProvider
的 endpointClosure
屬性:
public typealias EndpointClosure = (Target) -> Endpoint
open let endpointClosure: EndpointClosure
open func endpoint(_ token: Target) -> Endpoint {
return endpointClosure(token)
}
複製程式碼
EndpointClosure
的用途實際上就是將 Target
對映為 Endpoint
。我們可以自定義轉換方法,並在初始 MoyaProvider
時傳遞給 endpointClosure
引數,像這樣:
let endpointClosure = { (target: MyTarget) -> Endpoint in
let defaultEndpoint = MoyaProvider.defaultEndpointMapping(for: target)
return defaultEndpoint.adding(newHTTPHeaderFields: ["APP_NAME": "MY_AWESOME_APP"])
}
let provider = MoyaProvider<GitHub>(endpointClosure: endpointClosure)
複製程式碼
如果不想自定義,那麼就用 Moya
提供的預設轉換方法就行。
哦,還沒看 Endpoint
到底長啥樣:
open class Endpoint {
public typealias SampleResponseClosure = () -> EndpointSampleResponse
open let url: String
open let method: Moya.Method
open let task: Task
open let httpHeaderFields: [String: String]?
......
}
複製程式碼
是不是覺得和 TargetType
差不多?那問題來了,為什麼要 Endpoint
呢?
我有兩個觀點:
- 比起
Target
來,Endpoint
更像一個請求物件;Target
是通過列舉來描述的一組請求,而Endpoint
就是一個實實在在的請求物件;(廢話) - 通過
Endpoint
來隔離業務程式碼與Request
,畢竟這是Moya
的目標
如果有不同觀點,還請告訴我。
重複上面一句話:我們可以自定義轉換方法,來執行 Target
到 Endpoint
的對映操作。不過還有個問題,有些程式碼(比如headers的設定)即可以放在 Target
裡面,也可以放在 Endpoint
裡面。個人觀點是能放在 Target
裡面的就放在 Target
裡,這樣不需要自已去定義 EndpointClosure
。
Endpoint
類還有一些方法來便捷建立 Endpoint
,可以參考一下。
到這一步,我們得到的資料是一個
Endpoint
物件,有了這個物件,我們就可以來建立Request
了。
Request
和 Target->Endpoint
的對映一樣,Endpoint->Request
的對映也有一個類似的屬性:requestClosure
屬性。
public typealias RequestClosure = (Endpoint, @escaping RequestResultClosure) -> Void
open let requestClosure: RequestClosure
複製程式碼
同樣也可以自定義閉包傳遞給 MoyaProvider
的構造器,但通常不建議這麼做。因為這樣會讓業務程式碼直接觸達 Request
,有違 Moya
的目標。通常我們直接用預設的轉換方法就行。預設對映方法的實現在 MoyaProvider+Defaults.swift
檔案中,如下:
public final class func defaultRequestMapping(for endpoint: Endpoint, closure: RequestResultClosure) {
do {
let urlRequest = try endpoint.urlRequest()
closure(.success(urlRequest))
}
......
}
複製程式碼
看程式碼會發現實際的轉換是由 Endpoint
類的 urlRequest
方法來完成的,如下:
public func urlRequest() throws -> URLRequest {
guard let requestURL = Foundation.URL(string: url) else {
throw MoyaError.requestMapping(url)
}
var request = URLRequest(url: requestURL)
request.httpMethod = method.rawValue
request.allHTTPHeaderFields = httpHeaderFields
switch task {
case .requestPlain, .uploadFile, .uploadMultipart, .downloadDestination:
return request
case .requestData(let data):
request.httpBody = data
return request
......
}
複製程式碼
這個方法建立了一個 URLRequest
物件,看程式碼都能理解。
返回到 defaultRequestMapping()
方法中,可以看到生成的 urlRequest
被附在一個 Result
列舉中,並傳給 defaultRequestMapping
的第二個引數: RequestResultClosure
。這步我們暫時到這。
到此我們的
URLRequest
物件就構建完成了,實際上我們會發現URLRequest
包含的資訊並不大,但已經足夠了,可以發起請求了。
發起請求
我們回到 RequestResultClosure
中,也就是 requestNormal()
方法的 performNetworking
閉包中。在這個閉包裡,就開始了發起請求的旅程。我們簡單看一下流程:
基本上就三個步驟:
performRequest()
:在這個方法中,將請求根據task
的型別分流;sendRequest()
、uploadFile()
等四方法:這幾個方法主要是建立對應的請求物件,如DataRequest
、UploadRequest
sendAlamofireRequest()
:各類請求最後會匯聚到這個方法中,完成發起請求操作;
func sendAlamofireRequest<T>(_ alamoRequest: T, target: Target, callbackQueue: DispatchQueue?, progress progressCompletion: Moya.ProgressBlock?, completion: @escaping Moya.Completion) -> CancellableToken where T: Requestable, T: Request {
......
progressAlamoRequest = progressAlamoRequest.response(callbackQueue: callbackQueue, completionHandler: completionHandler)
progressAlamoRequest.resume()
......
}
複製程式碼
到此為止,請求部分就基本結束了。
有一個小問題可以注意下:一個
Target
最後一直被傳遞到sendAlamofireRequest
方法中,比Endpoint
的使用週期還長。呵呵。
等等,還有件事
為什麼 Target
的使用週期比 Endpoint
還長呢?看程式碼,在 sendAlamofireRequest()
方法中有這麼一段:
let plugins = self.plugins
plugins.forEach { $0.willSend(alamoRequest, target: target) }
複製程式碼
也就是說 Target
需要用在 plugin
的方法中。Plugin
,即外掛,是 Moya
提供了一種特別實用的機制,可以被用來編輯請求、響應及完成副作用的。Moya
提供了幾個預設的外掛,同樣我們也可以自定義外掛。所有的外掛都需要實現 PluginType
協議,看看它的定義:
public extension PluginType {
func prepare(_ request: URLRequest, target: TargetType) -> URLRequest { return request }
func willSend(_ request: RequestType, target: TargetType) { }
func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) { }
func process(_ result: Result<Moya.Response, MoyaError>, target: TargetType) -> Result<Moya.Response, MoyaError> { return result }
}
複製程式碼
實際上就是在整個資料流四個位置插入一些操作,這些操作可以對資料進行修改,也可以是一些沒有副作用(例如日誌)的操作。實際上 prepare
操作是在 RequestResultClosure
中就執行了。後面兩個方法都是在響應階段插入的操作。在此不描述了。
總結
這篇文章主要是從資料的流向來看了看 Moya
的請求構建過程。我們避開了各種產生錯誤的分支以及用於測試插樁的程式碼,這些有興趣可以參考程式碼的具體實現。
最後盜圖一張,你就會發現一圖勝千言,我上面講的以及後面一篇文章講的全是廢話。
下一篇我們會從資料流的後半段 -- 響應處理 -- 來繼續看看 Moya
的實現,敬請關注。
參考
- 官方文件
https://github.com/Moya/Moya/blob/master/docs/README.md
- Moya的設計之道
https://github.com/LeoMobileDeveloper/Blogs/blob/master/Swift/AnaylizeMoya.md
追蹤一下 Moya 的資料流向,來看看它的基本實現。