从零开始打造一个 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 (名字您随意),并声明我们的类方法:

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 ""
    }
}

我们首先声明了两个函数,request 函数接受的参数依次是:

  • method: 请求类别
  • url: 目标地址
  • form: 参数表
  • success: 成功的回调, 类型为(data:NSData?) -> Void
  • fail: 失败的回调,类型为(error : NSError?) -> Void

第二个函数 buildParams, 输入一个字典,返回一个字符串。很容易想到就是我们用来做 url encode 的函数。

建议大家写代码前,都先写出主要函数的声明和对应的参数、返回值的类型。这其实就是一种最基本的架构工作

实现发送请求

现在按照之前的分析,我们来实现请求发送的逻辑:

    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()
        
    }

整个流程很直观,虽然 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 的过程。

我们先实现转义:

    func escape(string: String) -> String {
        let legalURLCharactersToBeEscaped: CFStringRef = ":&=;+!@#$()',*"
        return CFURLCreateStringByAddingPercentEscapes(nil, string, nil, legalURLCharactersToBeEscaped, CFStringBuiltInEncodings.UTF8.rawValue) as String
    }

没啥技术含量,可直接抄去用。然后根据我们上面的分析,实现 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
    }

我们用了一个辅助函数 queryComponent 来表达步骤2这个递归过程。

至此,我们就完成了请求的封装,这个部分完整的代码在这里

现在我们就可以用它来发送请求了,比如我们想通过 bing 网页词典来查询 joepardize 这个单词的意思:

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

返回:(这里没有对结果进行 parse, 这个不属于本文的内容

**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 - ****必应**** Dictionary</title><meta name=\"title\" content=\"****必应词典**** - ****中国领先的中英文在线词典****\"/><meta name=\"robots\" content=\"nofollow\"/><meta name=\"keywords\" content=\"jeopardize,jeopardize****是什么意思****,jeopardize****的翻译****,jeopardize****的音标****,jeopardize****的读音****,jeopardize****的用法****,jeopardize****的例句****\"/><meta name=\"description\" content=\"****必应词典为您提供****jeopardize****的释义,美****[\'dʒepər.daɪz]****,英****[\'dʒepə(r)daɪz]****,****v. ****危害;危及;冒****…****的危险;损害;**** ****网络释义:**** ****损坏;使受危险;破坏;**** \"/><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**

更优雅的接口和适配器模式

显然,目前的接口并不友好,封装也很低级。对于移动应用的网络开发而言,还有几个基本的需求没有被覆盖:

  • 默认的主机名: 我们的 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:

enum RequestMethod{
    case Post
    case Get
}

成员变量什么的就不用一一列举了,大家可以直接查看该文件完整的源代码。 这里看一下对外暴露的4个方法

为了实现链式调用,每个方法返回的都是自身

    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)
        }
    }

fetchpost 分别生成 GET 和 POST 请求,paras 方法设置参数,go 方法进行实际请求操作。

现在,我们可以这样来发送网络请求:

aht.shareInstance.fetch("http://yahoo.com").go({ (result) in print(result) }) { (error) in print(error) }

如果有参数的话:

aht.shareInstance.fetch("http://cn.bing.com/dict/").paras(["q":"jeopardize"]).go({ (result) in print(result) }) { (error) in print(error) }

通过该类内部的 hostname 属性,即可实现缺省的主机名。

结语

至此,我们就完成了一个最简单、但足以应付绝大多数网络请求的框架,或者也可以基于此走得更远,比如:

  • 尝试管理多个 NSURLSession
  • 尝试实现文件的下载与上传
  • 尝试集成常见的 restful api authentication 的功能,比如 BCE的鉴权机制
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容