1.背景
隨著業(yè)務(wù)的發(fā)展,用戶對于網(wǎng)絡(luò)的依賴場景會越來越多,隨之而來遇到的各種異常網(wǎng)絡(luò)場景也越來越多。
1、為了保證網(wǎng)絡(luò)的接口的持續(xù)健康、及時發(fā)現(xiàn)問題,為網(wǎng)絡(luò)性能優(yōu)化提供數(shù)據(jù)基礎(chǔ)。
2、同時以報表的形式直觀的去展現(xiàn)現(xiàn)有網(wǎng)絡(luò)質(zhì)量。
3、也為了更好的支撐后續(xù)業(yè)務(wù)的發(fā)展,就需要我們?nèi)ゴ罱ㄆ鹣鄳?yīng)的網(wǎng)絡(luò)監(jiān)控體系。
二、目標(biāo)
提供一套完整的網(wǎng)絡(luò)采集、監(jiān)控和預(yù)警的可視化機制,用于線上接口可用性和健康度觀察,并提供一系列排查問題的輔助信息。
通過儀表盤可視化呈現(xiàn)網(wǎng)絡(luò)質(zhì)量,包括但不限于以下能力:
- 總體請求成功率
- 過濾單個請求成功率、失敗錯誤碼
- 查看接口請求詳情
- 接口訪問平均耗時、時長分布
- 通過 traceId 實現(xiàn)從客戶端請求的發(fā)起到最終具體服務(wù)器的處理返回全鏈路追蹤。
三、總體方案

不管是 iOS 還是 Android ,兩者最終要做的目標(biāo)方案如上圖。這里從下到上分別闡述每個部分的功能:
網(wǎng)絡(luò)基礎(chǔ)庫:針對平臺特性,這里有 iOS 的 AFnetworking、Alamofire 和 Android 的 okhttp3、okhttp4 ,其實現(xiàn)原理應(yīng)該都差不多,都是針對底層的網(wǎng)絡(luò)api進行進一步的封裝,提高接口的易用性。
攔截器:主要是針對各個基礎(chǔ)網(wǎng)絡(luò)庫進行接口攔截。根據(jù)平臺不同,iOS 主要使用 NSProtocol + Hook,Android 使用 Aspect 。
網(wǎng)絡(luò)封裝庫:一般開發(fā)過程都會針對基礎(chǔ)的網(wǎng)絡(luò)庫再做二次封裝,加入一些策略、緩存、安全校驗等管理,使其更加貼合業(yè)務(wù)和快速接入使用。
插件/功能模塊:以插件化的形式提供額外的網(wǎng)絡(luò)功能
統(tǒng)計模塊:將從業(yè)務(wù)開始調(diào)用到回調(diào)給業(yè)務(wù)方的各個環(huán)節(jié)的耗時及狀態(tài)值,變成統(tǒng)計數(shù)據(jù)匯報到APM。
網(wǎng)絡(luò)診斷模塊:對關(guān)鍵業(yè)務(wù)進行診斷,包括dns解析、ping、弱網(wǎng)檢測等,輸出診斷報告并上報到APM。
重試模塊:根據(jù)策略進行重試,包括 ip 重試、https 降級重試、原 url 重試等。
httpdns模塊:提供 httpdns 能力,解決域名劫持問題。
上傳模塊:提供上傳能力,包括斷點續(xù)傳、分片上傳以及包體大小、上傳耗時等信息監(jiān)控。
下載模塊:提供下載能力,包括大文件下載、斷點續(xù)傳以及包體大小、下載耗時等信息監(jiān)控。
mock 模塊:提供 mock 能力,主要用于測試和后臺接口還沒有準(zhǔn)備好的情況下使用。
對外接口層:這一層直接對接上層業(yè)務(wù)。
四、具體實現(xiàn)
1)請求方式
iOS 常用的第三方網(wǎng)絡(luò) AFNetworking、Alamofire 基本都是基于 NSURLConnection 或者是 NSURLSession 的封裝,其中 NSURLConnection 是比較舊的使用方式了,而 NSURLSession 則是比較新的也是比較被推薦的使用方式。
2)底層原理
在使用 NSURLConnection 和 NSURLSession 進行網(wǎng)絡(luò)請求的時候,實際上走的都是更底層的 URL Loading System,URL Loading System 使用標(biāo)準(zhǔn)協(xié)議 https 或者自定義協(xié)議訪問標(biāo)識資源,本身支持 http,https,文件,ftp 和數(shù)據(jù)協(xié)議。
可以通過繼承 NSURLProtocol 實現(xiàn)一個自定義的 Protocol,然后調(diào)用 registerClass:方法注冊到 URL Loading System 中去,這樣 NSURLConnection、NSURLSession 或者是 NSURLDownload 在使用 NSURLRequest 初始化一個連接的時候,URL Loading System 就會
將按照注冊時的相反順序詢問每個注冊的類,詢問到第一個 +canInitWithRequest: 方法返回 YES 的時候則使用該類去處理請求。
- NSURConnection 中,直接調(diào)用 registerClass:方法注冊我們自己的協(xié)議即可。
- NSURLSession 中,如果是通過 [NSURLSession sharedSession] 初始化創(chuàng)建網(wǎng)絡(luò)請求,調(diào)用 registerClass:即可,如果是通過 configurantion 來初始化,則通過修改 configuration 的 protocolClasses 屬性,把自定義類插入到該數(shù)組的前面,確保我們的自定義的協(xié)議能夠優(yōu)先處理到網(wǎng)絡(luò)請求。
可以看到 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)實現(xiàn)步驟
利用 Objc 運行時 hook 掉 NSURLSessionConfiguration 的 defaultSessionConfiguration 屬性和 ephemeralSessionConfiguration 屬性設(shè)置,然后修改 configuration 的 protocolClassess 屬性,插入我們自定義的 Protocol
在自定義的 NSURLProtocol 之類中實現(xiàn)如下方法:
+ canInitWithRequest: 在這里判斷當(dāng)前網(wǎng)絡(luò)請求是否需要監(jiān)控,如果不需要直接 return NO 即可。
+ canonicalRequestForRequest: 生成一個新的 request 請求,同時標(biāo)識該請求已經(jīng)處理過,防止死循環(huán)。
- startLoading 將新的請求發(fā)送出去,設(shè)置對應(yīng)的回調(diào)代理。
- stopLoading 停止網(wǎng)絡(luò)請求。
3. 處理請求回調(diào),實現(xiàn)需要進行處理的回調(diào)方法,處理完成后通過 self.client.urlProtocol 將回調(diào)方法傳回至原來的 delegate。
4. 至此,我們就完成了發(fā)送、接收等一系列操作,并且完美的將回調(diào)轉(zhuǎn)發(fā)回了原來的代理方,剩下的就是我們在回調(diào)中收集完了請求的各種信息就好。
流程圖如下:

4)可能存在的問題及優(yōu)化
流程并不復(fù)雜,從上圖可以看到,使用了網(wǎng)絡(luò)攔截之后的流程圖比原本的多了一個 custom protocol(DLURLProtocol)和 custom session。custom potocol 用于攔截網(wǎng)絡(luò)請求,custom session 用于發(fā)起新的請求。
這里可能會存在兩個問題:
每個請求都會新創(chuàng)建一個 NSURLSession,對于網(wǎng)絡(luò)請求這種很頻繁的操作來說不是很友好;
新創(chuàng)建的 NSURLSession 如何確保超時、緩存、認(rèn)證、cookies 等策略跟原始的 NSURLSession 保持一致,如果不一致是否會影響到既有的網(wǎng)絡(luò)請求?
五、風(fēng)險評估
針對可能存在的問題做相關(guān)梳理和驗證~
關(guān)于第一點:每個請求都會創(chuàng)建一個 NSURLSession 這個很好解決,使用一個單例即可,從蘋果的官方Demo CustomHTTPProtocol 中可以看到 Demux 這個類,通過閱讀源碼知道,該類的存在除了最大化復(fù)用 Session 之外,還將請求的發(fā)起和回調(diào)都放到了這個類進行處理,確保請求發(fā)起和回調(diào)都是在同一個線程和 Runloop Mode,至于為什么要這么做,文檔中沒有找到明確說明,不過后面踩坑的時候才發(fā)現(xiàn),如果不這么做的話,在回調(diào)里面很容易就會遇到崩潰的情況,盡管你什么都沒有做。
至于第二點:新創(chuàng)建的 NSURLSession 是否會影響到原來的網(wǎng)絡(luò)請求策略?
思考:
根據(jù)蘋果提供的Demo CustomHTTPProtocol 中可以看到,同樣也是通過新創(chuàng)建一個 NSURLSession 發(fā)起請求的,那么它難道不會出現(xiàn)超時、緩存、認(rèn)證等參數(shù)和原始請求不一致的情況么?
從邏輯上來說,要么就是要獲取原始請求的 session,拿到對應(yīng)的超時、緩存、認(rèn)證等配置信息再發(fā)起請求;要么就是 Demux 中新創(chuàng)建的 session 對于請求發(fā)起方來說是透明的,這種透明包括不影響任何原始請求的參數(shù)配置!
針對以上猜想做相關(guān)驗證:
5.1、超時驗證:
驗證1:原始請求設(shè)置超時為 5s,Demux 設(shè)置超時時間為 60s,手機網(wǎng)絡(luò)設(shè)置為100% lost
驗證結(jié)果:5s 觸發(fā)超時
`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: 調(diào)用發(fā)設(shè)置超時時間為 60s,Demux 設(shè)置超時時間為 5s,手機網(wǎng)絡(luò)設(shè)置為 100% lost
驗證結(jié)果:60s 觸發(fā)超時
|
`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}`
結(jié)論:Demux 中新創(chuàng)建的 NSURLSession 超時時間設(shè)置不影響到請求發(fā)起方。
5.2、緩存驗證:
驗證1:原始請求設(shè)置為不使用緩存 NSURLRequestReloadIgnoringLocalCacheData,Demux 中設(shè)置為使用緩存 NSURLRequestReturnCacheDataElseLoad ;通過 charles 抓包確認(rèn)在收到 completed 的時候是否有真正發(fā)起請求。
驗證結(jié)果:每次點擊開始請求按鈕的時候, charles 都能抓到請求數(shù)據(jù)包,且 response code 為 200。
驗證2:原始請求設(shè)置為使用緩存 NSURLRequestReturnCacheDataElseLoad,Demux 中設(shè)置為不使用緩存 NSURLRequestReturnCacheDataElseLoad;通過 charles 抓包確認(rèn)在收到 completed 的時候是否有真正發(fā)起請求。
驗證結(jié)果:卸載App,首次點擊請求按鈕的時候可以在 charles 中抓到請求數(shù)據(jù)包;后面再次點擊的時候就沒有抓到相關(guān)請求數(shù)據(jù)包了,但卻返回到了 completed 回調(diào),且 response code 為 200
結(jié)論:Demux 中新創(chuàng)建的 NSURLSession 緩存設(shè)置不影響到請求發(fā)起方。

5.3、認(rèn)證策略驗證:
驗證1:由于條件限制,我們這里只做單向驗證,即驗證服務(wù)器證書。在請求方的回調(diào) URLSession: didReceiveChallenge: completionHandler: 回調(diào)里面對服務(wù)器證書與本地正式的校驗,校驗通過則返回 completionHandler(NSURLSessionAuthChallengeUseCredential,credential);;然后在 DLURLProtocol 的 URLSession: didReceiveChallenge: completionHandler: 回調(diào)中直接設(shè)置為校驗不通過
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, NULL);
驗證結(jié)果:觸發(fā)請求方的超時設(shè)置!
結(jié)論:會影響到請求方,盡管在請求方的 URLSession: didReceiveChallenge: completionHandler:回調(diào)里面調(diào)用了認(rèn)證通過的completionHandler,一樣會觸發(fā)超時操作!
規(guī)避方法:網(wǎng)絡(luò)監(jiān)控所需要的信息采集不涉及到認(rèn)證這塊,可以直接將回調(diào)拋給請求方,由請求發(fā)起方進行處理。
5.4、耗時驗證:
驗證1:相隔10ms,異步輪流發(fā)起請求分別請求 www.baidu.com 、www.sina.com.cn 和 www.taobao.com 這幾個域名,加起來總共請求 100 次,然后計算使用 NSProtocol 和不使用 NSProtocol 的平均耗時。
驗證結(jié)果:
//有接入:
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
結(jié)論:除去網(wǎng)絡(luò)波動影響,耗時基本相近。
詳細(xì)日志:(存放在百度網(wǎng)盤上面的網(wǎng)絡(luò)監(jiān)控文件夾)