1. 程式人生 > >從零開始打造一個 Swift 網路框架

從零開始打造一個 Swift 網路框架

說起網路框架,大家第一時間就會想到 AFNetworking、Alamofire 這些業內響噹噹的作品,有的老鳥也會適當傷感一下曾經用的 ASI 。這些框架都有一個共同點——功能都很複雜,很齊全,而我們往往只能用到很小很小的一個部分。

事實上,咱們做 App 的時候,絕大多數時候對網路的需求都是收發 GET/POST 請求。就這樣來看,根據需求來造個屬於自己的輪子,似乎也是個不錯的選擇。尤其是現在蘋果提供的 NSURLSession 已經非常強大,基於原生的 SDK 來做一個自己的框架,其實是很容易的。

根據這個思想,我之前擼了一個簡單的網路庫 AaHTTP,在工作的專案裡重度用了一段時間也沒有遇到什麼特別的問題。

現在我們就來一步步看看如何做一個屬於自己的簡單的網路框架。

傳送請求的步驟分析

要傳送一個請求,分為如下步驟:

  1. 如果攜帶的引數是 GET 型別,則將引數進行 URL encode(轉化為 y1=x1&y2=x2的形式),追加到原始 url 的後面。如果引數是 POST 型別,則 URL 不變。
  2. 用最新的 URL 生成一個 NSMutableURLRequest 的物件
  3. 如果引數是 POST 的情況,設定 Content-Typeapplication/x-www-form-urlencoded, 並將引數進行 URL encode,並新增到 body 中。
  4. 使用 NSURLSession 傳送該請求

URL encode時,需要對特殊字元進行轉義。

定義傳送請求的介面

根據上面的步驟,我們不難一步到位的實現傳送請求,新建一個 AaNet.swift (名字您隨意),並宣告我們的類方法:

123456789 classAaNet:NSObject{classfunc request(method:String="GET",url:String,form:Dictionary=[:],success:(data:NSData?)->Void,fail:(error:NSError?)->Void){}func buildParams(parameters:[String:AnyObject])->String{return""}}

我們首先聲明瞭兩個函式,request 函式接受的引數依次是:

  • method: 請求類別
  • url: 目標地址
  • form: 引數表
  • success: 成功的回撥, 型別為(data:NSData?) -> Void
  • fail: 失敗的回撥,型別為(error : NSError?) -> Void

第二個函式 buildParams, 輸入一個字典,返回一個字串。很容易想到就是我們用來做 url encode 的函式。

建議大家寫程式碼前,都先寫出主要函式的宣告和對應的引數、返回值的型別。這其實就是一種最基本的架構工作

實現傳送請求

現在按照之前的分析,我們來實現請求傳送的邏輯:

1234567891011121314151617181920212223242526272829303132333435363738 classfunc request(method:String="GET",url:String,form:Dictionary=[:],success:(data:NSData?)->Void,fail:(error:NSError?)->Void){varinnerUrl=urlifmethod=="GET"{innerUrl+="?"+AaNet().buildParams(form)}let req=NSMutableURLRequest(URL:NSURL(string:innerUrl)!)req.HTTPMethod=methodifmethod=="POST"{req.addValue("application/x-www-form-urlencoded",forHTTPHeaderField:"Content-Type")print("POST PARAMS (form)")req.HTTPBody=AaNet().buildParams(form).dataUsingEncoding(NSUTF8StringEncoding)}let session=NSURLSession.sharedSession()print(req.description)let task=session.dataTaskWithRequest(req){(data,response,error)->Voidiniferror!=nil{fail(error:error)print(response)}else{if(response as!NSHTTPURLResponse).statusCode==200{success(data:data)}else{fail(error:error)print(response)}}}task.resume()}

整個流程很直觀,雖然 GET 引數和 POST 引數處理的位置不同,但都是用我們的 url encode 函式 buildParams 來操作的。區別是 GET 請求的話,處理完後直接 append 到 url 後面,而 POST 需要用 UTF8 encode 一下,放在 request 的 body 裡。

然後用 NSURLSession 的預設 session: sharedSession() 來發送請求,並在回撥裡判斷 statusCode 以及 error 物件是否為 nil 來判斷請求是否為空,來分別呼叫我們的 success 回撥或 fail 回撥。

實現 URL encode

現在我們來實現 buildParams,大體的步驟為:

encode:

  1. 把輸入字典轉換為鍵值對的陣列。[ (Key,Value) ]
  2. 對於每一個 (key,value),執行:
    2.1 對 key 進行轉義,得到 key'
    2.2 檢查 value 的型別,如果是簡單的值,則對其進行轉義,得到 value'。並將 (key' , value') 輸出到結果陣列中。
    2.3 如果 value 是陣列,則用當前的 keyvalue 中的每一個元素組成 tuple: [(key,subValue)], 遞迴執行步驟2。
    2.4 如果 value 是字典,也先把 value 對應的欄位轉化為鍵值對陣列,但是 key 的形式為 key[subKey], 前面是 key 是當前的 key,subKey 代表 value 對應的字典中的 key。得到鍵值對陣列後,遞迴執行步驟2。
  3. 步驟2執行完畢後,我們會得到一個一維的、並且 key 和 value 都被轉義過的鍵值對陣列 [ (key,value) ],然後我們將其轉換為 key1=value1&key2=value2&...keyN=valueN 的形式返回。

仔細感受一下,步驟2是不是有一個 flat 的過程。

我們先實現轉義:

1234 func escape(string:String)->String{let legalURLCharactersToBeEscaped:CFStringRef=":&=;[email protected]#$()',*"returnCFURLCreateStringByAddingPercentEscapes(nil,string,nil,legalURLCharactersToBeEscaped,CFStringBuiltInEncodings.UTF8.rawValue)asString}

沒啥技術含量,可直接抄去用。然後根據我們上面的分析,實現 URL encode:

1234567891011121314151617181920212223242526 func buildParams(parameters:[String:AnyObject])->String{varcomponents:[(String,String)]=[]forkey inArray(parameters.keys).sort(){let value:AnyObject!=parameters[key]components+=self.queryComponents(key,value)}return(components.map{"($0)=($1)"}as[String]).joinWithSeparator("&")}func queryComponents(key:String,_value:AnyObject)->[(String,String)]{varcomponents:[(String,String)]=[]iflet dictionary=value as?[String:AnyObject]{for(nestedKey,value)indictionary{components+=queryComponents("(key)[(nestedKey)]",value)}}elseiflet array=valueas?[AnyObject]{forvalueinarray{components+=queryComponents("(key)",value)}}else{components.appendContentsOf([(escape(key),escape("(value)"))])}returncomponents}

我們用了一個輔助函式 queryComponent 來表達步驟2這個遞迴過程。

至此,我們就完成了請求的封裝,這個部分完整的程式碼在這裡

現在我們就可以用它來發送請求了,比如我們想通過 bing 網頁詞典來查詢 joepardize 這個單詞的意思:

1234 AaNet.request("GET",url:"http://cn.bing.com/dict/",form:["q":"jeopardize"],success:{(data)inprint(String(data:data!,encoding:NSUTF8StringEncoding))}){(error)in}

返回:(這裡沒有對結果進行 parse, 這個不屬於本文的內容

1 **Optional("//<![CDATA[rnsi_ST=new Date;rn//]]>jeopardize - ****必應**** Dictionary//<![CDATA[n_G={ST:(si_ST?si_ST:new Date),Mkt:"en-US",RTL:false,Ver:"15",IG:"C33762708EB443748A4535A9339C11A0",EventID:"71B08123D0674FC09FBEBFA1DEAD9D4B",V:"web",P:"Dictionary",DA:"HK2",SUIH:"Jiikj9TC83VvRen-Y4-a_A",gpUrl:"\/fd\/ls\/GLinkPing.aspx?"};_G.lsUrl="/fd/ls/l?IG="+_G.IG;curUrl="http:\/\/cn.bing.com\/dict\/";functionsi_T(a){if(document.images){_G.GPImg=newImage;_G.GPImg.src=_G.gpUrl+'IG='+_G.IG+'&'+a;}returntrue;};n//]]>

更優雅的介面和介面卡模式

顯然,目前的介面並不友好,封裝也很低階。對於移動應用的網路開發而言,還有幾個基本的需求沒有被覆蓋:

  • 預設的主機名: 我們的 app 一般的後臺就一個域名,如果我們每次發一個請求都要敲一遍域名那真的太蛋疼了。
  • 預設的引數列表: 很多引數是基本每個請求都要帶的,比如 app 的版本,使用者裝置的語言等等。
  • 更加簡短並讓人一看就懂得函式呼叫。
  • 引數可預設
  • 錯誤處理可預設

要實現上述的需求,我們有兩條路可以走:

  • 在 AaNet 內部加上對應的邏輯,然後對之前的 request 做各種函式過載來實現。
  • 做一個新的模組,實現上述功能,但底層的資料傳送呼叫 AaNet, AaNet 程式碼不變。

憑直覺來看,似乎應該選擇第二個方案,首先上面的需求可能是多變的,但 AaNet 目前完成的功能是基本不會變的(除非 HTTP 協議的標準改變),變化的和不變的應該分開。其次是我們在將來有可能遇到 AaNet 不能滿足我們的需求,需要採用一些更加成熟的框架(e.g. AFNetworking 等)的時候,遷移的成本要最低的話,用一箇中間層把我們的程式碼和 AaNet 隔開是個很不錯的選擇。

這個思想在設計模式中叫做介面卡模式, 我們新開一個 AaHTTP (名字任意)類來處理上述的需求,在底層呼叫 AaNet 來實現請求的傳送。 然後在程式碼裡呼叫 AaHTTP 的方法來完成業務邏輯,這樣,即便某一天我們要需要替換網路通訊的框架,也只是需要在 AaHTTP 內部的實現上修改 AaNet 為其他實現即可,不需要修改其他程式碼。 這裡的 AaHTTP 就是一種典型的介面卡。

實現 AaHTTP

比起 AaNet, AaHTTP 的實現是很簡單的,主要都是一些設計層面的東西。

方便區別 GET 和 POST, 用字串肯定是不明智的,我們增加一個 enum:

1234 enumRequestMethod{casePostcaseGet}

成員變數什麼的就不用一一列舉了,大家可以直接檢視該檔案完整的原始碼。 這裡看一下對外暴露的4個方法

為了實現鏈式呼叫,每個方法返回的都是自身

123456789101112131415161718192021222324252627282930313233343536373839 func fetch(url:String)->AaHTTP{setDefaultParas()curUrl="(hostName)(url)"self.method=.Getreturnself}func post(url:String)->AaHTTP{setDefaultParas()curUrl="(hostName)(url)"self.method=.Postreturnself}func paras(p:[String:AnyObject])->AaHTTP{_=p.reduce(""){(str,p)