[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 )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 )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 
// 请求重定向
- (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方法中会判断是否拦截过的标记。所以这个问题不会影响到正常使用。

你可能感兴趣的:([iOS] NSURLProtocol)