iOS 開發中使用 NSURLProtocol 攔截 HTTP 請求

Draveness發表於2019-03-03

這篇文章會提供一種在 Cocoa 層攔截所有 HTTP 請求的方法,其實標題已經說明了攔截 HTTP 請求需要的瞭解的就是 NSURLProtocol

由於文章的內容較長,會分成兩部分,這篇文章介紹 NSURLProtocol 攔截 HTTP 請求的原理,另一篇文章如何進行 HTTP Mock 介紹這個原理在 OHHTTPStubs 中的應用,它是如何 Mock(偽造)某個 HTTP 請求對應的響應的。

NSURLProtocol

NSURLProtocol 是蘋果為我們提供的 URL Loading System 的一部分,這是一張從官方文件貼過來的圖片:

URL-loading-syste

官方文件對 NSURLProtocol 的描述是這樣的:

An NSURLProtocol object handles the loading of protocol-specific URL data. The NSURLProtocol class itself is an abstract class that provides the infrastructure for processing URLs with a specific URL scheme. You create subclasses for any custom protocols or URL schemes that your app supports.

在每一個 HTTP 請求開始時,URL 載入系統建立一個合適的 NSURLProtocol 物件處理對應的 URL 請求,而我們需要做的就是寫一個繼承自 NSURLProtocol 的類,並通過 - registerClass: 方法註冊我們的協議類,然後 URL 載入系統就會在請求發出時使用我們建立的協議物件對該請求進行處理。

這樣,我們需要解決的核心問題就變成了如何使用 NSURLProtocol 來處理所有的網路請求,這裡使用蘋果官方文件中的 CustomHTTPProtocol 進行介紹,你可以點選這裡下載原始碼。

在這個工程中 CustomHTTPProtocol.m 是需要重點關注的檔案,CustomHTTPProtocol 就是 NSURLProtocol 的子類:

@interface CustomHTTPProtocol : NSURLProtocol

...

@end複製程式碼

現在重新回到需要解決的問題,也就是 如何使用 NSURLProtocol 攔截 HTTP 請求?,有這個麼幾個問題需要去解決:

  • 如何決定哪些請求需要當前協議物件處理?
  • 對當前的請求物件需要進行哪些處理?
  • NSURLProtocol 如何例項化?
  • 如何發出 HTTP 請求並且將響應傳遞給呼叫者?

上面的這幾個問題其實都可以通過 NSURLProtocol 為我們提供的 API 來解決,決定請求是否需要當前協議物件處理的方法是:+ canInitWithRequest

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    BOOL shouldAccept;
    NSURL *url;
    NSString *scheme;

    shouldAccept = (request != nil);
    if (shouldAccept) {
        url = [request URL];
        shouldAccept = (url != nil);
    }
    return shouldAccept;
}複製程式碼

因為專案中的這個方法是大約有 60 多行,在這裡只貼上了其中的一部分,只為了說明該方法的作用:每一次請求都會有一個 NSURLRequest 例項,上述方法會拿到所有的請求物件,我們就可以根據對應的請求選擇是否處理該物件;而上面的程式碼只會處理所有 URL 不為空的請求。

請求經過 + canInitWithRequest: 方法過濾之後,我們得到了所有要處理的請求,接下來需要對請求進行一定的操作,而這都會在 + canonicalRequestForRequest: 中進行,雖然它與 + canInitWithRequest: 方法傳入的 request 物件都是一個,但是最好不要在 + canInitWithRequest: 中操作物件,可能會有語義上的問題;所以,我們需要覆寫 + canonicalRequestForRequest: 方法提供一個標準的請求物件:

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request;
}複製程式碼

這裡對請求不做任何修改,直接返回,當然你也可以給這個請求加個 header,只要最後返回一個 NSURLRequest 物件就可以。

在得到了需要的請求物件之後,就可以初始化一個 NSURLProtocol 物件了:

- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client {
    return [super initWithRequest:request cachedResponse:cachedResponse client:client];
}複製程式碼

在這裡直接呼叫 super 的指定構造器方法,例項化一個物件,然後就進入了傳送網路請求,獲取資料並返回的階段了:

- (void)startLoading {
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[[NSURLSessionConfiguration alloc] init] delegate:self delegateQueue:nil];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:self.request];
    [task resume];
}複製程式碼

這裡使用簡化了 CustomHTTPClient 中的專案程式碼,可以達到幾乎相同的效果。

你可以在 - startLoading 中使用任何方法來對協議物件持有的 request 進行轉發,包括 NSURLSessionNSURLConnection 甚至使用 AFNetworking 等網路庫,只要你能在回撥方法中把資料傳回 client,幫助其正確渲染就可以,比如這樣:

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];

    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    [[self client] URLProtocol:self didLoadData:data];
}複製程式碼

當然這裡省略後的程式碼只會保證大多數情況下的正確執行,只是給你一個對獲取響應資料粗略的認知,如果你需要更加詳細的程式碼,我覺得最好還是檢視一下 CustomHTTPProtocol 中對 HTTP 響應處理的程式碼,也就是 NSURLSessionDelegate 協議實現的部分。

client 你可以理解為當前網路請求的發起者,所有的 client 都實現了 NSURLProtocolClient 協議,協議的作用就是在 HTTP 請求發出以及接受響應時向其它物件傳輸資料:

@protocol NSURLProtocolClient <NSObject>
...
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;

- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;

- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
...
@end複製程式碼

當然這個協議中還有很多其他的方法,比如 HTTPS 驗證、重定向以及響應快取相關的方法,你需要在合適的時候呼叫這些代理方法,對資訊進行傳遞。

如果你只是繼承了 NSURLProtocol 並且實現了上述方法,依然不能達到預期的效果,完成對 HTTP 請求的攔截,你還需要在 URL 載入系統中註冊當前類:

[NSURLProtocol registerClass:self];複製程式碼

需要注意的是 NSURLProtocol 只能攔截 UIURLConnectionNSURLSessionUIWebView 中的請求,對於 WKWebView 中發出的網路請求也無能為力,如果真的要攔截來自 WKWebView 中的請求,還是需要實現 WKWebView 對應的 WKNavigationDelegate,並在代理方法中獲取請求。
無論是 NSURLProtocolNSURLConnection 還是 NSURLSession 都會走底層的 socket,但是 WKWebView 可能由於基於 WebKit,並不會執行 C socket 相關的函式對 HTTP 請求進行處理,具體會執行什麼程式碼暫時不是很清楚,如果對此有興趣的讀者,可以聯絡筆者一起討論。

總結

如果你只想瞭解如何對 HTTP 請求進行攔截,其實看到這裡就可以了,不過如果你想應用文章中的內容或者希望瞭解如何偽造 HTTP 響應,可以看下一篇文章如何進行 HTTP Mock

References


+ NSURLProtocol

Github Repo:iOS-Source-Code-Analyze

Follow: Draveness · Github

Source: draveness.me/intercept

相關文章