NSURLProtocol拦截 HTTP 请求

NSURLProtocol 是苹果为我们提供的 URL Loading System 的一部分, 在每一个 HTTP 请求开始时,URL 加载系统会创建一个合适的 NSURLProtocol 对象处理对应的 URL 请求,正常情况下的网络请求是使用的系统默认实现,  而我们需要做的就是写一个继承自 NSURLProtocol 的类,并通过 - registerClass: 方法注册我们的协议类,然后 URL 加载系统就会在请求发出时使用我们创建的协议对象对该请求进行处理。

NSURLProtocol是苹果为我们提供的 URL Loading System 的一部分,能够让你去重新定义苹果的URL加载系统(URL Loading System)的行为,URL Loading System里有许多类用于处理URL请求,比如NSURLNSURLRequestNSURLConnectionNSURLSession等,当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,NSURLProtocol看起来像是一个协议,但其实这是一个类,你不能直接实例化一个NSURLProtocol,而是需要写一个继承自 NSURLProtocol 的子类,并通过- registerClass:方法注册我们的协议类,然后 URL 加载系统就会在请求发出时使用我们创建的协议对象对该请求进行处理。

用一句话解释NSURLProtocol:就是一个苹果允许的中间人攻击。
NSURLProtocol可以劫持系统所有基于C socket的网络请求。
注意:WKWebView基于Webkit,并不走底层的C socket,所以NSURLProtocol拦截不了WKWebView中的请求。

使用场景

  不管你是通过UIWebView, NSURLConnection或者第三方库 (AFNetworkingAlamofire等),他们都是基于NSURLConnection或者 NSURLSession实现的,因此你可以通过NSURLProtocol做自定义的操作。

  •  重定向网络请求(可以解决电信的DNS域名劫持问题)
  • 忽略网络请求,使用本地缓存
  • 自定义网络请求的返回结果Response, 开发阶段编造假数据
  • 一些全局的网络请求设置
  • 快速进行测试环境的切换
  • 过滤掉一些非法请求
  • 网络的缓存处理(H5离线包 和 网络图片缓存)
  • 可以拦截UIWebView,基于系统的NSURLConnection或者NSURLSession进行封装的网络请求。目前WKWebView无法被NSURLProtocol拦截。
  • 当有多个自定义NSURLProtocol注册到系统中的话,会按照他们注册的反向顺序依次调用URL加载流程。当其中有一个NSURLProtocol拦截到请求的话,后续的NSURLProtocol就无法拦截到该请求。

具体步骤为:
使用NSURLProtocol的主要可以分为5个步骤:
注册—>拦截—>转发—>回调—>结束
即:
注册NSURLProtocol子类 -> 使用NSURLProtocol子类拦截请求 -> 使用NSURLSession重新发起请求 -> 将NSURLSession请求的响应内容返回 -> 结束

使用方法

1. 子类化:

由于 NSURLProtocol是一个抽象类,所以使用的时候必须定义一个它的子类:

#import <Foundation/Foundation.h>

@interface CustomURLProtocol : NSURLProtocol

@end

2. 注册:

  对于基于NSURLConnection或者使用[NSURLSession sharedSession]初始化对象创建的网络请求,调用registerClass方法即可。

//注册protocol
[NSURLProtocol registerClass:[CustomURLProtocol class]];

  对于基于NSURLSession的网络请求,如下所示需要通过配置sessionWithConfiguration:delegate:delegateQueue:初始化对象的,需要配置对象的protocolClasses属性,这个在下面会有详细的介绍。

// NSURLSession例子
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSArray *protocolArray = @[ [CustomURLProtocol class]];
configuration.protocolClasses = protocolArray;
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
NSURLSessionTask *task = [session dataTaskWithRequest:_request];
[task resume];

  一经注册之后,所有交给URL Loading system的网络请求都会被拦截,所以当不需要拦截的时候,要进行注销

[NSURLProtocol unregisterClass:[CustomURLProtocol class]];

3. 抽象对象必须实现的方法(拦截)

注册成功之后,就需要我们的子类去实现抽象方法:

//所有注册此Protocol的请求都会经过这个方法的判断, 是否对这个request生成一个NSURLProtocol实例并处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
//可选方法,对需要拦截的请求进行自定义的处理
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
//主要是用来判断两个request是否相同,这个方法基本不常用
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
//初始化protocol实例,所有来源的请求都以NSURLRequest形式接收
- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client;
/**
    开始请求
    在这里需要我们手动的把请求发出去,可以使用原生的NSURLSessionDataTask,也可以使用的第三方网络库
    同时设置"NSURLSessionDataDelegate"协议,接收Server端的响应
*/
- (void)startLoading;
//请求被停止
- (void)stopLoading;

详细说明:

  • canInitWithRequest

  该方法会拿到request的对象,我们可以通过该方法的返回值来筛选request是否需要被NSURLProtocol做拦截处理。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    
    NSString * scheme = [[request.URL scheme] lowercaseString];
    if ([scheme isEqual:@"http"]) {
        return YES;
    }

    //看看是否已经处理过了,防止无限循环 根据业务来截取 
    if ([NSURLProtocol propertyForKey: URLProtocolHandledKey inRequest:request]) {
        return NO;
    }
    return NO;
}

URLProtocolHandledKey 是:

static NSString * const URLProtocolHandledKey = @"URLProtocolHandledKey";

上面我们就只会拦截http的请求。

  • canonicalRequestForRequest:

  这个方法用来统一处理请求request 对象的,可以修改头信息,或者重定向。没有特殊需要,则直接return request
  如果要在这里做重定向以及头信息的时候注意检查是否已经添加,因为这个方法可能被调用多次,也可以在后面的方法中做。

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request;
}
  • requestIsCacheEquivalent:toRequest:

主要判断两个request是否相同,如果相同的话可以使用缓存数据,通常只需要调用父类的实现。

+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a toRequest:b];
}

4. 转发

  在拦截到网络请求,并且对网络请求进行定制处理以后。我们需要将网络请求重新发送出去,就可以初始化一个NSURLProtocol对象了:

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

该方法会创建一个NSURLProtocol实例,在这里直接调用super的指定构造器方法,实例化一个对象。

  • startLoading

  接下来就是转发的核心方法startLoading。在该方法中,把当前请求的request拦截下来以后,可以在这里修改请求信息,重定向网络,DNS解析,使用自定义的缓存等。至于发送的形式,可以是基于NSURLConnectionNSURLSession甚至AFNetworking等网络库。对于NSURLConnection来说,就是创建一个NSURLConnection,对于NSURLSession,就是发起一个NSURLSessionTask 。一般下载前需要设置该请求正在进行下载,防止多次下载的情况发生。

重点:需要标记已经处理过的request
下面就是一个重定向的例子:

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //标示该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //使用NSURLSession继续把request发送出去
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
//定义全局的NSURLSession对象用于stop请求使用
    self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
    [task resume];
}

5. 回调

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

//将新的response作为request对应的response
[self.client URLProtocol:self
       didReceiveResponse:response
       cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        
//设置request对应的 响应数据 response data
[self.client URLProtocol:self didLoadData:data];
        
//标记请求结束
[self.client URLProtocolDidFinishLoading:self];

所以上面的startLoading的完整版本应该是

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //标示该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //这个enableDebug随便根据自己的需求了,可以直接拦截到数据返回本地的模拟数据,进行测试
    BOOL enableDebug = NO;
    if (enableDebug) {
        
        NSString *str = @"测试数据";
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
    }
    else {
        //使用NSURLSession继续把request发送出去
        NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
        NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
        self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
        NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
        [task resume];
    }
}

上面采用NSURLSession发送的网络请求,所以实现NSURLSessionDelegate代理方法进行回调,我们可以做相应的处理,先看看NSURLSessionDelegate的代理方法:

//接收到返回信息时(还未开始下载), 执行的代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                 didReceiveResponse:(NSURLResponse *)response
                                  completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
//接收到服务器返回的数据 调用多次
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                     didReceiveData:(NSData *)data;
//请求结束或者是失败的时候调用
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                           didCompleteWithError:(nullable NSError *)error;

一般默认使用方式为:

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

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    // 打印返回数据
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (dataStr) {
        NSLog(@"***截取数据***: %@", dataStr);
    }
    [self.client URLProtocol:self didLoadData:data];
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    if (error) {
        [self.client URLProtocol:self didFailWithError:error];
    } else {
        [self.client URLProtocolDidFinishLoading:self];
    }
}

6. 结束

  • stopLoading

  在一个网络请求完全结束以后,NSURLProtocol回调用到。在该方法里,我们完成在结束网络请求的操作,以NSURLSession为例:

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

注意点:

1. 拦截AFNetworking

  目前为止,我们上面的代码已经能够监控到绝大部分的网络请求,但是呢,有一个却是特殊的,比如AFNetworking请求。

  因为AFNetworking网络请求的NSURLSession实例方法都是通过
sessionWithConfiguration:delegate:delegateQueue:方法获得的,我们是不能监听到的,
然而我们通过[NSURLSession sharedSession]生成session就可以拦截到请求,原因就出在NSURLSessionConfiguration上,我们进到NSURLSessionConfiguration里面看一下,他有一个属性:

@property (nullable, copy) NSArray<Class> *protocolClasses;

  我们能够看出,这是一个NSURLProtocol数组,上面我们提到了,我们监控网络是通过注册NSURLProtocol来进行网络监控的,但是通过sessionWithConfiguration:delegate:delegateQueue:得到的session,他的configuration中已经有一个NSURLProtocol,所以他不会走我们的protocol来,怎么解决这个问题呢? 其实很简单,我们将NSURLSessionConfiguration的属性protocolClassesget方法hook掉,通过返回我们自己的protocol,这样,我们就能够监控到通过sessionWithConfiguration:delegate:delegateQueue:得到的session的网络请求。
  所以对于AFNetworking中网络请求初始化方法可以修改为:

NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
//指定其protocolClasses
configuration.protocolClasses = @[[CustomURLProtocol class]];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:configuration];
//不采用manager初始化
//AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
[manager GET:@"http://www.baidu.com" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
        
} failure:^(NSURLSessionDataTask *task, NSError *error) {
        
}];

方式2: 也可以通过runtime来面向切面编程

#import <Foundation/Foundation.h>

@interface FFSessionConfiguration : NSObject
//是否交换方法
@property (nonatomic,assign) BOOL isExchanged;

+ (FFSessionConfiguration *)defaultConfiguration;
// 交换掉NSURLSessionConfiguration的 protocolClasses方法
- (void)load;
// 还原初始化
- (void)unload;

@end
#import "FFSessionConfiguration.h"
#import <objc/runtime.h>
#import "CustomURLProtocol.h"

@implementation FFSessionConfiguration

+ (FFSessionConfiguration *)defaultConfiguration {
    static FFSessionConfiguration *staticConfiguration;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        staticConfiguration=[[FFSessionConfiguration alloc] init];
    });
    return staticConfiguration;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.isExchanged = NO;
    }
    return self;
}

- (void)load {
    self.isExchanged=YES;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
    
}

- (void)unload {
    self.isExchanged=NO;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

- (void)swizzleSelector:(SEL)selector fromClass:(Class)original toClass:(Class)stub {
    Method originalMethod = class_getInstanceMethod(original, selector);
    Method stubMethod = class_getInstanceMethod(stub, selector);
    if (!originalMethod || !stubMethod) {
        [NSException raise:NSInternalInconsistencyException format:@"Couldn't load NEURLSessionConfiguration."];
    }
    method_exchangeImplementations(originalMethod, stubMethod);
}

- (NSArray *)protocolClasses {
    // 如果还有其他的监控protocol,也可以在这里加进去
    return @[[CustomURLProtocol class]];
}

@end

在使用时也很简单,采用下面方式注册

FFSessionConfiguration *sessionConfiguration = [FFSessionConfiguration defaultConfiguration];
[NSURLProtocol registerClass:[CustomURLProtocol class]];
if (![sessionConfiguration isExchanged]) {
    [sessionConfiguration load];
}

2. 关于不能拦截WKWebView

  原因是WKWebView在独立于app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用 NSURLProtocol无法拦截请求。但是如果真的想拦截,也是可以的, 具体可以参考NSURLProtocol对WKWebView的处理

Demo下载

已经有一个现成的第三库来做http mock了: https://www.jianshu.com/p/3c9638edf26f

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值