9

iOS中的网络调试

 3 years ago
source link: https://zhuanlan.zhihu.com/p/147058007
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

iOS中的网络调试

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

App外调试

早先很多的网络调试都是通过App外的调试来进行的,这种的好处是可以完全不影响App内的任何逻辑,并且也不用去考虑对网络层可能造成的影响。 - Charles 确实是网络调试的首选,他支持模拟器、真机调试,并且附带有map remotemap local的功能,可以说是iOS开发中的主流调试工具,但是缺点也很明显,使用时必须保证iPhone和Mac在同一Wi-Fi下,并且使用的时候还需要设置Wi-Fi对应的Proxy,而一旦电脑上的Charles关掉,手机就会连不上网络。在办公室可谓神器,可一旦离开了办公室,就没法使用了。 - Surge 也是近几年的一款不错的网络调试工具,iOS版设置好证书后,就可以直接看到所有app的请求,而Mac版提供的remote dashboard可以增加网络请求查看的效率,新的TF版本还增加了rewrite以及script的功能,基本能达到Charles的大部分常用需求,并且可以独立于Mac来进行。不过这种方式也有一定的问题,那就是每次查看网络请求都需要切换App,并且请求是所有应用发出的,而很难只看一个应用的请求(其实也是Filter做的不够细导致的问题)。

App内调试

目前GitHub上已经有非常多的网络调试框架,提供了简单的应用内收集网络请求的功能。 - GodEye 提供了一套完整的网络请求监控的功能,然而后面一直没有更新,并且会对应用内发出的请求有所影响(这点会在下文具体讲解),仅能作为调试使用,而不适合在线上继续调试。 - Bagel 这个的实现基本不会对应用内的请求有影响,不过这个必须要有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 为首的就是这种方法,只不过它内部发送请求用的是老的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方法,我们就负责将发出去的请求停止掉
  1. 同时,在自定义的URLProtocol中实现上面说的新请求的回调,在回调中通过self.client.urlProtocol的一系列方法,将回调传回至原来的delegate
  2. 至此,我们完成了发送、接收等一系列操作,并且完美的将回调转发回了原来的代理方,剩下的就是我们在回调中收集网络请求的各种信息就好了 这个方法看起来非常完美,通过图来展示如下(上面的是原有的流程,下面的是新的流程)

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

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

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

要解决上面的问题,我们需要引入URLSession复用的办法,也就是需要有一个管理者,去管理所有的URLSession,并且要分发他们各自网络请求的回调,调回对应的URLProtocol实例。在一次阅读苹果官方的URLProtocol例子中,我发现这个例子中的一些设计理念可以帮助我们解决这个问题,它里面有一个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标准库中对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源码中的逻辑

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

从根源解决问题

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

这也告诉我们,我们要找一个不会影响原有网络请求,而又想要拿到所有的网络请求回调的方法。在使用RxSwift的过程中,我了解到了一个很有意思的概念,叫DelegateProxy,它可以生成一个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的时候,遇到了一个问题,这一套方案会导致app无法连接到RN,无法加载对应的页面,在阅读了ReactNative的源码之后,我们找到了原因,在RN中的一个类RCTMultipartDataTask中,它在声明中说明了自己遵循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啦。

即刻App现可在各大应用市场更新下载,欢迎回家!感谢大家的耐心等待,希望大家把好消息扩散给认识的即友,让更多人尽快重回即刻镇。

即刻App​m.okjike.com


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK