使用 NSURLSession 实现 Proxy

实现原理

  1. NSURLProtocol 当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,这个子类可以实现自定义,这样就让你有机会对所有的 Request 进行统一的处理。
  2. 在适当的时候(一般是在 APP 启动的时候,那就是在AppDelegate的didFinishLaunchingWithOptions方法中;也有在业务需要的时候)注册自定义的类:NSURLProtocol.registerClass(ProxyProtocol) 这里的 ProxyProtocol 就是自定义的类
  3. ProxyProtocol 需要实现NSURLProtocol协议,其中几个比较重要的方法
    • canInitWithRequest:整个流程的入口,这个方法返回 true,URL Loading System会创建一个ProxyProtocol实例,然后调用NSURLConnection去获取数据,然而这也会调用URL Loading System,再次创建一个ProxyProtocol实例,而陷入了死循环。所以,我们需要做的是通过+setProperty:forKey:inRequest来对已经处理过的request 进行标识,在刚开始就判断这个 request 是否已经处理过了,如果是则返回 false。
    • canonicalRequestForRequest:在筛选出需要处理的请求后,需要实现这个方法。直接返回request,一般可以在这里修改 request 的内容,比如添加 header 等。
    • requestIsCacheEquivalent:判断两个 request 是否相同,如果相同的话可以使用缓存数据,通常只需要调用父类的实现。
    • startLoading、stopLoading:这两个方法开始和取消 request,并且需要在这里按照上面提到的 setProperty 标识 request 是否已经请求过。如果要实现代理就可以在 startLoading 中对代理的参数进行设置。

接下来,就可以对request 过程进行定制,做一些业务逻辑。如果使用NSURLSession,则使用NSURLSessionDataDelegate;如果使用 NSURLConnect,则使用NSURLConnectionDataDelegate。这两个 Delegate 中的方法是一一对应的。

使用 NSURLSession 实现代理

class ProxyProtocol: NSURLProtocol, NSURLSessionDataDelegate, NSURLSessionTaskDelegate {


    private var dataTask:NSURLSessionDataTask?
    private var urlResponse:NSURLResponse?
    private var receivedData:NSMutableData?
    private static var requestCount:Int=0

    class var CustomKey:String {
        return "myCustomKey"
    }

    // MARK: NSURLProtocol

    override class func canInitWithRequest(request: NSURLRequest) -> Bool {
        print("\(requestCount++):\(request.URL)")
        if (NSURLProtocol.propertyForKey(ProxyProtocol.CustomKey, inRequest: request) != nil) {
            return false
        }

        return true
    }

    override class func canonicalRequestForRequest(request: NSURLRequest) -> NSURLRequest {
        return request
    }

    override func startLoading() {
            var proxyDict:Dictionary<String,AnyObject> = Dictionary()
            proxyDict["HTTPEnable"] = 1
            proxyDict[kCFStreamPropertyHTTPProxyHost as String] = PROXY_SERVERHOST
            proxyDict[kCFStreamPropertyHTTPProxyPort as String] = PROXY_SERVERPORT

            proxyDict["HTTPSEnable"] = 1
            proxyDict[kCFStreamPropertyHTTPSProxyHost as String] = PROXY_SERVERHOST
            proxyDict[kCFStreamPropertyHTTPSProxyPort as String] = PROXY_SERVERPORT
            //proxyDict[kCFProxyTypeHTTPS as String] = kCFProxyTypeHTTPS


            let newRequest = self.request.mutableCopy() as! NSMutableURLRequest

            NSURLProtocol.setProperty("true", forKey: ProxyProtocol.CustomKey, inRequest: newRequest)

            let defaultConfigObj = NSURLSessionConfiguration.defaultSessionConfiguration()
            defaultConfigObj.connectionProxyDictionary = proxyDict
            let defaultSession = NSURLSession(configuration: defaultConfigObj, delegate: self, delegateQueue: nil)

            self.dataTask = defaultSession.dataTaskWithRequest(newRequest)
            self.dataTask!.resume()

    }

    override func stopLoading() {
        self.dataTask?.cancel()
        self.dataTask       = nil
        self.receivedData   = nil
        self.urlResponse    = nil
    }

    // MARK: NSURLSessionDataDelegate

    func URLSession(session: NSURLSession, dataTask: NSURLSessionDataTask,
        didReceiveResponse response: NSURLResponse,
        completionHandler: (NSURLSessionResponseDisposition) -> Void) {

            self.client?.URLProtocol(self, didReceiveResponse: response, cacheStoragePolicy: .NotAllowed)
            self.urlResponse = response
            self.receivedData = NSMutableData()

            completionHandler(.Allow)
    }

    func URLSession(session: NSURLSession, dataTask: NSURLSessionDataTask, didReceiveData data: NSData) {
        self.client?.URLProtocol(self, didLoadData: data)

        self.receivedData?.appendData(data)
    }

    // MARK: NSURLSessionTaskDelegate

    func URLSession(session: NSURLSession, task: NSURLSessionTask, didCompleteWithError error: NSError?) {

        if error != nil && error!.code != NSURLErrorCancelled {
            print("!!!!error:\(error)")
            self.client?.URLProtocol(self, didFailWithError: error!)
        } else {
            //saveCachedResponse()
            self.client?.URLProtocolDidFinishLoading(self)
        }
    }

}

按照苹果的推荐使用 NSURLSession 而不是 NSURLConnect,如果要使用 NSURLConnect,可以使用第三方的ASIHTTPRequest

遇到的问题

使用 NSURLSession 进行https请求代理不起作用

参考How to programmatically add a proxy to an NSURLSession ,发现原因是只设置了 http 的代理参数,没有设置 https的

            var proxyDict:Dictionary<String,AnyObject> = Dictionary()
            proxyDict["HTTPEnable"] = 1
            proxyDict[kCFStreamPropertyHTTPProxyHost as String] = PROXY_SERVERHOST
            proxyDict[kCFStreamPropertyHTTPProxyPort as String] = PROXY_SERVERPORT

    // 还要再设置 https 的参数        
            proxyDict["HTTPSEnable"] = 1
            proxyDict[kCFStreamPropertyHTTPSProxyHost as String] = PROXY_SERVERHOST
            proxyDict[kCFStreamPropertyHTTPSProxyPort as String] = PROXY_SERVERPORT

在Squid 中,https 请求的日志如下:

- - [13/Apr/2016:18:49:29 +0800] "CONNECT www.taobao.com:443 HTTP/1.1" 200 5456 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 9_2_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Mobile/
13D15" TCP_TUNNEL:HIER_DIRECT

它并不像 http 那样会有GET 资源的日志,比如

- - [13/Apr/2016:16:21:16 +0800] "GET http://www.suning.com/favicon.ico HTTP/1.1" 200 833 "http://shopping.suning.com/project/cart/cart2.html" "Mozilla/5.0 (Linux; Android 5.1.1; OPPO R9 Pl
ustm A Build/LMY47V; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/43.0.2357.121 Mobile Safari/537.36" TCP_CLIENT_REFRESH_MISS:HIER_DIRECT

connect 协议就像客户端与服务端之间的一个代理人,在发送 https 之前,通过 connect 与代理人建立连接,然后代理人进行转发,因为 https 是加密的,所以代理人只是转发,并不知道传输的具体内容。
关于 connect 的解释参考
《HTTP代理协议 HTTP/1.1的CONNECT方法》
《HTTP 代理原理及实现》

测试

$curl -x  123.45.67.89:1080  -o page.html http://bpsky.net

关于curl的更多用法:《shell神器curl用法笔记》

视频类资源的代理问题

现在很多视频网站的资源都使用 m3u8 格式,m3u8的解析网上有很多说明,大概原理就是将视频文件分割成更多小文件(.ts),然后在播放的时候逐个按顺序去播放这些.ts 文件。

在对这类视频类网站做代理的时候发现,squid日志中的视频资源大小比实际要少很多,也就是说一个视频资源的大部分并没有走到代理上面去。比如在Android4.x 和 iOS7以上,都会出现这个问题。部分网站的音频也存在这个问题。

在 Android5.x 及以上测试时发现是正常的,视频资源会都走代理。iOS 中在 xCode 的模拟器中完全没有问题,但是在真机上也会出现上面提到的大部分资源不会走代理的问题。

解决的办法,目前采用的是将 m3u8文件及分解的.ts 文件通过代理下载到本地,这样能保证一定会走代理,然后在本地将 m3u8文件和.ts 文件放在一起进行本地播放。
参考《 利用NSURLProtocol和本地代理实现在线视频边播放边缓存》

2016-04-13 23:52653