1.背景
随着业务的发展,用户对于网络的依赖场景会越来越多,随之而来遇到的各种异常网络场景也越来越多。
1、为了保证网络的接口的持续健康、及时发现问题,为网络性能优化提供数据基础。
2、同时以报表的形式直观的去展现现有网络质量。
3、也为了更好的支撑后续业务的发展,就需要我们去搭建起相应的网络监控体系。
二、目标
提供一套完整的网络采集、监控和预警的可视化机制,用于线上接口可用性和健康度观察,并提供一系列排查问题的辅助信息。
通过仪表盘可视化呈现网络质量,包括但不限于以下能力:
- 总体请求成功率
- 过滤单个请求成功率、失败错误码
- 查看接口请求详情
- 接口访问平均耗时、时长分布
- 通过 traceId 实现从客户端请求的发起到最终具体服务器的处理返回全链路追踪。
三、总体方案
不管是 iOS 还是 Android ,两者最终要做的目标方案如上图。这里从下到上分别阐述每个部分的功能:
网络基础库:针对平台特性,这里有 iOS 的 AFnetworking、Alamofire 和 Android 的 okhttp3、okhttp4 ,其实现原理应该都差不多,都是针对底层的网络api进行进一步的封装,提高接口的易用性。
拦截器:主要是针对各个基础网络库进行接口拦截。根据平台不同,iOS 主要使用 NSProtocol + Hook,Android 使用 Aspect 。
网络封装库:一般开发过程都会针对基础的网络库再做二次封装,加入一些策略、缓存、安全校验等管理,使其更加贴合业务和快速接入使用。
插件/功能模块:以插件化的形式提供额外的网络功能
统计模块:将从业务开始调用到回调给业务方的各个环节的耗时及状态值,变成统计数据汇报到APM。
网络诊断模块:对关键业务进行诊断,包括dns解析、ping、弱网检测等,输出诊断报告并上报到APM。
重试模块:根据策略进行重试,包括 ip 重试、https 降级重试、原 url 重试等。
httpdns模块:提供 httpdns 能力,解决域名劫持问题。
上传模块:提供上传能力,包括断点续传、分片上传以及包体大小、上传耗时等信息监控。
下载模块:提供下载能力,包括大文件下载、断点续传以及包体大小、下载耗时等信息监控。
mock 模块:提供 mock 能力,主要用于测试和后台接口还没有准备好的情况下使用。
对外接口层:这一层直接对接上层业务。
四、具体实现
1)请求方式
iOS 常用的第三方网络 AFNetworking、Alamofire 基本都是基于 NSURLConnection 或者是 NSURLSession 的封装,其中 NSURLConnection 是比较旧的使用方式了,而 NSURLSession 则是比较新的也是比较被推荐的使用方式。
2)底层原理
在使用 NSURLConnection 和 NSURLSession 进行网络请求的时候,实际上走的都是更底层的 URL Loading System,URL Loading System 使用标准协议 https 或者自定义协议访问标识资源,本身支持 http,https,文件,ftp 和数据协议。
可以通过继承 NSURLProtocol 实现一个自定义的 Protocol,然后调用 registerClass:方法注册到 URL Loading System 中去,这样 NSURLConnection、NSURLSession 或者是 NSURLDownload 在使用 NSURLRequest 初始化一个连接的时候,URL Loading System 就会
将按照注册时的相反顺序询问每个注册的类,询问到第一个 +canInitWithRequest: 方法返回 YES 的时候则使用该类去处理请求。
- NSURConnection 中,直接调用 registerClass:方法注册我们自己的协议即可。
- NSURLSession 中,如果是通过 [NSURLSession sharedSession] 初始化创建网络请求,调用 registerClass:即可,如果是通过 configurantion 来初始化,则通过修改 configuration 的 protocolClasses 属性,把自定义类插入到该数组的前面,确保我们的自定义的协议能够优先处理到网络请求。
可以看到 OHHTTPStubs 开源库在注册子类的时候也是这样处理的
+ (void)setEnabled:(BOOL)enable forSessionConfiguration:(NSURLSessionConfiguration*)sessionConfig
{
// Runtime check to make sure the API is available on this version
if ([sessionConfig respondsToSelector:@selector(protocolClasses)]
&& [sessionConfig respondsToSelector:@selector(setProtocolClasses:)])
{
NSMutableArray * urlProtocolClasses = [NSMutableArray arrayWithArray:sessionConfig.protocolClasses];
Class protoCls = HTTPStubsProtocol.class;
if (enable && ![urlProtocolClasses containsObject:protoCls])
{
// 将自己的 NSURLProtocol 插入到 protocolClasses 的第一个,进行拦截
[urlProtocolClasses insertObject:protoCls atIndex:0];
}
else if (!enable && [urlProtocolClasses containsObject:protoCls])
{
// 拦截完成后移除
[urlProtocolClasses removeObject:protoCls];
}
sessionConfig.protocolClasses = urlProtocolClasses;
}
else
{
NSLog(@"[OHHTTPStubs] %@ is only available when running on iOS7+/OSX9+. "
@"Use conditions like 'if ([NSURLSessionConfiguration class])' to only call "
@"this method if the user is running iOS7+/OSX9+.", NSStringFromSelector(_cmd));
}
}
3)实现步骤
利用 Objc 运行时 hook 掉 NSURLSessionConfiguration 的 defaultSessionConfiguration 属性和 ephemeralSessionConfiguration 属性设置,然后修改 configuration 的 protocolClassess 属性,插入我们自定义的 Protocol
在自定义的 NSURLProtocol 之类中实现如下方法:
+ canInitWithRequest: 在这里判断当前网络请求是否需要监控,如果不需要直接 return NO 即可。
+ canonicalRequestForRequest: 生成一个新的 request 请求,同时标识该请求已经处理过,防止死循环。
- startLoading 将新的请求发送出去,设置对应的回调代理。
- stopLoading 停止网络请求。
3. 处理请求回调,实现需要进行处理的回调方法,处理完成后通过 self.client.urlProtocol 将回调方法传回至原来的 delegate。
4. 至此,我们就完成了发送、接收等一系列操作,并且完美的将回调转发回了原来的代理方,剩下的就是我们在回调中收集完了请求的各种信息就好。
流程图如下:
4)可能存在的问题及优化
流程并不复杂,从上图可以看到,使用了网络拦截之后的流程图比原本的多了一个 custom protocol(DLURLProtocol)和 custom session。custom potocol 用于拦截网络请求,custom session 用于发起新的请求。
这里可能会存在两个问题:
每个请求都会新创建一个 NSURLSession,对于网络请求这种很频繁的操作来说不是很友好;
新创建的 NSURLSession 如何确保超时、缓存、认证、cookies 等策略跟原始的 NSURLSession 保持一致,如果不一致是否会影响到既有的网络请求?
五、风险评估
针对可能存在的问题做相关梳理和验证~
关于第一点:每个请求都会创建一个 NSURLSession 这个很好解决,使用一个单例即可,从苹果的官方Demo CustomHTTPProtocol 中可以看到 Demux 这个类,通过阅读源码知道,该类的存在除了最大化复用 Session 之外,还将请求的发起和回调都放到了这个类进行处理,确保请求发起和回调都是在同一个线程和 Runloop Mode,至于为什么要这么做,文档中没有找到明确说明,不过后面踩坑的时候才发现,如果不这么做的话,在回调里面很容易就会遇到崩溃的情况,尽管你什么都没有做。
至于第二点:新创建的 NSURLSession 是否会影响到原来的网络请求策略?
思考:
根据苹果提供的Demo CustomHTTPProtocol 中可以看到,同样也是通过新创建一个 NSURLSession 发起请求的,那么它难道不会出现超时、缓存、认证等参数和原始请求不一致的情况么?
从逻辑上来说,要么就是要获取原始请求的 session,拿到对应的超时、缓存、认证等配置信息再发起请求;要么就是 Demux 中新创建的 session 对于请求发起方来说是透明的,这种透明包括不影响任何原始请求的参数配置!
针对以上猜想做相关验证:
5.1、超时验证:
验证1:原始请求设置超时为 5s,Demux 设置超时时间为 60s,手机网络设置为100% lost
验证结果:5s 触发超时
`2021``-``03``-``28` `18``:``44``:``11.307007``+``0800` `NSURLProtocolTest[``36460``:``8443172``] start a request...`
`2021``-``03``-``28` `18``:``44``:``16.381879``+``0800` `NSURLProtocolTest[``36460``:``8443172``] Task <CDCBC81D-E0C5-4A52-BDBF-5912AC0BCA32>.<``1``> finished with error [-``1001``] Error Domain=NSURLErrorDomain Code=-``1001` `"The request timed out."` `UserInfo={_kCFStreamErrorCodeKey=-``2102``, NSUnderlyingError=``0x2836d19e0` `{Error Domain=kCFErrorDomainCFNetwork Code=-``1001` `"(null)"` `UserInfo={_kCFStreamErrorCodeKey=-``2102``, _kCFStreamErrorDomainKey=``4``}}, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <CDCBC81D-E0C5-4A52-BDBF-5912AC0BCA32>.<``1``>, _NSURLErrorRelatedURLSessionTaskErrorKey=(`
`"LocalDataTask <CDCBC81D-E0C5-4A52-BDBF-5912AC0BCA32>.<1>"`
`), NSLocalizedDescription=The request timed out., NSErrorFailingURLStringKey=https:``//www.baidu.com, NSErrorFailingURLKey=[https://www.baidu.com](https://www.baidu.com/), _kCFStreamErrorDomainKey=4}`
- 验证2: 调用发设置超时时间为 60s,Demux 设置超时时间为 5s,手机网络设置为 100% lost
验证结果:60s 触发超时
|
`2021``-``03``-``28` `19``:``02``:``29.918506``+``0800` `NSURLProtocolTest[``36473``:``8448954``] start a request...`
`2021``-``03``-``28` `19``:``03``:``29.869946``+``0800` `NSURLProtocolTest[``36473``:``8448954``] Task <A533832C-C1E2-48B4-9C73-50447B930141>.<``1``> finished with error [-``1001``] Error Domain=NSURLErrorDomain Code=-``1001` `"The request timed out."` `UserInfo={_kCFStreamErrorCodeKey=-``2102``, NSUnderlyingError=``0x283d144b0` `{Error Domain=kCFErrorDomainCFNetwork Code=-``1001` `"(null)"` `UserInfo={_kCFStreamErrorCodeKey=-``2102``, _kCFStreamErrorDomainKey=``4``}}, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <A533832C-C1E2-48B4-9C73-50447B930141>.<``1``>, _NSURLErrorRelatedURLSessionTaskErrorKey=(`
`"LocalDataTask <A533832C-C1E2-48B4-9C73-50447B930141>.<1>"`
`), NSLocalizedDescription=The request timed out., NSErrorFailingURLStringKey=https:``//www.baidu.com, NSErrorFailingURLKey=[https://www.baidu.com](https://www.baidu.com/), _kCFStreamErrorDomainKey=4}`
结论:Demux 中新创建的 NSURLSession 超时时间设置不影响到请求发起方。
5.2、缓存验证:
验证1:原始请求设置为不使用缓存 NSURLRequestReloadIgnoringLocalCacheData,Demux 中设置为使用缓存 NSURLRequestReturnCacheDataElseLoad ;通过 charles 抓包确认在收到 completed 的时候是否有真正发起请求。
验证结果:每次点击开始请求按钮的时候, charles 都能抓到请求数据包,且 response code 为 200。
验证2:原始请求设置为使用缓存 NSURLRequestReturnCacheDataElseLoad,Demux 中设置为不使用缓存 NSURLRequestReturnCacheDataElseLoad;通过 charles 抓包确认在收到 completed 的时候是否有真正发起请求。
验证结果:卸载App,首次点击请求按钮的时候可以在 charles 中抓到请求数据包;后面再次点击的时候就没有抓到相关请求数据包了,但却返回到了 completed 回调,且 response code 为 200
结论:Demux 中新创建的 NSURLSession 缓存设置不影响到请求发起方。
5.3、认证策略验证:
验证1:由于条件限制,我们这里只做单向验证,即验证服务器证书。在请求方的回调 URLSession: didReceiveChallenge: completionHandler: 回调里面对服务器证书与本地正式的校验,校验通过则返回 completionHandler(NSURLSessionAuthChallengeUseCredential,credential);;然后在 DLURLProtocol 的 URLSession: didReceiveChallenge: completionHandler: 回调中直接设置为校验不通过
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, NULL);
验证结果:触发请求方的超时设置!
结论:会影响到请求方,尽管在请求方的 URLSession: didReceiveChallenge: completionHandler:回调里面调用了认证通过的completionHandler,一样会触发超时操作!
规避方法:网络监控所需要的信息采集不涉及到认证这块,可以直接将回调抛给请求方,由请求发起方进行处理。
5.4、耗时验证:
验证1:相隔10ms,异步轮流发起请求分别请求 www.baidu.com 、www.sina.com.cn 和 www.taobao.com 这几个域名,加起来总共请求 100 次,然后计算使用 NSProtocol 和不使用 NSProtocol 的平均耗时。
验证结果:
//有接入:
2021-05-11 00:36:39.112549+0800 NSURLProtocolTest[90129:29124516] baidu, count:26 , avgDuration:324.019181
2021-05-11 00:36:39.112666+0800 NSURLProtocolTest[90129:29124516] sina, count:46 avgDuration:553.305805
2021-05-11 00:36:39.115587+0800 NSURLProtocolTest[90129:29124516] taobao, count:28 avgDuration:300.874954
//无接入:
2021-05-11 00:29:52.958785+0800 NSURLProtocolTest[90066:29117542] baidu, count:35 , avgDuration:306.175676
2021-05-11 00:29:52.958941+0800 NSURLProtocolTest[90066:29117542] sina, count:29 avgDuration:321.528200
2021-05-11 00:29:52.959113+0800 NSURLProtocolTest[90066:29117542] taobao, count:36 avgDuration:297.670796
结论:除去网络波动影响,耗时基本相近。
详细日志:(存放在百度网盘上面的网络监控文件夹)