iOS 設定代理(Proxy)方案總結

Nemocdz發表於2019-02-07

最近因為專案需要,需要在開啟某個網址時設定HTTP代理。所以做了相關的技術方案調研,並總結下來。

在WebView設定Proxy的方式,就是對請求進行攔截並重新處理。還有一種全域性的實現方案,使用iOS9以後才有的NetworkExtension,但是這種方案會在使用者看來像是個微皮恩的App,不友好且太重了。

使用URLProtocol

1. 自定義URLProtocol

URLProtocol是攔截可以攔截網路請求的抽象類,實際使用時需要自定義其子類使用。

使用時,需要將子類URLProtocol的型別進行註冊。

static var isRegistered = false

class func start() {
	guard isRegistered == false else {
        return
     }
     URLProtocol.registerClass(self)
     isRegistered = true
 }
複製程式碼

核心是重寫幾個方法

/// 這個方法用來對請求進行處理,比如加上頭,不處理直接返回就行
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
      return request
}


static let customKey = "HttpProxyProtocolKey"

/// 判斷是否需要處理,對處理過請求打上唯一識別符號customKey的屬性,避免迴圈處理
override class func canInit(with request: URLRequest) -> Bool {
    guard let url = request.url else {
    	return false
    }
        
    guard let scheme = url.scheme?.lowercased() else {
         return false
    }
        
    guard scheme == "http" || scheme == "https" else {
          return false
    }
        
    if let _ = URLProtocol.property(forKey:customKey, in: request) {
         return false
    }
        
    return true
}

private var dataTask:URLSessionDataTask?

/// 核心是在startLoading中對請求進行重發,將Proxy資訊設定進URLSessionConfigration,並生成URLSession傳送請求
override func startLoading() {
    // 1. 為請求打上標記
    let newRequest = request as! NSMutableURLRequest
    URLProtocol.setProperty(true, forKey: type(of: self).customKey, in: newRequest)
        
    // 2. 設定Proxy配置
    let proxy_server = "YourProxyServer" // proxy server
    let proxy_port = 1234 // your port
    let hostKey = kCFNetworkProxiesHTTPProxy as String
    let portKey = kCFNetworkProxiesHTTPPort as String
    let proxyDict:[String:Any] = [kCFNetworkProxiesHTTPEnable as String: true, hostKey:proxy_server, portKey: proxy_port]
    let config = URLSessionConfiguration.ephemeral
    config.connectionProxyDictionary = proxyDict
     
   	 // 3. 用配置生成URLSession
     let defaultSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        
     // 4. 發起請求
     dataTask = defaultSession.dataTask(with:newRequest as URLRequest)
     dataTask!.resume()
}

/// 在stopLoading中cancel任務
override func stopLoading() {
      dataTask?.cancel()
}
複製程式碼

同時,上層呼叫者對攔截應該是無感知的。當這個網路請求被 URLProtocol 攔截,需要保證上層實現的網路相關回撥或block都能被呼叫。解決這個問題,蘋果定義了 NSURLProtocolClient 協議,協議方法覆蓋了網路請求完整的生命週期。在攔截之後重發的請求的各階段適時,完整地呼叫了協議中的方法,上層呼叫者的回撥或者 block 都會在正確的時機被執行。

extension HttpProxyProtocol: URLSessionDataDelegate{
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
                    didReceive response: URLResponse,
                    completionHandler: (URLSession.ResponseDisposition) -> Void) {
        
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        client?.urlProtocol(self, didLoad: data)
    }
}

extension HttpProxyProtocol: URLSessionTaskDelegate{
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if error != nil && error!._code != NSURLErrorCancelled {
            client?.urlProtocol(self, didFailWithError: error!)
        } else {
            client?.urlProtocolDidFinishLoading(self)
        }
    }
}
複製程式碼

需要特別注意的是,在 UIWebView 中使用會出現 JS、CSS、Image 重定向後無法訪問的問題。解決方法是在重定向方法中新增如下程式碼:

func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
        let newRequest = request as! NSMutableURLRequest
        type(of: self).removeProperty(forKey: type(of: self).customKey, in: newRequest)
        client?.urlProtocol(self, wasRedirectedTo: newRequest as URLRequest, redirectResponse: response)
        dataTask?.cancel()
        let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
        client?.urlProtocol(self, didFailWithError: error)
    }
複製程式碼

到此完整的URLProtocol定義完了。但是裡面有一點不好的地方是,每次傳送一個請求時就會新建一個URLSession,非常低效。蘋果也不推薦這種做法,而且某些情況下由於請求未完全傳送完還有可能造成記憶體洩露等問題。因此,我們需要共享一個Session,並僅在代理的Host或者Port發生改變時,才重新生成新的例項。筆者模仿iOS上網路框架Alamofire的做法,簡單寫了一個SessionManager進行管理。

2. 自定義URLSessionManager

主要分兩個類

  • ProxySessionManager,負責持有URLSession,對Session是否需要重新生成或者共享進行管理
  • ProxySessionDelegate,和URLSession一一對應。將URLSession的Delegate分發到對應的Task的Delegate,維護Task的對應Delegate

ProxySessionManager主要就是對外提供介面,對外層隱藏細節,將Delegate和Task生成配置好。

class ProxySessionManager: NSObject {
    var host: String?
    var port: Int?
    
    static let shared = ProxySessionManager()
    private override init() {}
    
    private var currentSession: URLSession?
    private var sessionDelegate: ProxySessionDelegate?
    
    func dataTask(with request: URLRequest, delegate: URLSessionDelegate) -> URLSessionDataTask {
        /// 判斷是否需要生成新的Session
        if let currentSession = currentSession, currentSession.isProxyConfig(host, port){
            
        } else {
            let config = URLSessionConfiguration.proxyConfig(host, port)
            sessionDelegate = ProxySessionDelegate()
            currentSession = URLSession(configuration: config, delegate: self.sessionDelegate, delegateQueue: nil)
        }
        
        let dataTask = currentSession!.dataTask(with: request)
        /// 儲存Task對應的Delegate
        sessionDelegate?[dataTask] = delegate
        return dataTask
    }
}
複製程式碼

而對Session的connectionProxyDictionary的設定的Key,沒有HTTPS的。檢視CFNetwork裡的常量定義,發現有kCFNetworkProxiesHTTPSEnable,但是在iOS上被標記為不可用,只可以在MacOS上使用,那麼我們其實可以直接取這個常量的值進行設定,下面總結了相關的常量裡的對應的值。

Raw值 CFNetwork/CFProxySupport.h CFNetwork/CFHTTPStream.h CFNetwork/CFSocketStream.h
"HTTPEnable" kCFNetworkProxiesHTTPEnable N/A
"HTTPProxy" kCFNetworkProxiesHTTPProxy kCFStreamPropertyHTTPProxyHost
"HTTPPort" kCFNetworkProxiesHTTPPort kCFStreamPropertyHTTPProxyPort
"HTTPSEnable" kCFNetworkProxiesHTTPSEnable N/A
"HTTPSProxy" kCFNetworkProxiesHTTPSProxy kCFStreamPropertyHTTPSProxyHost
"HTTPSPort" kCFNetworkProxiesHTTPSPort kCFStreamPropertyHTTPSProxyPort
"SOCKSEnable" kCFNetworkProxiesSOCKSEnable N/A
"SOCKSProxy" kCFNetworkProxiesSOCKSProxy kCFStreamPropertySOCKSProxyHost
"SOCKSPort" kCFNetworkProxiesSOCKSPort kCFStreamPropertySOCKSProxyPort

這樣我們就可以擴充兩個Extension方法了。

fileprivate let httpProxyKey = kCFNetworkProxiesHTTPEnable as String
fileprivate let httpHostKey = kCFNetworkProxiesHTTPProxy as String
fileprivate let httpPortKey = kCFNetworkProxiesHTTPPort as String
fileprivate let httpsProxyKey = "HTTPSEnable"
fileprivate let httpsHostKey = "HTTPSProxy"
fileprivate let httpsPortKey = "HTTPSPort"

extension URLSessionConfiguration{
    class func proxyConfig(_ host: String?, _ port: Int?) -> URLSessionConfiguration{
        let config = URLSessionConfiguration.ephemeral
        if let host = host, let port = port {
            let proxyDict:[String:Any] = [httpProxyKey: true,
                                          httpHostKey: host,
                                          httpPortKey: port,
                                          httpsProxyKey: true,
                                          httpsHostKey: host,
                                          httpsPortKey: port]
            config.connectionProxyDictionary = proxyDict
        }
        return config
    }
}

extension URLSession{
    func isProxyConfig(_ aHost: String?, _ aPort: Int?) -> Bool{
        if self.configuration.connectionProxyDictionary == nil && aHost == nil && aPort == nil {
            return true
        } else {
            guard let proxyDic = self.configuration.connectionProxyDictionary,
                let aHost = aHost,
                let aPort = aPort,
                let host = proxyDic[httpHostKey] as? String,
                let port = proxyDic[httpPortKey] as? Int else {
                    return false
            }
            
            if aHost == host, aPort == port{
                return true
            } else {
                return false
            }
            
        }
    }
}
複製程式碼

ProxySessionDelegate,主要做的是將Delegate分發到每個Task的Delegate,並儲存TaskIdentifer對應的Delegate,內部實際使用Key-Value結構的字典儲存,在設定和取值時加鎖,避免回撥錯誤。

fileprivate class ProxySessionDelegate: NSObject {
    private let lock = NSLock()
    var taskDelegates = [Int: URLSessionDelegate]()
    /// 借鑑Alamofire,擴充套件下標方法
    subscript(task: URLSessionTask) -> URLSessionDelegate? {
        get {
            lock.lock()
            defer {
                lock.unlock()
            }
            return taskDelegates[task.taskIdentifier]
        }
        set {
            lock.lock()
            defer {
                lock.unlock()
            }
            taskDelegates[task.taskIdentifier] = newValue
        }
    }
}

/// 對回撥進行分發
extension ProxySessionDelegate: URLSessionDataDelegate{
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
                    didReceive response: URLResponse,
                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        if let delegate = self[dataTask] as? URLSessionDataDelegate{
            delegate.urlSession!(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
        } else {
            completionHandler(.cancel)
        }
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        if let delegate = self[dataTask] as? URLSessionDataDelegate{
            delegate.urlSession!(session, dataTask: dataTask, didReceive: data)
        }
    }
}

extension ProxySessionDelegate: URLSessionTaskDelegate{
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let delegate = self[task] as? URLSessionTaskDelegate{
            delegate.urlSession!(session, task: task, didCompleteWithError: error)
        }
        self[task] = nil
    }
}
複製程式碼

這樣,只要呼叫ProxySessionManager或者直接使用Alamofire進行網路請求,就可以做到URLSession儘量少建立了。蘋果官方也有一個SampleProject講自定義URLProtocol,做法也是用類似用一個單例進行管理。

3. WKWebView的特別處理

和UIWebView不一樣,WKWebView中的http&https的Scheme預設不走URLPrococol。需要讓WKWebView支援NSURLProtocol的話,需要呼叫蘋果私用方法,讓WKWebview放行http&https的Scheme。

通過Webkit的原始碼發現,需要呼叫的私有方法如下:

[WKBrowsingContextController registerSchemeForCustomProtocol:"http"];
[WKBrowsingContextController registerSchemeForCustomProtocol:"https"];
複製程式碼

而使用的話需要使用反射進行呼叫

Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    // 把 http 和 https 請求交給 NSURLProtocol 處理
    [(id)cls performSelector:sel withObject:@"http"];
    [(id)cls performSelector:sel withObject:@"https"];
}

複製程式碼

其中需要繞過稽核檢查主要是類名WKBrowsingContextController,除了可以對字串進行加密或者拆分外,由於在iOS8.4以上,可使用WKWebview的私有方法browsingContextController取到該型別的例項。

Class cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];

複製程式碼

然後使用上就能大大降低風險了,swift上寫法如下。

let sel = Selector(("registerSchemeForCustomProtocol:"))
let vc = WKWebView().value(forKey: "browsingContextController") as AnyObject
let cls = type(of: vc) as AnyObject

let _ = cls.perform(sel, with: "http")
let _ = cls.perform(sel, with: "https")

複製程式碼

優點:

  • 攔截能力強大
  • 同時支援UIWebView&WKWebView
  • 對系統無要求

缺點:

  • 對WKWebView支援不夠友好
    • 稽核有一定風險
    • iOS8.0-8.3需要額外開發量(私有型別&方法的混淆)
    • Post 請求 body 資料被清空
    • 對ATS支援不足

使用WKWebURLSchemeHandler

iOS11以上,蘋果為WKWebView增加了WKURLSchemeHandler協議,可以為自定義的Scheme增加遵循WKURLSchemeHandler協議的處理。其中可以在start和stop的時機增加自己的處理。

遵循協議中的兩個方法

func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    	let proxy_server = "YourProxyServer" // proxy server
        let proxy_port = 1234 // your port
        let hostKey = kCFNetworkProxiesHTTPProxy as String
        let portKey = kCFNetworkProxiesHTTPPort as String
        let proxyDict:[String:Any] = [kCFNetworkProxiesHTTPEnable as String: true, hostKey:proxy_server, portKey: proxy_port]
        let config = URLSessionConfiguration.ephemeral
        config.connectionProxyDictionary = proxyDict
    
        let defaultSession = URLSession(configuration: config)
        
        dataTask = defaultSession.dataTask(with: urlSchemeTask.request, completionHandler: {[weak urlSchemeTask] (data, response, error) in
            /// 回撥時urlSchemeTask容易崩潰,可能蘋果沒有考慮會在handler裡做非同步操作,這裡試了一下weak寫法,崩潰不出現了,不確定是否為完全解決方案                                                                             
            guard let urlSchemeTask = urlSchemeTask else {
                return
            }
            
            if let error = error {
                urlSchemeTask.didFailWithError(error)
            } else {
                if let response = response {
                    urlSchemeTask.didReceive(response)
                }
                
                if let data = data {
                    urlSchemeTask.didReceive(data)
                }
                urlSchemeTask.didFinish()
            }
        })
        dataTask?.resume()
}

複製程式碼

當然這裡URLSession的處理和URLProtocol一樣,可以進行復用處理。

然後生成WKWebviewConfiguration,並使用官方API將handler設定進去。

let config = WKWebViewConfiguration()
config.setURLSchemeHandler(HttpProxyHandler(), forURLScheme: "http")//丟擲異常

複製程式碼

但因為蘋果的setURLSchemeHandler只能對自定義的Scheme進行設定,所以像http和https這種scheme,已經預設處理了,不能呼叫這個API,需要用KVC取值進行設定。

extension WKWebViewConfiguration{
    class func proxyConifg() -> WKWebViewConfiguration{
        let config = WKWebViewConfiguration()
        let handler = HttpProxyHandler()
        /// 先設定
        config.setURLSchemeHandler(handler, forURLScheme: "dummy")
        let handlers = config.value(forKey: "_urlSchemeHandlers") as! NSMutableDictionary
        handlers["http"] = handler
        handlers["https"] = handler
        return config
    }
}

複製程式碼

然後給WKWebview設定就能使用了。

優點:

  • 蘋果官方方法
  • 無稽核風險

缺點:

  • 僅支援iOS11以上
  • 官方不支援非自定義Scheme,非正規設定方法可能出現其他問題

使用NetworkExtension

使用NetworkExtension,需要開發者額外申請許可權(證書)。

可以建立全域性VPN,影響全域性流量,可以獲取全域性Wifi列表,抓包,等和網路相關的功能。

其中可以使用第三方庫NEKit,進行開發,已經處理了大部分坑和進行封裝。

優點:

  • 功能強大
  • 使用原生功能,無稽核風險

缺點:

  • 許可權申請流程複雜
  • 僅支援iOS9以上(iOS8上僅支援系統自帶的 IPSec 和 IKEv2 協議的 VPN)
  • 原生介面實現複雜,第三方庫NEKit坑不知道有多少

最後

總結了相關程式碼在Demo裡,可以直接使用HttpProxyProtocol,HttpProxyHandler,HttpProxySessionManager。

Reference

相關文章