背景
公司最近在搞QUIC迁移,还在处于AB放量实验观察阶段,如何集成QUIC可参考iOS 集成 Cronet 方法和测试过程 。
一天,QA同学找到我,说是线上用户身份认证上传照片总是超时失败。根据QA同学的描述,发现问题是必现的😲。
简单快速排查
查看方法是 POST,如果不走 Cronet 的话正常,走了 Cronet 就出现问题。索性直接让所有的 POST方法不走 Cronet,问题先到此解决。
深入排查
❓疑问:难道所有的POST方法都有问题吗?
查了下其他走 Cronet 的 POST方法,都是正常的。出现POST超时还有一个是简历文件上传的接口。
说明上传文件的POST方法都有问题。
❓疑问:是WiFi和蜂窝网络的问题吗?
分别试验了 公司WiFi、家里WiFi和蜂窝网络,都是100%超时必现。
❓疑问:我们iOS App Cronet QUIC 基于 AFN 集成实现,那么针对文件POST上传,AFN是如何实现的呢?
在AFN层,通过调用 -[AFHTTPRequestSerializer multipartFormRequestWithMethod:URLString:parameters:constructingBodyWithBlock:error:]方法,执行constructingBodyWithBlock操作参数formData
通过调用
-[AFStreamingMultipartFormData requestByFinalizingMultipartFormData]方法对formData数据进行加工
我们注意到这里 给
request传入的是 HTTPBodyStream类型。Content-Type设置的值是multipart/form-data; boundary=xxxx。
最后通过调用系统API方法-[NSURLSession uploadTaskWithStreamedRequest:]发起网络请求
实现看起来并不复杂
❓疑问:难道是 Content-Type 类型是 multipart/form-date 的问题吗?
带着疑问,分析了也是 POST方法的 信息流的接口情况。
业务上层通过调用 -[NetManager POSTWithRequest:completion:]传入 request实现
在AFN层,调用
-[AFHTTPRequestSerializer requestBySerializingRequest:withParameters:error:]方法,在 通过HTTPMethodsEncodingParametersInURI判断不是GET, HEAD和 DELETE方法之后,就走到了else逻辑,POST就在else里处理。
可以看到,
request传入的是 HTTPBody类型。Content-Type设置的值是application/x-www-form-urlencoded
最后通过 -[NSURLSession dataTaskWithRequest:调用系统API完成网络请求
我们可以试着强行把 Content-Type的值换成 multipart/form-data; boundary=xxxx, 如下图
数据正常返回,说明不是
Content-Type值类型的问题
❓疑问:难道是系统 API -[NSURLSession dataTaskWithRequest:] 和 -[NSURLSession uploadTaskWithStreamedRequest:]方法在Cronet实现的问题?比如 -[NSURLSession uploadTaskWithStreamedRequest:] 没有实现什么的
源码编译指南:chromium.googlesource.com/chromium/sr…
通过查询Cronet源码,全都是C++实现,通常情况下并不会出现 OC 的 API。
❓疑问:Cronet 是有运行日志的,能否通过log看出问题所在
设置记录日志开关
NSString *logName = @"cronet-consumer-net-log.json";
// 记录 Cronet log
[Cronet startNetLogToFile:logName logBytes:NO];
NSString *logPath = [Cronet getNetLogPathForFile:logName];
导出沙盒日志
查看日志信息
摘取其中有用的信息,如下:
日志看不出问题,都是常规信息打印
所有能想到的办法都用了,还是问题依旧。看来只能研究Cronet的源码了😳
源码实现
Cronet全程使用C++来编写,采用 NSRULProtocol 切面编程的方式接管了 NSURLSession的系统级API网络请求。通过分析,找到了核心重点crn-http_protocol_handler实现代码(chromium.googlesource.com/chromium/sr…)
void HttpProtocolHandlerCore::Start(id<CRNNetworkClientProtocol> base_client) {
DCHECK(thread_checker_.CalledOnValidThread());
DCHECK(!client_);
DCHECK(base_client);
client_ = base_client;
// 获取URL
GURL url = GURLWithNSURL([request_ URL]);
// Now that all of the network clients are set up, if there was an error with
// the URL, it can be raised and all of the clients will have a chance to
// handle it.
// 判断url合法性
if (!url.is_valid()) {
DLOG(ERROR) << "Trying to load an invalid URL: "
<< base::SysNSStringToUTF8([[request_ URL] absoluteString]);
[client_ didFailWithNSErrorCode:NSURLErrorBadURL
netErrorCode:ERR_INVALID_URL];
return;
}
// 获取request上下文
const URLRequestContext* context =
g_protocol_handler_delegate->GetDefaultURLRequestContext()
->GetURLRequestContext();
DCHECK(context);
// 创建 request ,并设置 request 优先级 DEFAULT_PRIORITY
net_request_ =
context->CreateRequest(url, DEFAULT_PRIORITY, this).release();
// 设置 request HTTPMethod
net_request_->set_method(base::SysNSStringToUTF8([request_ HTTPMethod]));
// 设置 request cookies
net_request_->set_site_for_cookies(
net::SiteForCookies::FromUrl(GURLWithNSURL([request_ mainDocumentURL])));
#if !defined(NDEBUG)
DVLOG(2) << "From client:";
LogNSURLRequest(request_);
#endif // !defined(NDEBUG)
// 设置 request http headers
CopyHttpHeaders(request_, net_request_);
[client_ didCreateNativeRequest:net_request_];
SetLoadFlags();
// 设置 request SSL 证书 鉴权配置
net_request_->set_allow_credentials([request_ HTTPShouldHandleCookies]);
// https://crbug.com/979324 If the application app sets HTTPBody, then system
// creates new NSInputStream every time HTTPBodyStream is called. Get the
// stream here and hold on to it.
// 读取 request HTTPBodyStream
http_body_stream_ = [request_ HTTPBodyStream];
if (http_body_stream_) {
DCHECK(![request_ HTTPBody]);
http_body_stream_delegate_ =
[[CRWHTTPStreamDelegate alloc] initWithHttpProtocolHandlerCore:this];
[http_body_stream_ setDelegate:http_body_stream_delegate_];
DVLOG(1) << "input_stream " << http_body_stream_ << " delegate "
<< [http_body_stream_ delegate];
// 设置 runloop ,在调用open之后持续读取 stream 流
[http_body_stream_ scheduleInRunLoop:[NSRunLoop currentRunLoop]
forMode:NSDefaultRunLoopMode];
// 开始读取 stream 流
[http_body_stream_ open];
// stream 读取是否完毕的判断
if (net_request_->extra_request_headers().HasHeader(
HttpRequestHeaders::kContentLength)) {
// The request will be started when the stream is fully read.
return;
}
// 创建upload,设置 request uploader
std::unique_ptr<ChunkedDataStreamUploader> uploader =
std::make_unique<ChunkedDataStreamUploader>(this);
chunked_uploader_ = uploader->GetWeakPtr();
net_request_->set_upload(std::move(uploader));
} else if ([request_ HTTPBody]) { // 读取 request HTTPBody
DVLOG(1) << "HTTPBody " << [request_ HTTPBody];
NSData* body = [request_ HTTPBody];
const NSUInteger body_length = [body length];
if (body_length > 0) {
// 创建readler,设置 request reader
const char* source_bytes = reinterpret_cast<const char*>([body bytes]);
std::vector<char> owned_data(source_bytes, source_bytes + body_length);
std::unique_ptr<UploadElementReader> reader(
new UploadOwnedBytesElementReader(&owned_data));
net_request_->set_upload(
ElementsUploadDataStream::CreateWithReader(std::move(reader), 0));
}
}
// 发起网络请求,具体是一个个的job
net_request_->Start();
}
问题分析
这里我们看到了 对 [request_ HTTPBodyStream]和 [request_ HTTPBody]处理方式,想到 multipart/form-data数据就是设置到了 HTTPBodyStream
查看
self.bodyStream的类型是 AFMultipartBodyStream, AFMultipartBodyStream有什么特殊之处吗?
通过阅读 AFMultipartBodyStream的实现,我们发现了关键点。
-[NSInputStream scheduleInRunLoop:forMode:] 和 -[NSInputStream removeFromRunLoop:forMode:]方法实现居然为 {} 😲
问题关键点已经很明朗了, 在Cronet处理
[request_ HTTPBodyStream] 的时候调用 -[NSInputStream scheduleInRunLoop:forMode:]什么都没做,直接就在 判断 stream 是否读取完毕的时候return了。😒
// 设置 runloop ,在调用open之后持续读取 stream 流
[http_body_stream_ scheduleInRunLoop:[NSRunLoop currentRunLoop]
forMode:NSDefaultRunLoopMode];
// 开始读取 stream 流
[http_body_stream_ open];
// stream 读取是否完毕的判断
if (net_request_->extra_request_headers().HasHeader(
HttpRequestHeaders::kContentLength)) {
// The request will be started when the stream is fully read.
return;
}
这也就解释了,为什么上传文件无请求,一直到等到 15s 超时。
尝试解决
如果只是在 AFMultipartBodyStream 的实现上做文章,是修复不了的。
- (void)scheduleInRunLoop:(__unused NSRunLoop *)aRunLoop
forMode:(__unused NSString *)mode
{}
- (void)removeFromRunLoop:(__unused NSRunLoop *)aRunLoop
forMode:(__unused NSString *)mode
{}
只能是改Cronet的源码,但成本比较高。如果自己修好,稳定性各方面有待全面测试。
出于成本的考虑,尝试在App集成这块下功夫,规避 AFMultipartBodyStream类型的 HTTPBodyStream
尝试规避
// 是否要使用 Cronet 拦截指定请求
[Cronet setRequestFilterBlock:^BOOL(NSURLRequest* request) {
// 非 HTTP/HTTPS请求,不拦截
if (([request.URL.scheme isEqualToString:@"http"] || [request.URL.scheme isEqualToString:@"https"]) == NO) return NO;
// 专门排除的打点url,不拦截
if ([self isLogTask:request.URL.absoluteString] == YES) return NO;
// multipart/form-data AFN 重写NSInputStream的子类AFMultipartBodyStream 和 Cronet `void HttpProtocolHandlerCore::Start(id<CRNNetworkClientProtocol> base_client)` 实现不兼容,不拦截
// source: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/ios/net/crn_http_protocol_handler.mm
if ([request.HTTPBodyStream class ] == NSClassFromString ( @"AFMultipartBodyStream" )) {
return NO ;
}
// 剩余的就会拦截
return YES;
}];
一些讨论
1. iOS Cronet 是怎么集成的?使用的源码吗?如果是源码,可不可以直接改Cronet的源码来修复这个问题?
答: 正如 iOS 集成 Cronet 方法和测试过程 提到的,源码编译非常困难,编译不过。
我们的App集成Cronet 是直接使用官方编译好的静态库Framework包。无法修改源代码。
2. 在核心代码 crn-http_protocol_handler 的 Start方法里还能看出什么问题吗?
答: 还有一个问题。
正如下面的代码写的那样,它会一直读取stream流,假如这个流有1G的大小。
这时,会很大概率造成内存oom问题。不知道Apple QUIC是怎么处理stream流大小的问题。
// stream 读取是否完毕的判断
if (net_request_->extra_request_headers().HasHeader(
HttpRequestHeaders::kContentLength)) {
// The request will be started when the stream is fully read.
return;
}
3. 现在Cronet还有哪些问题?能提前预知吗?
答: 目前,只发现了 AFN 的 multipart/form-data使用AFMultipartBodyStream和Cronet有兼容问题,其他的需要持续观察线上用户反馈。
还不能做到提前预知,除非完全研究懂Cronet的源码。