简书的NSURLProtocol踩坑总结

本文假设你已经对NSURLProtocol有所了解,已了解的建议阅读苹果的Sample Code CustomHTTPProtocol
简书使用NSURLProtocol在请求时添加ETag头信息、替换URL host为HTTPDNS得到的ip,在返回时进行SSL Pinning的证书校验,保证了网络请求的可用性和安全性。

简书的网络层结构

由于NSURLProtocol属于苹果的黑魔法,文档并不详细,有些教程和诸如“NSURLProtocol的坑”的文章本身也是有坑或不完善的,所以我们写下这篇文章来分享简书在NSURLProtocol的开发使用中遇到的误区和摸索出的更佳实践(注意:可能并不是最佳实践),欢迎在原文评论区指正。

+canonicalRequestForRequest:

canonical用于形容词时意为典范的、标准的,也就是说这个方法更希望返回的是一个标准的request,所以什么才算标准的request,这个方法到底用来干嘛


我们可以看下苹果示例CustomHTTPProtocol项目中的CanonicalRequestForRequest.h文件的注释

The Foundation URL loading system needs to be able to canonicalize URL requests for various reasons (for example, to look for cache hits). The default HTTP/HTTPS protocol has a complex chunk of code to perform this function. Unfortunately there's no way for third party code to access this. Instead, we have to reimplement it all ourselves. This is split off into a separate file to emphasise that this is standard boilerplate that you probably don't need to look at.

简单说就是要在这个方法里将request格式化,具体看它的.m文件,依次做了以下操作

  • 将scheme、host间的分隔符置为://
  • 将scheme置为小写
  • 将host置为小写
  • 如果host为空,置为localhost
  • 如果path为空,保证host最后带上/
  • 格式化部分HTTP Header

正如注释中所表达的,在我们用NSURLProtocol接管一个请求后,URL loading system已经帮不上忙了,需要自己去格式化这个请求。那么这里就有几个问题:
我们实际项目中到底需不需要在这里去做一遍格式化的工作呢?
大部分项目中的API请求应该都是由统一的基类封装发出来的,其实已经保证了request格式的正确和统一,所以这个方法直接return request;就可以了。
如果我就是希望在这里格式化一下呢?
如注释中所说,CanonicalRequestForRequest文件可以视为标准操作,直接拿到项目中用就好。
我可以在这个方法里去做HTTPDNS的工作,替换host吗?
如果使用了NSURLCache,这个方法返回的request决定了NSURLCache的缓存数据库中request_key值(数据库的路径在app的/Library/Caches/<bundle id>/Cache.db

普通缓存

在此修改过request后的缓存

所以,如果在这里替换为HTTPDNS得到的host,就可能存在服务端数据不变,但由于ip改变导致request_key不同而无法命中cache的情况。

-startLoading

这也是个比较容易出问题的方法,下边讲三个易错点。
、不要在这个方法所在的线程里做任何同步阻塞的操作,例如网络请求,异步请求+信号量也不行。具体原因文档中没有提及,但这会使方法里发出的请求和startLoading本身的请求最终超时。
、很多使用NSURLProtocol做HTTPDNS的教程或demo里都教在该方法里直接创建NSURLSession,然后发出去修改后的请求,类似于

// 注意:这是错误示范
- (void)startLoading {
    ...
    重新构造request
    ...
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request];
    [task resume];
}

当然,这个和NSURLProtocol本身关系不大了,而是NSURLSession的用法出现了严重错误。对于用途相同的request,应该只创建一个URLSession,可参考AFNetworking。每个request都创建一个URLSession是低效且不推荐的,可能会遇到各种无法预知的bug,而且最致命的是即使你在-stopLoading处调了finishTasksAndInvalidateinvalidateAndCancel,内存在短期内还是居高不下。
关于这个内存泄露的问题,推荐阅读苹果官方论坛的讨论StackOverFlow的回答。概括下来就是每个NSURLSession都会创建一个维持10min的SSL cache,这个cache由Security.framework私有,无论你在这里调什么方法都不会清掉这个cache,所以在10min内的内存增长可能是无限制的。
正确的姿势应该像CustomHTTPProtocol那样创建一个URLSession单例来发送里面的请求,或者像我一样依旧用NSURLConnection来发请求。
、如果问题二最后采用NSURLConnection发请求,那么在结合HTTPDNS获取ip时应该会出现形如以下的代码:

- (void)startLoading {
    ...
    [[HTTPDNSManager manager] fetchIp:^(NSString *ip) {
        ...替换host...
        self.connection = [NSURLConnection connectionWithRequest:theRequest delegate:self];
    }];
}

你会发现URLConnection能发出请求但回调并不会走,这个很好理解,因为URLConnection的回调默认和发起的线程相同,而发起是在-[HTTPDNSManager fetchIp:]的回调线程中,这个线程用完就失活了,所以解决这个问题的关键在于使URLConnection的回调在一个存活的线程中。乍一想有3种方案:1、将创建URLConnection放到startLoading所在的线程执行;2、用-[NSURLConnection setDelegateQueue:]方法设置它的回调队列;3、将创建URLConnection放到主线程执行,非常暴力,但是我确实见过这么写的。这3种方案其实只有第1种可用。先看下CustomHTTPProtocol的Read Me.txt(是的,NSURLProtocol的文档还没这个Sample Code的Readme详细),中间部分有一段:

In addition, an NSURLProtocol subclass is expected to call the various methods of the NSURLProtocolClient protocol from the client thread, including all of the following:

-URLProtocol:wasRedirectedToRequest:redirectResponse:
-URLProtocol:didReceiveResponse:cacheStoragePolicy:
-URLProtocol:didLoadData:
-URLProtocolDidFinishLoading:
-URLProtocol:didFailWithError:
-URLProtocol:didReceiveAuthenticationChallenge:
-URLProtocol:didCancelAuthenticationChallenge:

方案2的setDelegateQueue:显然是无法把delegateQueue精确到指定线程的,除非最后把URLConnection回调里面的方法再强行调到client线程上去,那样的话还不如直接用方案1。
继续看那个txt,还是上述引用的位置,往下几行有个WARNING:

WARNING: An NSURLProtocol subclass must operate asynchronously. It is not safe for it to block the client thread for extended periods of time. For example, while it's reasonable for an NSURLProtocol subclass to defer work (like an authentication challenge) to the main thread, it must do so asynchronously. If the NSURLProtocol subclass passes a task to the main thread and then blocks waiting for the result, it's likely to deadlock the application.

HTTPS请求在回调中需要验证SSL证书,离不开SecTrustEvaluate函数。可以看到SecTrustEvaluate的文档最后有个特别注意事项,第二段写道

Because this function might look on the network for certificates in the certificate chain, the function might block while attempting network access. You should never call it from your main thread; call it only from within a function running on a dispatch queue or on a separate thread.

所以使用方案3很有可能在SecTrustEvaluate时阻塞掉主线程。

看了这么多错误示范,下边来看方案1-startLoading:里做host替换的正确示范:

@property(atomic, strong) NSThread *clientThread;
@property(atomic, strong) NSURLConnection *connection;

- (void)startLoading {
    NSMutableURLRequest *theRequest = [self.request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:APIProtocolHandleKey inRequest:theRequest];
    
    self.clientThread = [NSThread currentThread];
    [[HTTPDNSManager manager] fetchIp:^(NSString *ip) {
        if (ip) {
            [theRequest setValue:self.request.URL.host forHTTPHeaderField:@"Host"];
            
            NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:theRequest.URL
                                                        resolvingAgainstBaseURL:YES];
            urlComponents.host = ip;
            theRequest.URL = urlComponents.URL;
        }
        
        [self performBlockOnStartLoadingThread:^{
            self.connection = [NSURLConnection connectionWithRequest:theRequest delegate:self];
        }];
    }];
}

- (void)performBlockOnStartLoadingThread:(dispatch_block_t)block {
    [self performSelector:@selector(onThreadPerformBlock:)
                 onThread:self.clientThread
               withObject:[block copy]
            waitUntilDone:NO];
}

- (void)onThreadPerformBlock:(dispatch_block_t)block {
    !block ?: block();
}

request.HTTPBody

在NSURLProtocol中取request.HTTPBody得到的是nil,并不是因为body真的被NSURLProtocol抛弃了之类的,可以看到发出去的请求还是正常带着body的。
除非你的NSURLProtocol是用于Mock时根据HTTPBody中的参数来返回不同的模拟数据,否则大多数情况是不需要在意这点的。这也不是苹果的bug,只是body数据在URL loading system中到达这里之前就已经被转成stream了。如果必须的话,可以在request.HTTPBodyStream中解析它。

推荐阅读

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

推荐阅读更多精彩内容

  • 本文是逐行翻译,便于参照原文,如有歧义或者疑问请阅读原文比较。于 2017.1.25===============...
    Auditore阅读 1,444评论 4 4
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • Swift版本点击这里欢迎加入QQ群交流: 594119878最新更新日期:18-09-17 About A cu...
    ylgwhyh阅读 24,858评论 7 249
  • 生命走向终点的人们会被迫着回顾自己的一生。不过在这儿,回忆即遗忘。你所记得的关于自己的一切,当你走出这扇门时,都会...
    Penicillin00阅读 418评论 0 0
  • 文 艾米 小猫咪 远远地 你杏目奕奕地望着我 我也望着你 你在好奇 对面的那个人类拿的什么武器 对着我拍 这儿...
    月影清韵阅读 944评论 93 103