1. 程式人生 > >從資料流角度管窺 Moya 的實現(一):構建請求

從資料流角度管窺 Moya 的實現(一):構建請求

相信大家都封裝過網路層。

雖然系統提供的網路庫以及一些著名的第三方網路庫(AFNetworkingAlamofire)已經能滿足各種 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時,可以根據不同的介面來配置不同的 baseURLpathmethod等資訊。不過可能會導致一個問題:在一個大的獨立工程裡面,通常介面有幾十上百個。如果你把所有的介面都放一個列舉裡面,你可能最後會發現,各種 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呢?

我有兩個觀點:

  1. 比起 Target來,Endpoint更像一個請求物件;Target是通過列舉來描述的一組請求,而 Endpoint就是一個實實在在的請求物件;(廢話)
  2. 通過 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閉包中。在這個閉包裡,就開始了發起請求的旅程。我們簡單看一下流程:

 

 

基本上就三個步驟:

  1. performRequest():在這個方法中,將請求根據 task的型別分流;
  2. sendRequest()uploadFile()等四方法:這幾個方法主要是建立對應的請求物件,如 DataRequestUploadRequest
  3. 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的實現,敬請關注。

參考

  1. 官方文件 https://github.com/Moya/Moya/blob/master/docs/README.md
  2. Moya的設計之道 https://github.com/LeoMobileDeveloper/Blogs/blob/master/Swift/AnaylizeMoya.md

追蹤一下 Moya 的資料流向,來看看它的基本實現。


作者:知識小集
連結:https://juejin.im/post/5ac2cf34f265da23a1421483
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。