[iOS] NSURLProtocol

前言:最近在了解HttpDns的实现方案,经过调研,发现了NSURLProtocol这个在Apple URL Loading System中的特殊角色,特此记录一下。

1. NSURLProtocol简介

NSURLProtocol是一个抽象类,作为URL Loading System系统的一部分,能够帮助我们拦截所有的URL Loading System的请求,在此进行各种自定义的操作,是网络层实现AOP(面向切面变成)的利器。。

URL Loading System是Apple提供的一系列的类和协议,主要用来通过URL请求获取资源,像我们非常了解的NSURLSession等相关类就包含其中,如图所示:

image.png

需要注意的是上面特殊标注:能够帮助我们拦截所有的URL Loading System的请求。结合上图,我们可以知道NSURLProtocol可以拦截使用NSURLSessionNSURLConnection发起的请求,以及使用UIWebView发起的请求。

现在主流的iOS网络库,例如AFNetworking,Alamofire等网络库都是基于NSURLSession或NSURLConnection的,所以这些网络库的网络请求都可以被NSURLProtocol所拦截。

2. 使用场景

因为NSURLProtocol的强大特性,所以在实际应用中,它的使用场景也很广泛,比如:

  • 重定向网络请求,解决DNS域名劫持的问题
  • 进行全局或局部的网络请求设置,比如修改请求地址、header等
  • 协助实现HttpDns
  • 忽略网络请求,使用H5离线包或是缓存数据等
  • 自定义网络请求的返回结果,比如过滤敏感信息

2. NSURLProcotol的属性和方法

/// 注册该类,使之对URL加载系统可见
+ (BOOL)registerClass:(Class)protocolClass;

/// 取消注册该类
+ (void)unregisterClass:(Class)protocolClass;

/// 过滤方法。返回YES,则由该类处理请求,否则URL Loading System使用系统默认的行为处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;

/// 在该方法中自定义网络请求, 对请求进行修改,如URL重定向、添加Header
/// 无需额外处理可直接返回request
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

/// 创建一个实例
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(nullable NSCachedURLResponse *)cachedResponse client:(nullable id <NSURLProtocolClient>)client;

/// 判断两个请求是否相同,相同的话可以使用缓存数据,一般直接返回父类实现,或者不用重写。
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;

/// 开始加载请求,需要在该方法中发起一个新的请求
- (void)startLoading;

/// 取消加载请求
- (void)stopLoading;

/// 给指定的请求设置与指定键相关联的属性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;

/// 返回与指定的请求中指定的关键字关联的属性。如果没有该key,返回nil
+ (nullable id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;

/// 移除给指定的请求的指定key相关联的属性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;

/// 注册该类
+ (BOOL)registerClass:(Class)protocolClass;

/// 取消注册
+ (void)unregisterClass:(Class)protocolClass;

3. NSURLProtocol的使用

上面有提到NSURLProtocol是一个抽象类,所以使用的时候需要创建一个子类:

@interface CustomHTTPProtocol : NSURLProtocol

@end

使用NSURLProtocol主要可以分为5个步骤:注册—>拦截—>转发—>回调—>结束。

3.1 注册

  • 对于基于NSURLConnection或者使用[NSURLSession sharedSession]创建的网络请求,调用registerClass方法即可:
[NSURLProtocol registerClass:[CustomHTTPProtocol class]];
  • 对于基于NSURLSession的网络请求,需要通过配置NSURLSessionConfiguration对象的protocolClasses属性:
NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.protocolClasses = @[[NSClassFromString(@"CustomHTTPProtocol") class]];

3.2 拦截

拦截到网络请求后,NSURLProtocol会依次执行下面👇🏻几个方法。

3.2.1 控制是否需要被拦截
// 过滤方法。返回YES,则由该类拦截处理请求
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;

示例如下:

+ (BOOL)canInitWithRequest:(NSURLRequest *)request{
    NSString *scheme = [[request.URL scheme] lowercaseString];
    if ([scheme isEqual:@"http"]) {
        return YES;
    }
    return NO;
}
3.2.2 对request请求处理
/// 在该方法中自定义网络请求, 对请求进行修改,如URL重定向、添加Header
/// 无需额外处理可直接返回request
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

我们在这个方法里可以对原有的request请求进行处理,比如添加公共的请求头等,最后返回一个NSURLRequest实例即可:

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    /// 如果是http请求改成https
    if ([request.URL.scheme isEqualToString:@"http"]) {
        NSMutableURLRequest *mutableRequest = [request mutableCopy];
        NSString *urlString = mutableRequest.URL.absoluteString;
        urlString = [urlString stringByReplacingOccurrencesOfString:@"http" withString:@"https"];
        mutableRequest.URL = [NSURL URLWithString:urlString];
        return mutableRequest;
    }
    return request;
}

3.3 转发

在拦截到网络请求,并且对网络请求进行定制处理以后。我们需要将网络请求重新发送出去。

3.3.1 创建一个NSURLProcotol实例
/// 创建一个实例
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(nullable NSCachedURLResponse *)cachedResponse client:(nullable id <NSURLProtocolClient>)client;

该方法会创建一个NSURLProtocol实例,这里每一个网络请求都会创建一个新的实例。

3.3.2 发起请求
/// 开始加载请求,需要在该方法中发起一个新的请求
- (void)startLoading;

接下来就是转发的核心方法startLoading。在该方法中,我们把处理过的request重新发送出去。至于发送的形式,可以是基于NSURLConnection,NSURLSession甚至CFNetwork:

/// 开始网络请求
- (void)startLoading {
    
    NSMutableURLRequest *recursiveRequest = [[self request] mutableCopy];
    
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    
    NSMutableArray *protocolClasses = [configuration.protocolClasses mutableCopy];
    [protocolClasses addObject:self];
    configuration.protocolClasses = @[self.class];
    
    self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:recursiveRequest];
    [task resume];
}

3.4 回调

既是面向切面的编程,就不能影响到原来网络请求的逻辑。所以上一步将网络请求转发出去以后,当收到网络请求的返回,还需要再将返回值返回给URL Loading System

3.4.1 数据回调

这就需要使用到实现NSURLProtocolClient协议的client属性,在NSURLSessionDelegate的回调方法中,将数据返回给URL Loading System

#pragma mark -- NSURLSessionDataDelegate

/// 接收到服务响应时调用的方法
- (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];
}

/// 当请求完成(成功|失败)的时候会调用该方法,如果请求失败,则error有值
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    
    if (error) {
        [[self client] URLProtocol:self didFailWithError:error];
    } else {
        [[self client] URLProtocolDidFinishLoading:self];
    }
}
3.4.2 NSURLProtocolClient

记录下上面用到的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

3.5 结束

在一个网络请求完全结束以后,NSURLProtocol回调用到stopLoading。

在该方法里,我们完成在结束网络请求的操作。以NSURLSession为例:

- (void)stopLoading {
  [self.session invalidateAndCancel];
  self.session = nil;
}

4. 注意事项

虽然NSURLProtocol功能很强大,但是坑也不少。

4.1 拦截到的 request 请求的 HTTPBody 为 nil

可以借助 HTTPBodyStream 来获取 body,代码如下:

NSMutableURLRequest * req = [self mutableCopy];
    if ([self.HTTPMethod isEqualToString:@"POST"]) {
        if (!self.HTTPBody) {
            NSInteger maxLength = 1024;
            uint8_t d[maxLength];
            NSInputStream *stream = self.HTTPBodyStream;
            NSMutableData *data = [[NSMutableData alloc] init];
            [stream open];
            BOOL endOfStreamReached = NO;
            //不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
            while (!endOfStreamReached) {
                NSInteger bytesRead = [stream read:d maxLength:maxLength];
                if (bytesRead == 0) { //文件读取到最后
                    endOfStreamReached = YES;
                } else if (bytesRead == -1) { //文件读取错误
                    endOfStreamReached = YES;
                } else if (stream.streamError == nil) {
                    [data appendBytes:(void *)d length:bytesRead];
                }
            }
            req.HTTPBody = [data copy];
            [stream close];
        }
    }

4.2 多个NSURLProtocol嵌套使用

对于通过配置 NSURLSessionConfiguration 对象的 protocolClasses 属性来注册的情况,protocolClasses 数组中只有第一个 NSURLProtocol 会起作用,后续的 NSURLProtocol 就无法拦截到了,同时如果要拦截AFN中的请求,也需要特殊处理,可以使用runtime交换NSURLSessionConfigurationprotocolClasses方法来解决:

///使用的是AFN 所以重新给session的protocolclasses赋值
+ (void)exchangeNSURLSessionConfiguration{
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    Method originalMethod = class_getInstanceMethod(cls, @selector(protocolClasses));
    Method stubMethod = class_getInstanceMethod([DNSURLProtocol class], @selector(protocolClasses));
    if (!originalMethod || !stubMethod) {
        [NSException raise:NSInternalInconsistencyException format:@"Couldn't load NSURLSessionConfiguration."];
    }
    method_exchangeImplementations(originalMethod, stubMethod);
}

- (NSArray *)protocolClasses {
    return @[[DNSURLProtocol class]];
}

4.3 canInitWithRequest方法多次调用

偶尔会出现canInitWithRequest方法多次调用的情况,这个问题出现非常的奇怪,目前还不清楚原因。但是因为我们在canInitWithRequest方法中会判断是否拦截过的标记。所以这个问题不会影响到正常使用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容