iOS中的网络调试

Python实战社群

Java实战社群

长按识别下方二维码,按需求添加

扫码关注添加客服

进Python社群▲

扫码关注添加客服

进Java社群

作者丨Kael

来源丨即刻技术团队

https://mp.weixin.qq.com/s/K0_3efxXKJM3fU-Icyh7Hg

开发 iOS 的过程中,有一件非常令人头疼的事,那就是网络请求的调试,无论是后端接口的问题,或是参数结构问题,你总需要一个网络调试的工具来简化调试步骤。

现状

App 外调试

早先很多的网络调试都是通过 App 外的调试来进行的,这种的好处是可以完全不影响 App 内的任何逻辑,并且也不用去考虑对网络层可能造成的影响。

  • Charles[1] 确实是网络调试的首选,他支持模拟器、真机调试,并且附带有map remotemap local的功能,可以说是 iOS 开发中的主流调试工具,但是缺点也很明显,使用时必须保证 iPhone 和 Mac 在同一 Wi-Fi 下,并且使用的时候还需要设置 Wi-Fi 对应的 Proxy,而一旦电脑上的 Charles 关掉,手机就会连不上网络。在办公室可谓神器,可一旦离开了办公室,就没法使用了。

  • Surge[2] 也是近几年的一款不错的网络调试工具,iOS 版设置好证书后,就可以直接看到所有 app 的请求,而 Mac 版提供的 remote dashboard 可以增加网络请求查看的效率,新的 TF 版本还增加了rewrite以及script的功能,基本能达到 Charles 的大部分常用需求,并且可以独立于 Mac 来进行。不过这种方式也有一定的问题,那就是每次查看网络请求都需要切换 App,并且请求是所有应用发出的,而很难只看一个应用的请求(其实也是 Filter 做的不够细导致的问题)。

App 内调试

目前 GitHub 上已经有非常多的网络调试框架,提供了简单的应用内收集网络请求的功能。

  • GodEye[3] 提供了一套完整的网络请求监控的功能,然而后面一直没有更新,并且会对应用内发出的请求有所影响(这点会在下文具体讲解),仅能作为调试使用,而不适合在线上继续调试。

  • Bagel[4] 这个的实现基本不会对应用内的请求有影响,不过这个必须要有 Mac 的应用才可以使用,而且因为实现的原因,如果应用内使用了自定义的URLProtocol,会使得网络请求的抓取重复。以上的两大类调试方式,各有优劣,App 外调试往往因为并不针对某个应用,导致查询的体验非常一般,现在 Github 上的大部分网络调试框架也基本都和这两个的原理类似,而这些调试工具的实现,由于多是用于 Debug 环境,对很多网络监控的要求也就非常的低,比如GodEye这种,就明显会影响到现有的网络请求,虽然影响很小,在调试环境下也能够接受,基本能够完成目的,但是一旦我们希望在线上(包括 testflight)环境下进行调试,也就会让所有网络请求都有受到影响的风险(具体的风险后面会讲到)。

网络调试的原理

为了解决上面的问题,我们决定从现有的 App 内调试方案入手,着手优化一些细节的部分,来达到即使在线上进行调试也不影响网络请求的目的。下面我先介绍一下目前主流的几个网络调试方案的原理。

URL Loading System 中的 URL Protocol

很多人在入门 iOS 的时候,都会通过Alamofire等第三方网络请求库来发送网络请求,但大部分的网络请求库都是基于标准库中URLConnection或者URLSession的封装,其中URLConnection是旧的封装,而URLSession则是较新的也是现在被推荐使用的封装,它们本身对 URL 的加载、响应等一系列的事件进行了处理,其中就包含了所谓的传输协议的修改,标准库中提供了基础的URL传输协议,包括 http、https、ftp 等,当然,如果我们有自己的协议要处理,标准库也是提供了对应的方式的。

在标准库中,有一个URLProtocol的类,从名字来看我们就知道它是处理URL加载中的协议的,那么定义了对应的类,也要有办法让标准库来使用自定义的协议,我们可以通过改变一个URLProtocol的数组来达到目的。

  • URLConnection中,会有一个URLProtocol的类变量代表这个URLProtocol的数组,我们可以通过registerClass的方法来在这个数组中插入我们自己的协议

  • URLSession中,则是由 configuration 来处理,我们可以通过在 configuration 中直接修改这个数组来插入我们自己的协议 在标准库中,每当有网络请求发出的时候,系统都会从对应的数组中依次询问每一个URLProtocol的类是否能处理当前请求

open class func canInit(with request: URLRequest) -> Bool

当遇到了一个能返回 true 的类,那么系统就会调用对应的类的初始化方法,初始化出当前类的一个实例,而剩下的关于请求发送、接收以及回调的事情就交由这个新的实例来处理,而系统提供的 http、https 这些基本的协议,都是由默认存在于URLProtocol数组中的类来实现的,所以如果我们希望自己处理,就需要将自己的协议插入到这个数组的前面,来保证优先被询问到是否能处理这个网络请求。

因此我们可以通过继承URLProtocol,并实现相关的方法,作为中间层来处理网络的发送、接收后的各种事件,URLProtocol有能力改变URL加载过程中的每一个环节,但是又要去调用原始的响应方法,这样的设计让协议的处理不会影响网络调用以及网络响应的调用方式,让网络请求发送方无感知的情况下来做中间的处理。

正是这个类似“隐身”的特点,让URLProtocol成为了很多网络调试框架使用的首选,这些框架通过 hookURLSession或者URLSessionConfiguration的初始化方法,在URLSession中的 configuration 中插入自定义的网络调试Protocol,那么所有对应的网络请求都会通过这个Protocol来发送,而在这个 Protocol 中将请求重新通过正常的URLSession发送,然后接收到网络请求的回调,再回调回原来的网络请求的 delegate,就可以在不影响原有请求的情况下,拿到请求的所有回调,并在这其中进行记录。

以上面提到的GodEye[5] 为首的就是这种方法,只不过它内部发送请求用的是老的URLConnection而不是URLSession,然而这倒是没有什么影响,这类的实现起来也是基本差不多,下面是主要的几个步骤

  1. 利用 Objc 的运行时来 hook 掉URLSession.init(configuration:delegate:delegateQueue:)方法,然后在调用原初始化方法之前,在URLSessionConfiguration中插入我们自定义的URLProtocol,同时调用URLProtocol下的类方法registerClass来注册自定义的类。

  2. 在自定义的URLProtocol子类中实现

  • canInit(with:)方法,在里面判断这个网络请求是否需要监控,如果不需要可以直接放行

  • canonicalRequest(for:)方法中,我们通常会对原有的请求进行一些处理,例如加上一个 flag 将请求标识为已经被处理过了

  • startLoading()方法中,我们需要将对应的请求发送出去,通常情况下我们会用一个新的URLSession将请求再次发送,并且将新的 delegate 设置为自己,这样新的请求的回调就会由当前的URLProtocol处理

  • stopLoading方法,我们就负责将发出去的请求停止掉

  • 同时,在自定义的URLProtocol中实现上面说的新请求的回调,在回调中通过self.client.urlProtocol的一系列方法,将回调传回至原来的 delegate

  • 至此,我们完成了发送、接收等一系列操作,并且完美的将回调转发回了原来的代理方,剩下的就是我们在回调中收集网络请求的各种信息就好了 这个方法看起来非常完美,通过图来展示如下(上面的是原有的流程,下面的是新的流程)

  • 很多 app 的网络监控也是到此为止,然而这些 app 通常是只在调试模式下才打开调试,因为不会有很大的问题,然而我们没法要求所有的后端开发都安装所谓的调试版本,如果我们希望在线上(包括 testflight)情况下,也能进行调试,这套方案的一些小问题就会显得很严重了

    • 首先,正常情况下一个 app 可能也就一两个URLSession的实例,现在却是发一个请求就会有一个新的URLSession的实例,这个本身在性能上会有一定的潜在风险,然而这不是因为大家不想复用所谓的URLSession,而是正如我们上面解释的,系统会对每一个请求都初始化一个URLProtocol的实例来处理,而每个实例都要处理各自的回调,而且在URLProtocol中无法拿到原始的URLSession,所以大家也都不愿意花时间在URLSession上,毕竟很多 app 可能也只有在调试的时候才会开启这个功能

    • 其次,在URLProtocol中,我们每次初始化的新的URLSession都是用的默认的 configuration,包括超时、缓存等设置都和原来的URLSession不同,这会导致一些表现不符合预期

    这两点对于线上环境都是无法接受的,因此这个方案基本不符合我们的要求。

    要解决上面的问题,我们需要引入URLSession复用的办法,也就是需要有一个管理者,去管理所有的URLSession,并且要分发他们各自网络请求的回调,调回对应的URLProtocol实例。在一次阅读苹果官方的URLProtocol例子[6]中,我发现这个例子中的一些设计理念可以帮助我们解决这个问题,它里面有一个Demux的概念。

    我们前面所说,每次发请求都新建一个URLSession的实例,原因是我们如果只在URLProtocol的情况下,很难通过上下文拿到对应的URLSession,同时也没有做任何的复用,因为原来的方法,我们让URLSession的 delegate 是当前的URLProtocol,而 session 的 delegate 是无法改变的,因此我们为了方便而这么做,而Demux其实就是做了非常多复杂的事情,将所谓的URLSession存下来复用,那么既然复用了 delegate,Demux的另一件事就是将聚合到一起的 delegate 再转发出去。

    Demux 会对每一个不同的原URLSession生成一个新的URLSession,demux 本身会记录当前请求的 id,然后统一处理回调,在回调的时候,再通过这个 id 来寻找对应的URLProtocol,来执行回调,这样就完美解决了上面的第一个问题,下图就展示了 Demux 的工作原理与流程。

    在实现上,当我们引入 Demux 的时候,我们也就没有多URLSession的问题了,但是实现上,我们想要拿到原有URLSession的 configuration,似乎没有那么容易,首先,URLProtocol本身就没办法拿到原有的URLSession,因为从接口的设计上,它只能拿到对应的URLRequest来处理原有的请求,而不能做更多的事了,眼看着这件事是没法解决了的时候,我通过苹果开源的 swift 标准库[7]中对URLProtocol的阅读,发现其实在请求时,其实标准库会调用initWithTask:cachedResponse:client:将对应的URLSessionTask传过去,只是是私有的属性,我们不能访问,然而这件事依然还是给了我启发,我们最后的解决办法是,通过继承URLProtocol写一个自己的BaseLoggerurlProtocol,然后 override 这个初始化方法,并且将传入的 task 保存下来,这样我们就能在URLProtocol中拿到这个请求对应的 task,然后再通过 task 拿到原有的URLSession,这样我们就可以完美的通过原来的 configuration 来初始化新的URLSession,解决上面的两个问题,而这也是目前即刻中使用的网络监控方式,以下是一些核心功能是实现代码。

    #pragma mark - Base Url Protocol
    @interface BaseLoggerURLProtocol : NSURLProtocol
    @property (atomic, copy, readwrite) NSURLSessionTask * originTask;
    @end
    
    @implementation BaseLoggerURLProtocol : NSURLProtocol
    - (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client {
        self.originTask = task;
        self = [super initWithRequest:task.originalRequest cachedResponse:cachedResponse client:client];
        return self;
    }
    @end
    
    // MARK: - Logger Demux
    class LoggerURLSessionDemux: NSObject {
        public private(set) var configuration: URLSessionConfiguration!
        public private(set) var session: URLSession!
    
        private var taskInfoByTaskId: [Int: TaskInfo] = [:]
        private var sessionDelegateQueue: OperationQueue = OperationQueue()
    
        public init(configuration: URLSessionConfiguration) {
            super.init()
    
            self.configuration = (configuration.copy() as! URLSessionConfiguration)
    
            sessionDelegateQueue.maxConcurrentOperationCount = 1
            sessionDelegateQueue.name = "com.jike...”
    
            self.session = URLSession(configuration: self.configuration, delegate: self, delegateQueue: self.sessionDelegateQueue)
            self.session.sessionDescription = self.identifier
        }
    }
    
    // MARK: - Demux Manager
    class LoggerURLDemuxManager {
        static let shared = LoggerURLDemuxManager()
    
        private var demuxBySessionHashValue: [Int: LoggerURLSessionDemux] = [:]
    
        func demux(for session: URLSession) -> LoggerURLSessionDemux {
    
            objc_sync_enter(self)
            let demux = demuxBySessionHashValue[session.hashValue]
            objc_sync_exit(self)
    
            if let demux = demux {
                return demux
            }
    
            let newDemux = LoggerURLSessionDemux(configuration: session.configuration)
            objc_sync_enter(self)
            demuxBySessionHashValue[session.hashValue] = newDemux
            objc_sync_exit(self)
            return newDemux
        }
    }
    
    // MARK: - Url Protocol Start Loading
    public class LoggerURLProtocol: BaseLoggerURLProtocol {
    override open func startLoading() {
            guard let originTask = originTask,
                let session = originTask.value(forKey: “session”) as? URLSession else {
                // We must get the session for using demux.
                client?.urlProtocol(self, didFailWithError: LoggerError.cantGetSessionFromTask)
                // Release the task
                self.originTask = nil
                return
            }
            // Release the task
            self.originTask = nil
    
            let demux = LoggerURLDemuxManager.shared.demux(for: session)
    
            var runLoopModes: [RunLoop.Mode] = [RunLoop.Mode.default]
            if let currentMode = RunLoop.current.currentMode,
                currentMode != RunLoop.Mode.default {
                runLoopModes.append(currentMode)
            }
    
            self.thread = Thread.current
            self.modes = runLoopModes.map { $0.rawValue }
    
            let recursiveRequest = (self.request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
            LoggerURLProtocol.setProperty(true, forKey: LoggerURLProtocol.kOurRecursiveRequestFlagProperty, in: recursiveRequest)
    
            self.customTask = demux.dataTask(with: recursiveRequest as URLRequest, delegate: self, modes: runLoopModes)
    
            self.customTask?.resume()
    
            let networkLog = NetworkLog(request: request)
            self.networkLog = networkLog
    
            RGLogger.networkLogCreationSubject.onNext(networkLog)
        }
    }
    

    新的方案

    上面所说的方案解决了传统方案的大部分问题,也在我们的 app 开发阶段进行了一些使用,然而我们却遇到了新的问题

    方案的问题

    我们上面提到的方案,根据传统的方案,进行了一些改进,避免了大部分传统方案的问题,但是有一个是我们始终无法避开的点,那么就是我们仍然重新发送了一个网络请求,而不是直接对原来的网络请求进行的监控,那么原来请求怎么发送,我们就得原封不动的发送出去,不然如果发送了错误的网络请求,那么就会导致收到错误的响应甚至无法收到响应,直接导致应用内的功能受损,这是这套方案从开始就会有的问题。

    正是因为这个问题,我们也遇到了这次网络监控最大的挑战,那就是不同寻常的请求,由于我们 app 内使用了Alamofire来进行网络请求,而它在上传MultipartFormData如果数据量过大,那么就会有一个机制是将 data 放在一个临时目录下,然后通过 Upload File 来进行上传数据,具体的机制可见Alamofire 源码中的逻辑[8]。

    而正是这个机制,导致我们 app 在上传图片的时候,使用了 Upload File 的方式上传,然而在我们的自定义的URLProtocol,只能直接拿到对应的URLRequest,然而 Upload File 的时候,我们没法简单的通过它获取到上传的数据,因而我们通过这个URLRequest发出的请求,只会带有空的 body,而不会上传真正的数据,导致图片上传失败,这也直接影响到了 app 的功能,而我们当时只能通过不监控上传图片请求的方式绕开这个问题。

    从根源解决问题

    从这个问题来看,无论是传统的方案还是我们改进后的方案,都一定会重新发送一次网络请求,只要我们没法完美的发出原来的请求,这个方案就是不够完美的,也就是说URLProtocol这条路也就没法继续走下去了。

    这也告诉我们,我们要找一个不会影响原有网络请求,而又想要拿到所有的网络请求回调的方法。在使用RxSwift[9]的过程中,我了解到了一个很有意思的概念,叫DelegateProxy[10],它可以生成一个 proxy,并将这个 proxy 设置为原来的 delegate,然后再通过转发,将所有调用过来的方法,全都转发到原有的 delegate 去,这样,既能作为一个中间层拿到所有的回调,又能不影响原有的处理,而在RxSwift下的RxCocoa中,已经将这一套技术用在了各种 UI 组件上了,我们平时调用的

    tableView.rx.contentOffset.subscribe(on: { event in })
    

    就是最简单的既不影响 tableView 的 delegate 又能拿到回调的例子。

    有了这个方向,我就准备实现一套URLSessionDelegateDelegateProxy,这样也能既不影响原来网络请求的发送,又能拿到所有回调,这样只需要将相应的回调转发回原有的 delegate 就好了。因此我实现了一个基本的 delegate proxy

    public final class URLSessionDelegateProxy: NSObject {
        private var networkLogs: [Int: JKLogger.NetworkLog] = [:]
        var _forwardTo: URLSessionDelegate?
    
        // MARK: - Initialize
        @objc public init(forwardToDelegate delegate: URLSessionDelegate) {
            self._forwardTo = delegate
            super.init()
        }
    
        // MARK: - Responder
        override public func responds(to aSelector: Selector!) -> Bool {
            return _forwardTo?.responds(to: aSelector) ?? false
        }
    }
    

    然后实现对应的URLSessionDelegate的方法,并且调用_forwardTo的对应方法,将回调回传回原有的回调,然后我们要做的,就是去 hook 掉URLSession的初始化方法sessionWithConfiguration:delegate:delegateQueue:,然后用传入的 delegate 初始化我们自己的DelegateProxy,然后将新的 delegate 设置回去就好了,具体回传的方式如下

    // MARK: - URLSessionDataDelegate
    extension JKLogger.URLSessionDelegateProxy: URLSessionDataDelegate {
        var _forwardToDataDelegate: URLSessionDataDelegate? { return _forwardTo as? URLSessionDataDelegate }
    
        public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
            _forwardToDataDelegate?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
        }
    }
    

    这样我们就能达到预期的效果了,同时也完美的避开了之前的方法中,需要我们重新发送请求的问题。

    一个小插曲

    上面的最新方案在使用了一段时间后,基本没有什么问题,然而我们在使用React Native[11]的时候,遇到了一个问题,这一套方案会导致 app 无法连接到 RN,无法加载对应的页面,在阅读了ReactNative的源码之后,我们找到了原因,在 RN 中的一个类RCTMultipartDataTask[12]中,它在声明中说明了自己遵循NSURLSessionDataDelegate协议,但是却在实现中实现了NSURLSessionStreamDelegate的方法,因此,在我们自己的DelegateProxy中的回调时,我们使用了

    _forwardTo as? URLSessionStreamDelegate // always failed
    

    的时候,是没法直接转换的,但是标准库中,对于回调的实现,还是基于 objc 通过运行时判断是否responds(to: Selector)的,因此标准库是能调用到RCTMultipartDataTask中对应的方法的,但是我们在 swift 代码中却没办法直接调用到这个方法,这也就造成了RCTMultipartDataTask 少收到了一个回调,不能工作也是正常。虽然ReactNative的这种写法很莫名其妙,而且这种写法也是非常不推荐的,然而我们既然是要做完美的网络监控方案,我们还是应该保持标准库的做法,通过 objc 的方式来进行回调,而不是通过简单的 swift 的as转换来进行调用。

    这件事听起来非常简单,毕竟对于一个拥有强大运行时的 objc 来说,动态调用一个方法还算是很简单,我们第一个想到的就是performSelector,然而这个方法最多只能传两个参数,而网络请求的回调可以有非常多的参数,在对比了NSInvocation等方案之后,我们最终还是选择了直接通过objc_msgSend方式来调用,只需要我们做好了判断,这个也能很安全的执行

    #import “_JKSessionDelegateProxy.h”
    #import <objc/runtime.h>
    #import <objc/message.h>
    #define JKMakeSureRespodsTo(object, sel) if (![object respondsToSelector:sel]) { return ;}
    
    @interface _JKSessionDelegateProxy () <NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate, NSURLSessionStreamDelegate, _JKNetworkLogUpdateDelegate>
    @end
    @implementation _JKSessionDelegateProxy
    - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didSendBodyData:(int64_t)bytesSent totalBytesSent:(int64_t)totalBytesSent totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
        JKMakeSureRespodsTo(self.forwardTo, _cmd);
        ((void (*)(id, SEL, NSURLSession*, NSURLSessionTask*, int64_t, int64_t, int64_t))objc_msgSend)(self.forwardTo, _cmd, session, task, bytesSent, totalBytesSent, totalBytesExpectedToSend);
    }
    @end
    

    上面的代码也展现了众多回调中的一个,只需要按照对应的方式完成所有的回调就好了。

    以上也是我经过多个框架的对比、以及多次实践得到的目前最好的解决办法,它既能解决传统方案的需要重新发送网络请求的致命弱点,也能在不影响任何网络请求的情况下,监控到所有的 app 内发出的网络请求,基本达到了我们对于无论调试还是线上环境,都能完美进行网络调试的工具的要求。

    在完成了上面所说的调试之后,我们只要在 app 内提供展示的 UI,就可以像下面这张图一样展示出来,在 app 内 debug 啦。

    外部链接

    [1]

    Charles: https://www.charlesproxy.com/

    [2]

    Surge: https://nssurge.com/

    [3]

    GodEye: https://github.com/zixun/GodEye

    [4]

    Bagel: https://github.com/yagiz/Bagel

    [5]

    GodEye: https://github.com/zixun/GodEye

    [6]

    例子: https://github.com/robovm/apple-ios-samples/tree/master/CustomHTTPProtocol

    [7]

    苹果开源的 swift 标准库: https://github.com/apple/swift-corelibs-foundation

    [8]

    Alamofire 源码中的逻辑: https://github.com/Alamofire/Alamofire/blob/ab47c9774e0f4e6f0809de86165726893defef49/Source/MultipartUpload.swift#L48

    [9]

    RxSwift: https://github.com/ReactiveX/RxSwift

    [10]

    DelegateProxy: https://github.com/ReactiveX/RxSwift/blob/c3c0cac3d4c176b04404e3574d62b51776277384/RxCocoa/Common/DelegateProxyType.swift

    [11]

    React Native: https://facebook.github.io/react-native/

    [12]

    RCTMultipartDataTask: https://github.com/facebook/react-native/blob/master/React/Base/RCTMultipartDataTask.m

    程序员专栏 扫码关注填加客服 长按识别下方二维码进群
    
    

    近期精彩内容推荐:  

     太牛了!35岁成阿里最年轻技术副总裁

     华中科技大学学霸201万顶薪签约华为

     写给小白看的线程和进程,高手勿入

     史上最全python字符串操作指南

    在看点这里好文分享给更多人↓↓

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值