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

知識小集發表於2019-03-04

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 的實現(一):構建請求

我們這裡從普通資料請求的整個流程來看看 Moya 的基本實現。

操控者 MoyaProvider

在梳理流程之前,有必要了解一下 MoyaProvider。我把這個 MoyaProvider 稱為 Moya 的操控者。在 Moya 層,它是整個資料流的管理者,包括構建請求、發起請求、接收響應、處理響應。也許類似的,我們自己封裝的網路庫也會有這樣一個角色,如 NetworkManager。我們來看看它和 Moya 中其它類/結構體的關係。

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

與我們直接打交道最多的也是這個類,不過我們不在這細講,在這裡它不是主角。我們來結合資料流,來看看資料在這個類中怎麼流轉。

構建 Request

一個基本的 HTTP/HTTPS 普通資料請求通常包含以下幾個要素:

  • URL
  • 請求引數
  • 請求方法
  • 請求報頭資訊
  • 可選的認證資訊

對於 Alamofire 來說,最終是構建一個 Request,然後使用不同的請求物件,依賴於這些資訊來發起請求。所以,構建請求的終點是 Request

不過官方文件給了一個構建 Request 的流程圖:

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

我們來看看這個流程。

請求的起點 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 資料,在文章中不做描述。

可以看到這個協議包含了一個請求所需要的基本資訊:用於拼接 URLbaseURLpath 、請求方法、請求報頭等。我們自定義的 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,然後發起請求就行了。而在從上面的圖可以看到,TargetRequest 之間還有一個 Endpoint。這是啥玩意呢?我們來看看。

MoyaProviderrequest 方法中呼叫了 requestNormal 方法。這個方法的第一行就做了個轉換操作,將 Target 轉換成 Endpoint 物件:

func requestNormal(_ target: Target, callbackQueue: DispatchQueue?, progress: Moya.ProgressBlock?, completion: @escaping Moya.Completion) -> Cancellable {
    let endpoint = self.endpoint(target)
    ......
}
複製程式碼

endpoint() 方法實際上呼叫的是 MoyaProviderendpointClosure 屬性:

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 的目標

如果有不同觀點,還請告訴我。

重複上面一句話:我們可以自定義轉換方法,來執行 TargetEndpoint 的對映操作。不過還有個問題,有些程式碼(比如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 閉包中。在這個閉包裡,就開始了發起請求的旅程。我們簡單看一下流程:

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

基本上就三個步驟:

  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 的實現(一):構建請求

下一篇我們會從資料流的後半段 -- 響應處理 -- 來繼續看看 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 的資料流向,來看看它的基本實現。

掃描關注 知識小集

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

相關文章