深入理解Moya設計

YinTokey發表於2019-03-04

關於Moya

Moya是一個網路抽象層,它在底層將Alamofire進行封裝,對外提供更簡潔的介面供開發者呼叫。在以往的Objective-C中,大部分開發者會使用AFNetwork進行網路請求,當業務複雜一些時,會對AFNetwork進行二次封裝,編寫一個適用於自己專案的網路抽象層。在Objective-C中,有著名的YTKNetwork,它將AFNetworking封裝成抽象父類,然後根據每一種不同的網路請求,都編寫不同的子類,子類繼承父類,來實現請求業務。Moya在專案層次中的地位,有點類似於YTKNetwork。可以看下圖對比

深入理解Moya設計

但是如果單純把Moya等同於swift版的YTKNetwork,那就是比較錯誤的想法了。Moya的設計思路和YTKNetwork差距非常大。上面我在介紹YTKNetwork時在強調子類和父類,繼承,是因為YTKNetwork是比較經典的利用OOP思想(物件導向)設計的產物。基於swift的Moya雖然也有使用到繼承,但是它的整體上是以POP思想(Protocol Oriented Programming,面向協議程式設計)為主導的。

面向協議程式設計(POP)

在閱讀Moya原始碼之前,如果對POP有一定了解,那麼理解其Moya會事半功倍的效果。在Objective-C也有協議,一般是讓物件遵守協議,然後實現協議規定的方法,通過這種方式來對類實現擴充套件。POP其實就是把這種思路進一步強化。很多時候事物具備多樣化的特質,而這些特質是無法單純從一個類中繼承而來的。為了解決這個痛點,C++有了多繼承,即一個子類可以繼承多種父類,這些被繼承的父類之間不一定有關聯。但是這依然會有其他問題,比如子類繼承父類後,不一定需要用到所有的父類方法和屬性,等於子類擁有了一些毫無用處的屬性和方法。比如父類進行了修改,那麼很難避免影響到子類。C++的多繼承還會帶來菱形缺陷,什麼是菱形缺陷?本節的下方我會放兩個連結,方便大家查閱。而Swift則引入了面向協議程式設計,通過協議來規定事物的實現。通過遵守不同的協議,來對一個類或者結構體或者列舉進行定製,它只需要實現協議所規定的屬性或方法即可,有點類似於搭建積木,取每一塊有需求的模組,進行組合拼接,相對於OOP,其耦合性更低,也為程式碼的維護和擴充提供更多的可能性。關於POP思想大致是這樣,下面是王巍關於POP的兩篇文章,值得讀一番。
面向協議程式設計與 Cocoa 的邂逅 (上)
面向協議程式設計與 Cocoa 的邂逅 (下)

Moya的模組組成

由於Moya是使用POP來設計的一個網路抽象層,因此他整體的邏輯結構並沒有明顯的繼承關係。Moya的核心程式碼,可以分成以下幾個模組

深入理解Moya設計

Provider

provider是一個提供網路請求服務的提供者。通過一些初始化配置之後,在外部可以直接用provider來發起request。

Request

在使用Moya進行網路請求時,第一步需要進行配置,來生成一個Request。首先按照官方文件,建立一個列舉,遵守TargetType協議,並實現協議所規定的屬性。為什麼要建立列舉來遵守協議,而不像Objective-C那樣建立類來遵守協議呢?其實使用類或者結構體也是可以的,這裡猜測使用列舉的原因是因為swift的列舉功能比Objective-C強大許多,列舉結合switch語句,使得API管理起來比較方便。
Request的生成過程如下圖

深入理解Moya設計

我們根據上圖,結合程式碼來分析其Request的生成過程。
根據建立了一個遵守TargetType協議的名為Myservice的列舉,我們完成了如下幾個變數的設定。

baseURL
path
method
sampleData
task
headers
複製程式碼

提供了這些網路請求的“基本材料”之後,就可以進一步配置去生成所需要的請求。看上圖的第一個箭頭,通過了一個EndpointClosure生成了endPoint。endPoit是一個物件,把網路請求所需的一些屬性和方法進行了包裝,在EndPoint類中有如下屬性:

    public typealias SampleResponseClosure = () -> EndpointSampleResponse

    open let url: String
    open let sampleResponseClosure: SampleResponseClosure
    open let method: Moya.Method
    open let task: Task
    open let httpHeaderFields: [String: String]?
複製程式碼

可以很直觀地看出來,EndPoint這幾個屬性可以和上面通過TargetTpye配置的變數對應起來。那麼這個過程在程式碼中做了哪些事?
在MoyaProvider類裡,有如下宣告

 /// Closure that defines the endpoints for the provider.
    public typealias EndpointClosure = (Target) -> Endpoint<Target>
    
    open let endpointClosure: EndpointClosure
複製程式碼

宣告瞭一個閉包,引數為Target,它是一個泛型,然後返回一個EndPoint。endPoint是一個類,它對請求的引數和動作進行了包裝,下面會對它進行詳細說明,先繼續看endpointClosure做了什麼。

endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping
複製程式碼

在MoyaProvider的初始化方法裡,呼叫其擴充套件的類方法defaultEndpointMapping輸入Target作為引數,返回了一個endPoint物件。

    public final class func defaultEndpointMapping(for target: Target) -> Endpoint<Target> {
        return Endpoint(
            url: URL(target: target).absoluteString,
            sampleResponseClosure: { .networkResponse(200, target.sampleData) },
            method: target.method,
            task: target.task,
            httpHeaderFields: target.headers
        )
    }
複製程式碼

Target就是一開始進行配置的列舉,通過點語法取出Target的變數,完成endPoint的初始化。這裡可能對於url和sampleResponseClosure會感到一些疑惑。url初始化,可以進入URL+Moya.swift檢視,它對NSURL類進行構造器的擴充套件,讓其具備根據Moya的TargetType來進行初始化的能力。

   /// Initialize URL from Moya`s `TargetType`.
    init<T: TargetType>(target: T) {
        // When a TargetType`s path is empty, URL.appendingPathComponent may introduce trailing /, which may not be wanted in some cases
        if target.path.isEmpty {
            self = target.baseURL
        } else {
            self = target.baseURL.appendingPathComponent(target.path)
        }
    }
複製程式碼

sampleResponseClosure是一個和網路請求返回假資料相關的閉包,這裡可以先忽略,不影響對Moya生成Request過程的理解。
我們知道了MoyaProvider.defaultEndpointMapping可以返回endPoint物件後,重新看一遍這句

endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping
複製程式碼

使用@escaping把endpointClosure宣告為逃逸閉包,我們可以把

EndpointClosure = MoyaProvider.defaultEndpointMapping
複製程式碼

轉換為

(Target) -> Endpoint<Target> = func defaultEndpointMapping(for target: Target) -> Endpoint<Target>
複製程式碼

再進一步轉換,等號左邊的可以寫成一個常規的閉包表示式

{(Target)->Endpoint<Target> in
	return Endpoint(
            url: URL(target: target).absoluteString,
            sampleResponseClosure: { .networkResponse(200, target.sampleData) },
            method: target.method,
            task: target.task,
            httpHeaderFields: target.headers
        )
}
複製程式碼

即endpointClosure這個閉包,傳入了Target作為引數,該閉包可以返回一個endPoint物件,如何獲取到閉包返回的endPoint物件?MoyaProvider提供了這麼一個方法

    /// Returns an `Endpoint` based on the token, method, and parameters by invoking the `endpointClosure`.
    open func endpoint(_ token: Target) -> Endpoint<Target> {
        return endpointClosure(token)
    }
複製程式碼

以上就是關於TargetType通過endpointClosure轉化為endPoint的過程。

下一步就是把利用requestClosure,傳入endPoint,然後生成request。request生成過程和endPoint很相似。

在MoyaProvider中宣告:

  /// Closure that decides if and what request should be performed
    public typealias RequestResultClosure = (Result<URLRequest, MoyaError>) -> Void
    open let requestClosure: RequestClosure
複製程式碼

然後在MoyaProvider的初始化方法裡有很相似的一句

                requestClosure: @escaping RequestClosure = MoyaProvider.defaultRequestMapping,
複製程式碼

進入檢視defaultRequestMapping方法

    public final class func defaultRequestMapping(for endpoint: Endpoint<Target>, closure: RequestResultClosure) {
        do {
            let urlRequest = try endpoint.urlRequest()
            closure(.success(urlRequest))
        } catch MoyaError.requestMapping(let url) {
            closure(.failure(MoyaError.requestMapping(url)))
        } catch MoyaError.parameterEncoding(let error) {
            closure(.failure(MoyaError.parameterEncoding(error)))
        } catch {
            closure(.failure(MoyaError.underlying(error, nil)))
        }
    }
複製程式碼

和endpointClosure類似,我們經過轉換,可以得到requestClosure的表示式為

{(endpoint:Endpoint<Target>, closure:RequestResultClosure) in
   do {
       let urlRequest = try endpoint.urlRequest()
       closure(.success(urlRequest))
   } catch MoyaError.requestMapping(let url) {
       closure(.failure(MoyaError.requestMapping(url)))
   } catch MoyaError.parameterEncoding(let error) {
       closure(.failure(MoyaError.parameterEncoding(error)))
   } catch {
       closure(.failure(MoyaError.underlying(error, nil)))
   }

}
複製程式碼

整體上使用do-catch語句來初始化一個urlRequest,根據不同結果向閉包傳入不同的引數。一開始使用try來呼叫endpoint.urlRequest(),如果丟擲錯誤,會切換到catch語句中去。endpoint.urlRequest()這個方法比較長,這裡就不放出來,感興趣可自行到Moya核心程式碼裡的Endpoint.swift裡檢視。它其實做的事情很簡單,就是根據前面說到的endpoint的那些屬性來初始化一個NSURLRequest的物件。

以上就是上方圖中所畫的,根據TargetType最終生成Request的過程。很多人會感到疑惑,為什麼搞得這麼麻煩,直接一步到位,傳一些必要引數生成Request不就完了?為什麼還要再增加endPoint這麼一個節點?根據Endpoint類所提供的一些方法來看,個人認為應該是為了更靈活地配置網路請求,以適應更多樣化的業務需求。Endpoint類還有幾個方法

    /// Convenience method for creating a new `Endpoint` with the same properties as the receiver, but with added HTTP header fields.
    open func adding(newHTTPHeaderFields: [String: String]) -> Endpoint<Target> 
    
        /// Convenience method for creating a new `Endpoint` with the same properties as the receiver, but with replaced `task` parameter.
    open func replacing(task: Task) -> Endpoint<Target>

複製程式碼

借用這些方法,在endpointClosure中可以給一些網路請求新增請求頭,替換請求引數,讓這些請求配置更加靈活。

我們看完了整個Request生成過程,那麼通過requestClosure生成的的Request是如何被外部拿到的呢?這就是我們下一步要探討的,Provider傳送請求實現過程。在下一節裡將會看到如何使用這個Request。

Provider傳送請求

我們再來看一下官方文件裡說明的Moya的基本使用步驟

  1. 建立列舉,遵守TargetType協議,實現規定的屬性。
  2. 初始化 provider = MoyaProvider<Myservice>()
  3. 呼叫provider.request,在閉包裡處理請求結果。

其中第一步我們在上方已經說明完了,MoyaProvider的初始化我們只說明瞭一小部分。在此不準備一口氣初始化方法中剩餘的部分講完,這又會涉及很多東西,同時理解起來會比較麻煩。在後面的程式碼解讀中,如果有涉及到相關屬性,再回到初始化方法中一個一個突破。

    open func request(_ target: Target,
                      callbackQueue: DispatchQueue? = .none,
                      progress: ProgressBlock? = .none,
                      completion: @escaping Completion) -> Cancellable {

        let callbackQueue = callbackQueue ?? self.callbackQueue
        return requestNormal(target, callbackQueue: callbackQueue, progress: progress, completion: completion)
    }
複製程式碼

直接從這裡可能看不出什麼,再追溯到requestNormal中去
這個方法內容比較長,其中一些外掛相關的程式碼,和測試樁的程式碼,暫且跳過不做說明,暫時不懂他們並不會成為理解provider.request的阻礙,它們屬於可選內容,而不是必須的。

        let endpoint = self.endpoint(target)

複製程式碼

生成了endPoint物件,這個很好理解,前面已經做過說明。
檢視performNetworking閉包

      if cancellableToken.isCancelled {
                self.cancelCompletion(pluginsWithCompletion, target: target)
                return
            }
複製程式碼

如果取消請求,則呼叫取消完成的回撥,並return,不在執行閉包內下面的語句。
在這個閉包裡傳入了引數(requestResult: Result<URLRequest, MoyaError>),這裡用到了Result,想深入瞭解,可自行研究,這裡簡單說一下Result是幹什麼的。Result使用列舉方式,提供一些執行處理的結果,如下,很容易能看懂它所表達的意思。

   switch requestResult {
            case .success(let urlRequest):
                request = urlRequest
            case .failure(let error):
                pluginsWithCompletion(.failure(error))
                return
            }
複製程式碼

如果請求成功,會拿到URLRequest,如果失敗,會使用外掛去處理失敗回撥。

            // Allow plugins to modify request
            let preparedRequest = self.plugins.reduce(request) { $1.prepare($0, target: target) }
複製程式碼

使用外掛對請求進行完善

            cancellableToken.innerCancellable = self.performRequest(target, request: preparedRequest, callbackQueue: callbackQueue, progress: progress, completion: networkCompletion, endpoint: endpoint, stubBehavior: stubBehavior)
複製程式碼

這裡的self.performRequest就是進行實際的網路請求,內部程式碼比較多,但是思路很簡單,使用Alamofire的SessionManager來傳送請求。
配置完成後就可以呼叫requestClosure(endpoint, performNetworking),執行這個閉包獲取到上方所說的Request,來執行具體的網路請求了。

Response

在使用Alamofire傳送請求時,定義了閉包來處理請求的響應。Response這個類對於請求結果,提供了一些加工方法,比如data轉json,圖片轉換等。

Plugins

Moya提供了一個外掛協議PluginType,協議裡規定了幾種方法,闡明瞭外掛的應用區域。

 /// Called to modify a request before sending
    func prepare(_ request: URLRequest, target: TargetType) -> URLRequest

    /// Called immediately before a request is sent over the network (or stubbed).
    func willSend(_ request: RequestType, target: TargetType)

    /// Called after a response has been received, but before the MoyaProvider has invoked its completion handler.
    func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType)

    /// Called to modify a result before completion
    func process(_ result: Result<Moya.Response, MoyaError>, target: TargetType) -> Result<Moya.Response, MoyaError>
複製程式碼
  • prepare可以在請求之前對request進行修改。
  • willSend在請求傳送之前的一瞬間呼叫,這個可以用來新增請求時轉圈圈的Toast
  • didReceive在接收到請求響應時,且MoyaProvider的completion handler之前呼叫。
  • process在completion handler之前呼叫,用來修改請求結果
    可以通過以下圖來直觀地理解外掛呼叫時機
    深入理解Moya設計

    使用外掛的方式,讓程式碼僅保持著主幹邏輯,使用者根據業務需求自行加入外掛來配置自己的網路業務層,這樣做更加靈活,低耦合。Moya提供了4種外掛

  • AccessTokenPlugin OAuth的Token驗證
  • CredentialsPlugin 證書
  • NetworkActivityPlugin 網路請求狀態
  • NetworkLoggerPlugin 網路日誌
    可以根據需求編寫自己的外掛,選取NetworkActivityPlugin來檢視外掛內部構成。
public final class NetworkActivityPlugin: PluginType {

    public typealias NetworkActivityClosure = (_ change: NetworkActivityChangeType, _ target: TargetType) -> Void
    let networkActivityClosure: NetworkActivityClosure

    public init(networkActivityClosure: @escaping NetworkActivityClosure) {
        self.networkActivityClosure = networkActivityClosure
    }

    public func willSend(_ request: RequestType, target: TargetType) {
        networkActivityClosure(.began, target)
    }

    public func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType) {
        networkActivityClosure(.ended, target)
    }
}
複製程式碼

外掛內部結構很簡單,除了自行定義的一些變數外,就是遵守PluginType協議後,去實現協議規定的方法,在特定方法內做自己需要做的事。因為PluginType它已經有一個協議擴充套件,把方法的預設實現都完成了,在具體外掛內不一定需要實現所有的協議方法,僅根據需要實現特定方法即可。
寫好外掛之後,使用起來也比較簡答,MoyaProvider的初始化方法中,有個形參plugins: [PluginType] = [],把網路請求中需要用到的外掛加入陣列中。

總結

Moya可以說是非常Swift式的一個框架,最大的優點是使用面向協議的思想,讓使用者能以搭積木的方式配置自己的網路抽象層。提供了外掛機制,在保持主幹網路請求邏輯的前提下,讓開發者根據自身業務需求,定製自己的外掛,在合適的位置加入到網路請求的過程中。

相關文章