iOS Cronet QUIC兼容性解决案例 - AFN 文件上传 POST 方法超时

794 阅读6分钟

背景

公司最近在搞QUIC迁移,还在处于AB放量实验观察阶段,如何集成QUIC可参考iOS 集成 Cronet 方法和测试过程

一天,QA同学找到我,说是线上用户身份认证上传照片总是超时失败。根据QA同学的描述,发现问题是必现的😲。

简单快速排查

查看方法是 POST,如果不走 Cronet 的话正常,走了 Cronet 就出现问题。索性直接让所有的 POST方法不走 Cronet,问题先到此解决。

深入排查

❓疑问:难道所有的POST方法都有问题吗?

查了下其他走 CronetPOST方法,都是正常的。出现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实现 image.png 在AFN层,调用 -[AFHTTPRequestSerializer requestBySerializingRequest:withParameters:error:]方法,在 通过HTTPMethodsEncodingParametersInURI判断不是GET, HEADDELETE方法之后,就走到了else逻辑,POST就在else里处理。

可以看到,request传入的是 HTTPBody类型。Content-Type设置的值是application/x-www-form-urlencoded

最后通过 -[NSURLSession dataTaskWithRequest:调用系统API完成网络请求

我们可以试着强行把 Content-Type的值换成 multipart/form-data; boundary=xxxx, 如下图

image.png 数据正常返回,说明不是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];

导出沙盒日志

image.png

查看日志信息

摘取其中有用的信息,如下: image.png 日志看不出问题,都是常规信息打印

所有能想到的办法都用了,还是问题依旧。看来只能研究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_handlerStart方法里还能看出什么问题吗?

答: 还有一个问题。

正如下面的代码写的那样,它会一直读取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的源码。