深度理解 NSURLProtocol

歡迎訪問個人博客原文git

NSURLProtocol 是什麼

NSURLProtocol 是 Foundation 框架中 URL Loading System 的一部分。它可讓開發者能夠在不修改應用內原始請求代碼的狀況下,去改變 URL 加載的所有細節。換句話說,NSURLProtocol 是一個被 Apple 默許的中間人攻擊。github

雖然 NSURLProtocol 叫「Protocol」,卻不是協議,而是一個抽象類。web

既然 NSURLProtocol 是一個抽象類,說明它沒法被實例化,那麼它又是如何實現網絡請求攔截的?算法

答案就是經過子類化來定義新的或是已經存在的 URL 加載行爲。若是當前的網絡請求是能夠被攔截的,那麼開發者只須要將一個自定義的 NSURLProtocol 子類註冊到 App 中,在這個子類中就能夠攔截到全部請求並進行修改。數組

那麼到底哪些網絡請求能夠被攔截?緩存

NSURLProtocol 使用場景

前面已經說了,NSURLProtocol 是 URL Loading System 的一部分,因此它能夠攔截全部基於 URL Loading System 的網絡請求:服務器

  • NSURLSession
  • NSURLConnection
  • NSURLDownload
  • NSURLResponse
  • NSHTTPURLResponse
  • NSURLRequest
  • NSMutableURLRequest

相應的,基於它們實現的第三方網絡框架 AFNetworkingAlamofire 的網絡請求,也能夠被 NSURLProtocol 攔截到。網絡

但早些年基於 CFNetwork 實現的,好比 ASIHTTPRequest,其網絡請求就沒法被攔截。session

另外,UIWebView 也是能夠被 NSURLProtocol 攔截的,但 WKWebView 不能夠。(由於 WKWebView 是基於 WebKit,並不走 C socket。)app

所以,在實際應用中,它的功能十分強大,好比:

  • 重定向網絡請求,解決 DNS 域名劫持的問題
  • 進行全局或局部的網絡請求設置,好比修改請求地址、header 等
  • 忽略網絡請求,使用 H5 離線包或是緩存數據等
  • 自定義網絡請求的返回結果,好比過濾敏感信息

下面來看一下 NSURLProtocol 的相關方法。

NSURLProtocol 的相關方法

建立協議對象

// 建立一個 URL 協議實例來處理 request 請求
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
// 建立一個 URL 協議實例來處理 session task 請求
- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
複製代碼

註冊和註銷協議類

// 嘗試註冊 NSURLProtocol 的子類,使之在 URL 加載系統中可見
+ (BOOL)registerClass:(Class)protocolClass;
// 註銷 NSURLProtocol 的指定子類
+ (void)unregisterClass:(Class)protocolClass;
複製代碼

肯定子類是否能夠處理請求

子類化 NSProtocol 的首要任務就是告知它,須要控制什麼類型的網絡請求。

// 肯定協議子類是否能夠處理指定的 request 請求,若是返回 YES,請求會被其控制,返回 NO 則直接跳入下一個 protocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 肯定協議子類是否能夠處理指定的 task 請求
+ (BOOL)canInitWithTask:(NSURLSessionTask *)task;
複製代碼

獲取和設置請求屬性

NSURLProtocol 容許開發者去獲取、添加、刪除 request 對象的任意元數據。這幾個方法經常使用來處理請求無限循環的問題。

// 在指定的請求中獲取與指定鍵關聯的屬性
+ (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;
// 設置與指定請求中的指定鍵關聯的屬性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
// 刪除與指定請求中的指定鍵關聯的屬性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
複製代碼

提供請求的規範版本

若是你想要用特定的某個方式來修改請求,能夠用下面這個方法。

// 返回指定請求的規範版本
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
複製代碼

肯定請求是否相同

// 判斷兩個請求是否相同,若是相同可使用緩存數據,一般只須要調用父類的實現
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
複製代碼

啓動和中止加載

這是子類中最重要的兩個方法,不一樣的自定義子類在調用這兩個方法時會傳入不一樣的內容,但共同點都是圍繞 protocol 客戶端進行操做。

// 開始加載
- (void)startLoading;
// 中止加載
- (void)stopLoading;
複製代碼

獲取協議屬性

// 獲取協議接收者的緩存
- (NSCachedURLResponse *)cachedResponse;
// 接受者用來與 URL 加載系統通訊的對象,每一個 NSProtocol 的子類實例都擁有它
- (id<NSURLProtocolClient>)client;
// 接收方的請求
- (NSURLRequest *)request;
// 接收方的任務
- (NSURLSessionTask *)task;
複製代碼

NSURLProtocol 在實際應用中,主要是完成兩步:攔截 URL 和 URL 轉發。先來看如何攔截網絡請求。

如何利用 NSProtocol 攔截網絡請求

建立 NSURLProtocol 子類

這裏建立一個名爲 HTCustomURLProtocol 的子類。

@interface HTCustomURLProtocol : NSURLProtocol
@end
複製代碼

註冊 NSURLProtocol 的子類

在合適的位置註冊這個子類。對基於 NSURLConnection 或者使用 [NSURLSession sharedSession] 初始化對象建立的網絡請求,調用 registerClass 方法便可。

[NSURLProtocol registerClass:[NSClassFromString(@"HTCustomURLProtocol") class]];
// 或者
// [NSURLProtocol registerClass:[HTCustomURLProtocol class]]; 
複製代碼

若是須要全局監聽,能夠設置在 AppDelegate.mdidFinishLaunchingWithOptions 方法中。若是隻須要在單個 UIViewController 中使用,記得在合適的時機註銷監聽:

[NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]];
複製代碼

若是是基於 NSURLSession 的網絡請求,且不是經過 [NSURLSession sharedSession] 方式建立的,就得配置 NSURLSessionConfiguration 對象的 protocolClasses 屬性。

NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.protocolClasses = @[[NSClassFromString(@"HTCustomURLProtocol") class]];
複製代碼

實現 NSURLProtocol 子類

實現子類分爲五個步驟:

註冊 → 攔截 → 轉發 → 回調 → 結束

以攔截 UIWebView 爲例,這裏須要重寫父類的這五個核心方法。

// 定義一個協議 key
static NSString * const HTCustomURLProtocolHandledKey = @"HTCustomURLProtocolHandledKey";

// 在拓展中定義一個 NSURLConnection 屬性。經過 NSURLSession 也能夠攔截,這裏只是以 NSURLConnection 爲例。
@property (nonatomic, strong) NSURLConnection *connection;
// 定義一個可變的請求返回值,
@property (nonatomic, strong) NSMutableData *responseData;

// 方法 1:在攔截到網絡請求後會調用這一方法,能夠再次處理攔截的邏輯,好比設置只針對 http 和 https 的請求進行處理。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    // 只處理 http 和 https 請求
    NSString *scheme = [[request URL] scheme];
    if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame ||
          [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) {
        // 看看是否已經處理過了,防止無限循環
        if ([NSURLProtocol propertyForKey:HTCustomURLProtocolHandledKey inRequest:request]) {
            return NO;
        }
        // 若是還須要截取 DNS 解析請求中的連接,能夠繼續加判斷,是否爲攔截域名請求的連接,若是是返回 NO
        return YES;
    }
    return NO;
}

// 方法 2:【關鍵方法】能夠在此對 request 進行處理,好比修改地址、提取請求信息、設置請求頭等。
+ (NSURLRequest *) canonicalRequestForRequest:(NSURLRequest *)request {
    // 能夠打印出全部的請求連接包括 CSS 和 Ajax 請求等
    NSLog(@"request.URL.absoluteString = %@",request.URL.absoluteString);
    NSMutableURLRequest *mutableRequest = [request mutableCopy];
    return mutableRequest;
}

// 方法 3:【關鍵方法】在這裏設置網絡代理,從新建立一個對象將處理過的 request 轉發出去。這裏對應的回調方法對應 <NSURLProtocolClient> 協議方法
- (void)startLoading {
    // 能夠修改 request 請求
    NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
    // 打 tag,防止遞歸調用
    [NSURLProtocol setProperty:@YES forKey:HTCustomURLProtocolHandledKey inRequest:mutableRequest];
    // 也能夠在這裏檢查緩存
    // 將 request 轉發,對於 NSURLConnection 來講,就是建立一個 NSURLConnection 對象;對於 NSURLSession 來講,就是發起一個 NSURLSessionTask。
    self.connection = [NSURLConnection connectionWithRequest:mutableRequest delegate:self];
}

// 方法 4:主要判斷兩個 request 是否相同,若是相同的話可使用緩存數據,一般只須要調用父類的實現。
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a toRequest:b];
}

// 方法 5:處理結束後中止相應請求,清空 connection 或 session
- (void)stopLoading {
    if (self.connection != nil) {
        [self.connection cancel];
        self.connection = nil;
    }
}

// 按照在上面的方法中作的自定義需求,看狀況對轉發出來的請求在恰當的時機進行回調處理。
#pragma mark- NSURLConnectionDelegate

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    [self.client URLProtocol:self didFailWithError:error];
}

#pragma mark - NSURLConnectionDataDelegate

// 當接收到服務器的響應(連通了服務器)時會調用
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    self.responseData = [[NSMutableData alloc] init];
    // 能夠處理不一樣的 statusCode 場景
    // NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode];
    // 能夠設置 Cookie
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}

// 接收到服務器的數據時會調用,可能會被調用屢次,每次只傳遞部分數據
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    [self.responseData appendData:data];
    [self.client URLProtocol:self didLoadData:data];
}

// 服務器的數據加載完畢後調用
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    [self.client URLProtocolDidFinishLoading:self];
}

// 請求錯誤(失敗)的時候調用,好比出現請求超時、斷網,通常指客戶端錯誤
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    [self.client URLProtocol:self didFailWithError:error];
}

複製代碼

上面用到的一些 NSURLProtocolClient 方法:

@protocol NSURLProtocolClient <NSObject>
// 請求重定向
- (void)URLProtocol:(NSURLProtocol *)protocol wasRedirectedToRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse;
// 響應緩存是否合法
- (void)URLProtocol:(NSURLProtocol *)protocol cachedResponseIsValid:(NSCachedURLResponse *)cachedResponse;
// 剛接收到 response 信息
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
// 數據加載成功
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
// 數據完成加載
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
// 數據加載失敗
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
// 爲指定的請求啓動驗證
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
// 爲指定的請求取消驗證
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end
複製代碼

補充內容

使用 NSURLSession 時的注意事項

若是在 NSURLProtocol 中使用 NSURLSession,須要注意:

  • 攔截到的 request 請求的 HTTPBody 爲 nil,但能夠藉助 HTTPBodyStream 來獲取 body;
  • 若是要用 registerClass 註冊,只能經過 [NSURLSession sharedSession] 的方式建立網絡請求。

註冊多個 NSURLProtocol 子類

當有多個自定義 NSURLProtocol 子類註冊到系統中的話,會按照他們註冊的反向順序依次調用 URL 加載流程,也就是最後註冊的 NSURLProtocol 會被優先判斷。

對於經過配置 NSURLSessionConfiguration 對象的 protocolClasses 屬性來註冊的狀況,protocolClasses 數組中只有第一個 NSURLProtocol 會起做用,後續的 NSURLProtocol 就沒法攔截到了。

因此 OHHTTPStubs 在註冊 NSURLProtocol 子類的時候是這樣處理的:

+ (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));
    }
}
複製代碼

如何攔截 WKWebView

雖然 NSURLProtocol 沒法直接攔截 WKWebView,但其實仍是有解決方案的。就是使用 WKBrowsingContextControllerregisterSchemeForCustomProtocol

// 註冊 scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
    // 經過 http 和 https 的請求,同理可經過其餘的 Scheme 可是要知足 URL Loading System
    [cls performSelector:sel withObject:@"http"];
    [cls performSelector:sel withObject:@"https"];
}
複製代碼

但因爲這涉及到了私有方法,直接引用沒法過蘋果的機審,因此使用的時候須要對字符串作下處理,好比對方法名進行算法加密處理等,實測也是能夠經過審覈的。

總之,NSURLProtocol 很是強大,不管是優化 App 的性能,仍是拓展功能,都具備很強的可塑空間,但在使用的同時,又要多關注它帶來的問題。儘管它在不少框架或者知名項目中都已經得以應用,其奧義依然值得開發者們去深刻研究。

相關文章
相關標籤/搜索