說起網(wǎng)絡(luò)框架,大家第一時(shí)間就會(huì)想到 AFNetworking、Alamofire 這些業(yè)內(nèi)響當(dāng)當(dāng)?shù)淖髌?,有的老鳥也會(huì)適當(dāng)傷感一下曾經(jīng)用的 ASI 。這些框架都有一個(gè)共同點(diǎn)——功能都很復(fù)雜,很齊全,而我們往往只能用到很小很小的一個(gè)部分。
事實(shí)上,咱們做 App 的時(shí)候,絕大多數(shù)時(shí)候?qū)W(wǎng)絡(luò)的需求都是收發(fā) GET/POST 請(qǐng)求。就這樣來看,根據(jù)需求來造個(gè)屬于自己的輪子,似乎也是個(gè)不錯(cuò)的選擇。尤其是現(xiàn)在蘋果提供的 NSURLSession 已經(jīng)非常強(qiáng)大,基于原生的 SDK 來做一個(gè)自己的框架,其實(shí)是很容易的。
根據(jù)這個(gè)思想,我之前擼了一個(gè)簡單的網(wǎng)絡(luò)庫 AaHTTP,在工作的項(xiàng)目里重度用了一段時(shí)間也沒有遇到什么特別的問題。
現(xiàn)在我們就來一步步看看如何做一個(gè)屬于自己的簡單的網(wǎng)絡(luò)框架。
發(fā)送請(qǐng)求的步驟分析
要發(fā)送一個(gè)請(qǐng)求,分為如下步驟:
- 如果攜帶的參數(shù)是
GET類型,則將參數(shù)進(jìn)行 URL encode(轉(zhuǎn)化為y1=x1&y2=x2的形式),追加到原始 url 的后面。如果參數(shù)是POST類型,則 URL 不變。 - 用最新的 URL 生成一個(gè)
NSMutableURLRequest的對(duì)象 - 如果參數(shù)是
POST的情況,設(shè)置Content-Type為application/x-www-form-urlencoded, 并將參數(shù)進(jìn)行 URL encode,并添加到 body 中。 - 使用 NSURLSession 發(fā)送該請(qǐng)求
URL encode時(shí),需要對(duì)特殊字符進(jìn)行轉(zhuǎn)義。
定義發(fā)送請(qǐng)求的接口
根據(jù)上面的步驟,我們不難一步到位的實(shí)現(xiàn)發(fā)送請(qǐng)求,新建一個(gè) AaNet.swift (名字您隨意),并聲明我們的類方法:
class AaNet: NSObject {
class func request( method : String = "GET",url : String ,form : Dictionary<String,AnyObject> = [:],success : (data : NSData?)->Void,fail:(error : NSError?)->Void){
}
func buildParams(parameters: [String: AnyObject]) -> String {
return ""
}
}
我們首先聲明了兩個(gè)函數(shù),request 函數(shù)接受的參數(shù)依次是:
-
method: 請(qǐng)求類別 -
url: 目標(biāo)地址 -
form: 參數(shù)表 -
success: 成功的回調(diào), 類型為(data:NSData?) -> Void -
fail: 失敗的回調(diào),類型為(error : NSError?) -> Void
第二個(gè)函數(shù) buildParams, 輸入一個(gè)字典,返回一個(gè)字符串。很容易想到就是我們用來做 url encode 的函數(shù)。
建議大家寫代碼前,都先寫出主要函數(shù)的聲明和對(duì)應(yīng)的參數(shù)、返回值的類型。這其實(shí)就是一種最基本的架構(gòu)工作
實(shí)現(xiàn)發(fā)送請(qǐng)求
現(xiàn)在按照之前的分析,我們來實(shí)現(xiàn)請(qǐng)求發(fā)送的邏輯:
class func request( method : String = "GET",url : String ,form : Dictionary<String,AnyObject> = [:],success : (data : NSData?)->Void,fail:(error : NSError?)->Void){
var innerUrl = url
if method == "GET"{
innerUrl += "?" + AaNet().buildParams(form)
}
let req = NSMutableURLRequest(URL: NSURL(string: innerUrl)!)
req.HTTPMethod = method
if method == "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) -> Void in
if error != nil{
fail(error: error)
print(response)
}else{
if (response as! NSHTTPURLResponse).statusCode == 200{
success(data : data)
}else{
fail(error: error)
print(response)
}
}
}
task.resume()
}
整個(gè)流程很直觀,雖然 GET 參數(shù)和 POST 參數(shù)處理的位置不同,但都是用我們的 url encode 函數(shù) buildParams 來操作的。區(qū)別是 GET 請(qǐng)求的話,處理完后直接 append 到 url 后面,而 POST 需要用 UTF8 encode 一下,放在 request 的 body 里。
然后用 NSURLSession 的默認(rèn) session: sharedSession() 來發(fā)送請(qǐng)求,并在回調(diào)里判斷 statusCode 以及 error 對(duì)象是否為 nil 來判斷請(qǐng)求是否為空,來分別調(diào)用我們的 success 回調(diào)或 fail 回調(diào)。
實(shí)現(xiàn) URL encode
現(xiàn)在我們來實(shí)現(xiàn) buildParams,大體的步驟為:
encode:
- 把輸入字典轉(zhuǎn)換為鍵值對(duì)的數(shù)組。
[ (Key,Value) ] - 對(duì)于每一個(gè)
(key,value),執(zhí)行:
2.1 對(duì)key進(jìn)行轉(zhuǎn)義,得到key'
2.2 檢查value的類型,如果是簡單的值,則對(duì)其進(jìn)行轉(zhuǎn)義,得到value'。并將(key' , value')輸出到結(jié)果數(shù)組中。
2.3 如果value是數(shù)組,則用當(dāng)前的key和value中的每一個(gè)元素組成 tuple:[(key,subValue)], 遞歸執(zhí)行步驟2。
2.4 如果value是字典,也先把value對(duì)應(yīng)的字段轉(zhuǎn)化為鍵值對(duì)數(shù)組,但是 key 的形式為key[subKey], 前面是 key 是當(dāng)前的 key,subKey 代表value對(duì)應(yīng)的字典中的 key。得到鍵值對(duì)數(shù)組后,遞歸執(zhí)行步驟2。 - 步驟2執(zhí)行完畢后,我們會(huì)得到一個(gè)一維的、并且 key 和 value 都被轉(zhuǎn)義過的鍵值對(duì)數(shù)組
[ (key,value) ],然后我們將其轉(zhuǎn)換為key1=value1&key2=value2&...keyN=valueN的形式返回。
仔細(xì)感受一下,步驟2是不是有一個(gè)
flat的過程。
我們先實(shí)現(xiàn)轉(zhuǎn)義:
func escape(string: String) -> String {
let legalURLCharactersToBeEscaped: CFStringRef = ":&=;+!@#$()',*"
return CFURLCreateStringByAddingPercentEscapes(nil, string, nil, legalURLCharactersToBeEscaped, CFStringBuiltInEncodings.UTF8.rawValue) as String
}
沒啥技術(shù)含量,可直接抄去用。然后根據(jù)我們上面的分析,實(shí)現(xiàn) URL encode:
func buildParams(parameters: [String: AnyObject]) -> String {
var components: [(String, String)] = []
for key in Array(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)] {
var components: [(String, String)] = []
if let dictionary = value as? [String: AnyObject] {
for (nestedKey, value) in dictionary {
components += queryComponents("\(key)[\(nestedKey)]", value)
}
} else if let array = value as? [AnyObject] {
for value in array {
components += queryComponents("\(key)", value)
}
} else {
components.appendContentsOf([(escape(key), escape("\(value)"))])
}
return components
}
我們用了一個(gè)輔助函數(shù) queryComponent 來表達(dá)步驟2這個(gè)遞歸過程。
至此,我們就完成了請(qǐng)求的封裝,這個(gè)部分完整的代碼在這里
現(xiàn)在我們就可以用它來發(fā)送請(qǐng)求了,比如我們想通過 bing 網(wǎng)頁詞典來查詢 joepardize 這個(gè)單詞的意思:
AaNet.request("GET", url: "http://cn.bing.com/dict/", form: ["q":"jeopardize"], success: { (data) in
print(String(data: data!, encoding: NSUTF8StringEncoding))
}) { (error) in
}
返回:(這里沒有對(duì)結(jié)果進(jìn)行 parse, 這個(gè)不屬于本文的內(nèi)容
**Optional("<!DOCTYPE html><html lang=\"en\" xml:lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:Web=\"http://schemas.live.com/Web/\"><script type=\"text/javascript\">//<![CDATA[\r\nsi_ST=new Date;\r\n//]]></script><head><!--pc--><title>jeopardize - ****必應(yīng)**** Dictionary</title><meta name=\"title\" content=\"****必應(yīng)詞典**** - ****中國領(lǐng)先的中英文在線詞典****\"/><meta name=\"robots\" content=\"nofollow\"/><meta name=\"keywords\" content=\"jeopardize,jeopardize****是什么意思****,jeopardize****的翻譯****,jeopardize****的音標(biāo)****,jeopardize****的讀音****,jeopardize****的用法****,jeopardize****的例句****\"/><meta name=\"description\" content=\"****必應(yīng)詞典為您提供****jeopardize****的釋義,美****[\'d?ep?r.da?z]****,英****[\'d?ep?(r)da?z]****,****v. ****危害;危及;冒****…****的危險(xiǎn);損害;**** ****網(wǎng)絡(luò)釋義:**** ****損壞;使受危險(xiǎn);破壞;**** \"/><meta content=\"text/html; charset=utf-8\" http-equiv=\"content-type\"/><link href=\"/sa/simg/favicon_teal_min.ico\" rel=\"icon\"/><script type=\"text/javascript\">//<![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\\/\";function si_T(a){if(document.images){_G.GPImg=new Image;_G.GPImg.src=_G.gpUrl+\'IG=\'+_G.IG+\'&\'+a;}return true;};\n//]]></script><style type=\"text/css**
更優(yōu)雅的接口和適配器模式
顯然,目前的接口并不友好,封裝也很低級(jí)。對(duì)于移動(dòng)應(yīng)用的網(wǎng)絡(luò)開發(fā)而言,還有幾個(gè)基本的需求沒有被覆蓋:
- 默認(rèn)的主機(jī)名: 我們的 app 一般的后臺(tái)就一個(gè)域名,如果我們每次發(fā)一個(gè)請(qǐng)求都要敲一遍域名那真的太蛋疼了。
- 默認(rèn)的參數(shù)列表: 很多參數(shù)是基本每個(gè)請(qǐng)求都要帶的,比如 app 的版本,用戶設(shè)備的語言等等。
- 更加簡短并讓人一看就懂得函數(shù)調(diào)用。
- 參數(shù)可缺省
- 錯(cuò)誤處理可缺省
要實(shí)現(xiàn)上述的需求,我們有兩條路可以走:
- 在 AaNet 內(nèi)部加上對(duì)應(yīng)的邏輯,然后對(duì)之前的
request做各種函數(shù)重載來實(shí)現(xiàn)。 - 做一個(gè)新的模塊,實(shí)現(xiàn)上述功能,但底層的數(shù)據(jù)發(fā)送調(diào)用
AaNet,AaNet代碼不變。
憑直覺來看,似乎應(yīng)該選擇第二個(gè)方案,首先上面的需求可能是多變的,但 AaNet 目前完成的功能是基本不會(huì)變的(除非 HTTP 協(xié)議的標(biāo)準(zhǔn)改變),變化的和不變的應(yīng)該分開。其次是我們?cè)趯碛锌赡苡龅?AaNet 不能滿足我們的需求,需要采用一些更加成熟的框架(e.g. AFNetworking 等)的時(shí)候,遷移的成本要最低的話,用一個(gè)中間層把我們的代碼和 AaNet 隔開是個(gè)很不錯(cuò)的選擇。
這個(gè)思想在設(shè)計(jì)模式中叫做適配器模式, 我們新開一個(gè) AaHTTP (名字任意)類來處理上述的需求,在底層調(diào)用 AaNet 來實(shí)現(xiàn)請(qǐng)求的發(fā)送。 然后在代碼里調(diào)用 AaHTTP 的方法來完成業(yè)務(wù)邏輯,這樣,即便某一天我們要需要替換網(wǎng)絡(luò)通信的框架,也只是需要在 AaHTTP 內(nèi)部的實(shí)現(xiàn)上修改 AaNet 為其他實(shí)現(xiàn)即可,不需要修改其他代碼。 這里的 AaHTTP 就是一種典型的適配器。
實(shí)現(xiàn) AaHTTP
比起 AaNet, AaHTTP 的實(shí)現(xiàn)是很簡單的,主要都是一些設(shè)計(jì)層面的東西。
方便區(qū)別 GET 和 POST, 用字符串肯定是不明智的,我們?cè)黾右粋€(gè) enum:
enum RequestMethod{
case Post
case Get
}
成員變量什么的就不用一一列舉了,大家可以直接查看該文件完整的源代碼。 這里看一下對(duì)外暴露的4個(gè)方法
為了實(shí)現(xiàn)鏈?zhǔn)秸{(diào)用,每個(gè)方法返回的都是自身
func fetch(url : String) -> AaHTTP{
setDefaultParas()
curUrl = "\(hostName)\(url)"
self.method = .Get
return self
}
func post(url : String) -> AaHTTP{
setDefaultParas()
curUrl = "\(hostName)\(url)"
self.method = .Post
return self
}
func paras(p : [String:AnyObject]) -> AaHTTP{
_ = p.reduce("") { (str, p) -> String in
parameters[p.0] = p.1
return ""
}
return self
}
func go(success : String -> Void, failure : NSError?->Void){
var smethod = ""
if method == .Get{
smethod = "GET"
}else{
smethod = "POST"
}
AaNet.request(smethod, url: curUrl, form: parameters, success: { (data) -> Void in
print("request successed in \(self.curUrl)")
let result = String(data: data!, encoding: NSUTF8StringEncoding)
success(result!)
}) { (error) -> Void in
print("request failed in \(self.curUrl)")
failure(error)
}
}
fetch 和 post 分別生成 GET 和 POST 請(qǐng)求,paras 方法設(shè)置參數(shù),go 方法進(jìn)行實(shí)際請(qǐng)求操作。
現(xiàn)在,我們可以這樣來發(fā)送網(wǎng)絡(luò)請(qǐng)求:
aht.shareInstance.fetch("http://yahoo.com").go({ (result) in print(result) }) { (error) in print(error) }
如果有參數(shù)的話:
aht.shareInstance.fetch("http://cn.bing.com/dict/").paras(["q":"jeopardize"]).go({ (result) in print(result) }) { (error) in print(error) }
通過該類內(nèi)部的 hostname 屬性,即可實(shí)現(xiàn)缺省的主機(jī)名。
結(jié)語
至此,我們就完成了一個(gè)最簡單、但足以應(yīng)付絕大多數(shù)網(wǎng)絡(luò)請(qǐng)求的框架,或者也可以基于此走得更遠(yuǎn),比如:
- 嘗試管理多個(gè) NSURLSession
- 嘗試實(shí)現(xiàn)文件的下載與上傳
- 嘗試集成常見的 restful api authentication 的功能,比如 BCE的鑒權(quán)機(jī)制