Cronet网络库系列(一):用例与原理实现详解

蒂卡波湖牧羊犬

背景

对于小程序框架这种既有业务API请求,又有web请求。Android平台上不管是系统HttpUrlConnection还是OkHttp都无法满足完全优化的需求,所以在使用自研webview基础上,有了使用Chromium网络栈优化的想法。以下引用是Cronet官方介绍翻译。

Cronet是Chromium的网络堆栈,已单独编译成库供移动设备使用。这与十亿多人在Chrome浏览器中使用的网络堆栈相同。它提供了一种易于使用,高性能,符合标准且安全的方式来执行HTTP请求。.

Cronet是Chromium网络引擎对不同操作系统做的封装,实现了移动端应用层、表示层、会话层协议,支持HTTP1/2、SPDY、QUIC、WebSocket、FTP、DNS、TLS等协议标准。支持Android、IOS、Chrome OS、Fuchsia,部分支持Linux、MacOS、Windows桌面操作系统。实现了Brotli数据压缩、预连接、DNS缓存、session复用等策略优化以及TCP fast open等系统优化。本文内容基于Chromium 75版本。

独立编译

  1. 下载完整的chromium内核的源码:https://www.chromium.org/deve...

    调试版

  2. 生成ninja文件(类似Makefile)

    Android / iOS builds
    $ ./components/cronet/tools/cr_cronet.py gn --out_dir=out/Cronet
    
    Desktop builds (targets the current OS)
    $ gn gen out/Cronet
  3. 编译
    $ ninja -C out/Cronet cronet_package

    发布版

    $ ./components/cronet/tools/cr_cronet.py gn --release
    $ ninja -C out/Release cronet_package
  4. 集成
    生成的库在out/Release/cronet目录下,项目中引入jar包以及libs/armeabi-v7a/libcronet.75.0.3770.150.so(Android)或者Cronet.framework(IOS).Android平台jar包1M,SO 2.7M.
    如果要单独编译dex包做插件加载,建议引入cronet_api.jar作为接口层、其他三个jar打成dex通过classloader动态加载。打dex的方法,Chromium 75已经不支持dx,要用新的d8工具。具体命令是:
    java -jar third_party/r8/lib/d8.jar --release --output dex.jar --lib third_party/android_sdk/public/platforms/android-xx/android.jar --lib third_party/android_sdk/public/extras/android/support/v4/android-support-v4.jar cronet_implxxx.jar cronet_implxxx.jar
    图片描述
官方编译参考:https://chromium.googlesource...
多平台编译实践:https://www.twblogs.net/a/5d7...

Android平台功能示例

发起请求基本流程

// 初始化引擎
CronetEngine.Builder myBuilder = new CronetEngine.Builder(context);
CronetEngine cronetEngine = myBuilder.build();

// 创建请求线程
Executor executor = Executors.newSingleThreadExecutor();

// 创建UrlRequest
UrlRequest.Builder requestBuilder = cronetEngine.newUrlRequestBuilder(
        "https://www.example.com", new MyUrlRequestCallback(), executor);
UrlRequest request = requestBuilder.build();

// 发起请求
request.start();

// 网络状态回调
class MyUrlRequestCallback extends UrlRequest.Callback {
  private static final String TAG = "MyUrlRequestCallback";

  @Override
  public void onRedirectReceived(UrlRequest request, UrlResponseInfo info, String newLocationUrl) {
    android.util.Log.i(TAG, "onRedirectReceived method called.");
    // You should call the request.followRedirect() method to continue
    // processing the request.
    request.followRedirect();
  }

  @Override
  public void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
    android.util.Log.i(TAG, "onResponseStarted method called.");
    // You should call the request.read() method before the request can be
    // further processed. The following instruction provides a ByteBuffer object
    // with a capacity of 102400 bytes to the read() method.
    request.read(ByteBuffer.allocateDirect(102400));
  }

  @Override
  public void onReadCompleted(UrlRequest request, UrlResponseInfo info, ByteBuffer byteBuffer) {
    android.util.Log.i(TAG, "onReadCompleted method called.");
    // You should keep reading the request until there's no more data.
    request.read(ByteBuffer.allocateDirect(102400));
  }

  @Override
  public void onSucceeded(UrlRequest request, UrlResponseInfo info) {
    android.util.Log.i(TAG, "onSucceeded method called.");
  }
}

回调状态描述

  1. 应用程序调用start()方法后,生命周期处于“已启动”状态。
  2. 服务器可以发送重定向响应,该响应将流转移到onRedirectReceived()方法。在此方法中,您可以执行以下客户端操作之一:

    • 使用followRedirect()跟随重定向。此方法将请求恢复为已启动状态。
    • 使用cancel()取消请求。此方法将请求带到onCanceled()方法,其中应用程序可以在请求移动到Canceled final状态之前执行其他操作。
  3. 应用程序跟随所有重定向后,服务器发送响应头并调用onResponseStarted()方法。请求处于Waiting for read()状态。应用程序应该调用read()方法来尝试读取部分响应主体。调用read()后,请求处于读取状态,其中有以下可能的结果:

    • 读取response是成功的,但有更多的数据可用。调用onReadCompleted()并且请求再次处于Waiting for read()状态。应用程序应再次调用read()方法以继续读取响应正文。应用程序也可以使用cancel()方法停止读取请求。
    • 读取response是成功的,没有更多的数据可用。调用onSucceeded()方法,请求现在处于Succeeded最终状态。
    • 读取response失败了。调用onFailed方法,请求的最终状态现在为Failed。

流程图如下:

The Cronet request lifecycle

数据上传

MyUploadDataProvider myUploadDataProvider = new MyUploadDataProvider();
requestBuilder.setHttpMethod("POST");
requestBuilder.setUploadDataProvider(myUploadDataProvider, executor);

MyUploadDataProvider继承UploadDataProvider.当Cronet发送request body时会调用myUploadDataProvider.read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer)将byteBuffer的数据分段发送,结束写入byteBuffer需要client调用uploadDataSink.onReadSucceeded通知Cronet. 更多细节参考API文档.

配置CronetEngine

Enabling HTTP/2 and QUIC:

    engineBuilder.enableHttp2(true).enableQuic(true);

Controlling the cache:

  engineBuilder.setStoragePath(storagePathString);
  engineBuilder.enableHttpCache(CronetEngine.Builder.HttpCache.DISK,
          1024 * 1024);

NetLog调试日志

client可以通过CronetEngine.startNetLogToFile以及CronetEngine.stopNetLog将网络日志保存,通过chrome://net-internals#import导入日志进行分析.

API以及示例参考资料

Android官方示例:https://developer.android.com...
Android Cronet API:https://developer.android.com...
本地编译Android API: //src/components/cronet/android/api/
IOS Sample: //src/components/cronet/ios/test/
Naitve Sample: //src/components/cronet/native/sample/test/

性能数据

用例1:Cronet 75 vs OkHttp 3.11
1)Http请求耗时。单次请求均值Cronet与OkHttp一致,主要取决于网速。10次并发请求均值Cronet落后于OkHttp。
2) Https请求耗时。单次请求均值Cronet优于OkHttp。10次并发请求均值Cronet与OkHttp一致,但是Cronet先到和后到请求差异较大。

源码:https://github.com/bojie-liu/CronetPerformanceSample
详细数据见:https://docs.qq.com/sheet/DZFhURWR1U1pMaVhO?tab=BB08J2&c=C12A0N0

用例2:蘑菇街实践.针对 Chromium 网络库和系统网络库做了测试,得到了如下数据:
参考资料:https://www.infoq.cn/article/...
图片描述

用例3:腾讯实践,QUIC对比HTTP2.
参考资料:https://cloud.tencent.com/dev...

原理实现

整体架构

这里从两个角度剖析网络栈实现 (以Android平台为例).一是架构上从顶至底分层,明确Cronet API到系统socket调用每一层的职责,方便将抽象的需求或功能实现对应到相应的模块相应层级中.二是流程上说明URLRequest.start到数据返回在每一层执行了什么逻辑,方便对细节进行把握.
第一部分:
架构:

  • 平台接口层.提供各平台Cronet API,隐藏底层实现.
  • 平台实现层.负责Cronet库加载,封装系统网络库提供应用选择.负责C层JNI接口与Android Java的适配转换,包括请求类CronetUrlRequest发起请求、将状态回调抛到Client传入的Executor的线程执行.Cronet引擎上下文CronetUrlRequestContext加载库、设置netlog,cache、监听网络状态、管理回调线程的Executor.
  • 共用接口层.一是提供统一的C++接口给各平台上层调用.二是负责C层网络栈接口与JNI层的适配转换,包括CronetURLRequestAdapter将请求抛到内核NetWork线程执行、对C/Java进行数据转换、创建IOBufferWithByteBuffer将返回数据直接放在Java ByteBuffer中避免拷贝.包括CronetURLRequestContextAdapter持有网络栈Context、初始化NetWork线程、数据转换.
  • 网络栈接口层.此层及以下的代码都在/net目录下,负责提供网络请求接口隐藏底层网络协议的实现,上层应用包括Cronet、WebView资源加载、devtools调试请求等。URLRequest创建并执行不同的底层URLRequestJob、提供上层获取网络状态和数据、代理服务器信息等。URLRequestContext网络栈上下文,持有一大堆全局网络模块例如CookieStore、NetLog、HostResolver域名解析、NetworkThrottleManager请求优先级管理等。

屏幕截图.png

流程:
http网络请求

  1. URLRequest::Start首先调用URLRequestJobManager::CreateJob根据不同的scheme创建Job,优先从Cronet Client设置的自定义job_factory创建,没有则从kBuiltinFactories(http/https,ws/wss)中创建。这里是URLRequestHttpJob,然后初始化job并且设置URLRequest状态,最后调URLRequestHttpJob::Start。

URLRequestContext初始化

  1. 在URLRequestContextBuilder::Build中对一大堆网络功能的初始化。包括自定义的UA、host_resolver、ssl_config_service、cookie_store、安全证书相关、代理服务配置、http_cache设置、data/file/ftp协议的JobFactory等。

第二部分
架构:

  • URLRequestxxxJob:负责不同scheme的request/response数据管理,具体网络协议由底层实现。例如DataJob解析url的charset/data构造response返回;HttpJob管理cookie、通过HttpNetworkTransaction拉取response、保存auth_credential等。
  • TransactionFactory:创建具体的xxxTransaction工厂类。
  • HttpTransaction:表示一次Http请求响应过程,定义了Start/RestartWithAuth/Read以及回调抽象接口。例如子类HttpNetworkTransaction状态机除了驱动HttpStream建立连接,还负责Http身份验证处理与重试,HttpStream是否keep_alive等。
  • HttpNetworkSession:http scheme请求过程全局的Context,包括http/quic协议的配置、SocketPool/HostResolver/ProxyService等所有http请求都用到的模块。在URLRequestContextBuilder中创建并保存在URLRequestContextStorage中。
  • HttpStream:http/quic/websocket协议的共同抽象类,定义了发送请求、读取响应、获取连接状态等接口,由不同协议具体实现。以HttpBasicStream为例,持有读取响应buffer,持有HttpStreamParser状态机调度发送header/body、接收响应,持有ClientSocketHandle具体的socket连接。
  • StreamFactory:建立HttpStream连接的工厂类。例如HttpStreamFactory的RequestStream里面会解析代理信息(JobController),创建ClientSocketHandler解析Host、connect服务器,封装在HttpStream中返回给上层。

屏幕截图.png

流程:
http网络请求

  1. URLRequestHttpJob::Start。设置额外的Header例如kCookie、UA,如果已有transaction则重新建立连接,否则调用HttpCache::CreateTransaction根据config初始化disk_cache backend,可能是内存缓存MemBackend或者文件缓存SimpleBackend、创建transaction并启动。
  2. HttpCache::Transaction::Start。启动状态机通过DoLoop在不同状态间异步流转。
    2.1 确保backend存在并且通过url构造缓存key。
    2.2 用key查找backend缓存entry,如果没有则初始化HttpCache::ActiveEntry并且加到backend中并且流转到SEND_REQUEST状态,因为backend可能在IO线程执行,所以增删改查都是抽象成WorkItem在HttpCache的pending_queue执行,其中cache支持HTTP range requests。
    2.3 DoSendRequest创建HttpNetworkTransaction并执行Start发起请求。
    2.4 依次将服务器返回的Header以及response写入cache中,
  3. HttpNetworkTransaction::Start。同样是个DoLoop状态机,大致分为走HttpNetworkSession建立连接,走HttpStream读取数据两步。
    3.1 DoCreateStream,通过HttpStreamFactory以及ClientSocketHandler创建HttpStream跟服务器建立socket连接,在HttpNetworkTransaction::OnStreamReady回调中保存stream_、SSL信息server_ssl_config_、代理服务信息proxy_info_。
    3.2 DoInitStream初始化stream_,如果是HttpBasicStream则用已连接的socket创建HttpStreamParser,如果是QuicHttpStream则保存SSL信息并且等待Handshake建立。
    3.3 DoGenerateServerAuthToken生成AuthToken,如果是https则用用户名和密码生成代理或直连服务器的AuthToken。
    3.4 DoSendRequest调用HttpStreamParser::SendRequest发送请求。
    3.5 DoReadHeaders接收Header信息。调用底层HttpStreamParser::DoReadHeaders读取并解析头信息。然后根据网络错误或者response_code流转下一步状态。
    3.6 底层socket接收Body数据。回调顶层Client的onReadCompleted或onSucceeded。
    3.7 顶层Client调用CronetUrlRequest.read(ByteBuffer)。如果HttpCache存在缓存entry并且该url允许读缓存,则从backend中ReadData;否则继续往下调HttpStream::ReadResponseBody(这里是HttpBasicStream)读取网络请求数据,返回缓冲区read_buf_以及调用底层stream_socket_->Read的数据。

第三部分
架构:

  • ClientSocketPool:负责创建ConnectJob并管理Socket池状态,提供外部可用的Socket连接。主要持有一个GroupMap,key是字符串(连接类型+host+port)用于区分各种 可复用的Socket连接,例如"ssl/$(hostport)"。Group持有空闲Socket、ConnectJobs以及pending_requests。
  • xxxConnectJob:负责创建不同类型的Socket并建立连接。
  • xxxClientSocket:不同传输类型的Socket,底层都是持有linux socket fd。具体子类有DatagramClientSocket(UDPClientSocket)、StreamSocket(TCPClientSocket、SOCKS5ClientSocket、SSLSocket等)。

屏幕截图.png

流程:
HttpNetworkTransaction::DoCreateStream建立连接(第二部分3.1),会走到HttpStreamFactory::JobController::DoLoop状态机处理。

  1. ProxyResolutionService::ResolveProxy解析代理服务器。
    1.1 初始化获取系统代理配置。读取环境变量scheme.proxyHost以及scheme.nonProxyHost同时监听Android系统PROXY_CHANGE_ACTION消息,从系统ConnectivityManager更新代理以及PAC script_url。
    1.2 TryToCompleteSynchronously简单判断特殊url走直连,例如localhost。另外检查代理配置是否存在此url,如果符合直接返回。
    1.3 调ProxyResolver::GetProxyForURL获取ProxyInfo代理信息。对于Cronet库,CronetUrlRequestContext初始化时通过调ProxyResolutionService::CreateWithoutProxyResolver创建ProxyResolverFactoryForNullResolver,NullResolver表示不存在后端resolver,所以直接返回。
  2. HttpStreamFactory::JobController::DoCreateJobs创建HttpStream并且建立连接。
    2.1 如果是预连接preconnect。如果存在Alternative Services则用替代protocol, host, port创建HttpStreamFactory::Job,否则根据原始请求创建Job,Job会带上PRECONNECT类型。然后调job.Preconnect(),最后进入HttpStreamFactory::Job::DoLoop状态机建立连接。
    2.2 如果是正常请求。如果存在Alternative Services,则CreateAltSvcJob并且job.Start()启动。然后CreateMainJob并且调main_job_->Start(),在Job::DoLoop中初始化连接创建HttpStream。(这里可能同时启动两个job,根据注释是为了保险起见启动main_job)
    2.3 Job::DoInitConnection。
    2.3.1 如果是QUIC,根据proxy_info_设置代理调QuicStreamRequest::Request,用QuicSessionKey查找是否有可复用QuicChromiumClientSession,没有则正常请求,调用不同实现的verifier_->Verify()验证证书,调用QuicStreamFactory::Job::DoLoop。首先DoResolveHost通过HostResolver(StaleHostResolver for cronet)创建并启动域名解析请求,最终会调DnsTransactionImpl::StartQuery用TCPClientSocket/UDPClientSocket/HttpRequest其中一种方式请求DNS信息。然后DoConnect创建UDPClientSocket调Connect;创建QuicChromiumClientSession初始化QuicConfig;配置quic协议数据字段长度,调session_->StartReading()启动for循环读取UDP数据包;调QuicCryptoClientHandshaker::DoHandshakeLoop建立握手,其中包括发送CHLO接收REJ、验证签名、从加密信息中获取ChannelID以及更新服务器配置。最后将request以及session保持在QuicStreamFactory中。
    2.3.2 如果是spdy,则调SpdySessionPool::RequestSession,完成后回调OnSpdySessionAvailable将spdy_session设置在HttpStreamFactory::Job中。
    2.3.3 如果是http,分PRECONNECT、websocket、普通http三种情况。普通http情况下,首先在ClientSocketPoolManager::InitSocketPoolHelper中根据socket类型(tls/http/ftp)、代理、ssl、隐私模式等创建SocketParams以及可共用socket的GroupId,然后传到TransportClientSocketPool::RequestSocket中请求socket。第一步在group_map_清理断链的socket,查找是否有可复用socket,有则将socket包装在ClientSocketHandle中传给上层HttpStreamFactory。没有则执行第二步创建TransportConnectJob并调用DoLoop依次解析域名、创建TCPClientSocket(父类是StreamSocket)并且调用Connect。域名解析跟2.3.1QUIC的DoResolveHost流程一样,Connect会调用持有的底层平台socket API,实现open/bind/connect,在此是SocketPosix示例。最后将TCPClientSocket保存在ClientSocketHandle中。
    2.4 Job::DoCreateStream。如果非spdy,将ClientSocketHandle std::move到持有的HttpBasicStream中。如果是spdy,则用SpdySession创建SpdyHttpStream保存在持有的HttpStream中。
    2.5 HttpNetworkTransaction异步接收Job的回调结果。因为JobController继承Job::Delegate实现OnStreamReady、OnNeedsProxyAuth、OnInitConnection等接口,而HttpNetworkTransaction又是JobController的Delegate,所以在Job::DoLoop每个状态结束后都会回调上层,最终HttpStream被std::move到HttpNetworkTransaction中。

HttpNetworkTransaction::DoSendRequest通过HttpStream发送请求内容(第二部分3.4)。

  1. HttpStreamParser::SendRequest。如果header+body<=1400bytes则合并填充到DrainableIOBuffer的request_headers_中,header通过memcpy复制,body通过调Cronet Client的UploadDataProvider.read()将stream读到request_headers_的地址中。
  2. DoSendHeaders。调用SocketPosix::DoWrite,HANDLE_EINTR(send(socket_fd_, request_headers_, buf_len, MSG_NOSIGNAL)),HANDLE_EINTR宏在send返回-1或触发EINTR时继续send,否则退出循环。如果send返回EWOULDBLOCK或者EAGAIN表示socket被设置为nonblocking,但是send操作被block,此时会调MessagePumpLibevent::WatchFileDescriptor用libevent库的epoll监听该socket的fd是否ready to write,ready之后在回调中重新send写入数据。如果send正常返回>=0,或errno为0则返回OK。
  3. DoSendBody。如果upload_data_stream还存在内容则读出来放在request_body_send_buf_调SocketPosix::DoWrite发出。
  4. DoReadHeaders。调SocketPosix::Read,跟DoWrite一样会用WatchFileDescriptor监听读状态并重试。
  5. Cronet Client主动调read会到HttpStreamParser::DoReadBody。将read_buf_中header剩余的buffer读到user_read_buf_中,再从SocketPosix读数据。
  6. DoReadBodyComplete。根据Content-Length、chunked encoding以及接收到的body大小处理buffer以及结束状态,如果DoReadBody返回<=0或者(chunked_decoder_读到EOF或者body>=Content-Length)则正常结束返回上层HttpNetworkTransaction::DoReadBodyComplete,调stream_->Close(!keep_alive),keep_alive取决于IsResponseBodyComplete()并且CanReuseConnection(),继而调到底层平台socket的close。

更多细节

HttpCache
HttpCache::Transaction检查是否可以从缓存中提供请求。如果需要重新验证请求,它将发送更新请求。它还可能将range request分为多个缓存的和非缓存的连续块,并且可能针对单个range request发出多个网络请求。
HttpCache::Transaction使用以下三类disk_cache::Backends存储缓存的索引和文件:内存,块文件缓存和Simple Cache文件缓存。第一个用于incognito模式。后两者都存储在磁盘上。
每个URL都有一个读/写锁。从技术上讲,该锁允许一次多次读取,但是由于HttpCache::Transaction在将其降级为只读锁之前始终会抓住用于写入和读取的锁,因此对同一URL的所有请求实际上都是串行完成的。在许多情况下,渲染器进程已经合并对相同URL的请求,从而在某种程度上减轻了此问题。
还值得注意的是,每个渲染器进程还具有自己的内存中缓存,该缓存与net /中实现的缓存无关,后者位于网络服务中。

取消机制
应用可以调用cancel取消请求。 这将导致network::URLLoader销毁自身及其URLRequest。
当一个已取消请求的HttpNetworkTransaction被析构时,它会根据协议(HTTP/HTTP2/QUIC)和任何接收到的标头,确定HttpStream拥有的套接字是否可以重用。 如果套接字有可能被重用,则在将套接字返回SocketPool之前,将创建HttpResponseBodyDrainer尝试读取HttpStream的任何其余主体字节(如果有)。 如果花费的时间太长或出现错误,则改为关闭套接字。 由于这一切都发生在高速缓存下面的层,因此任何耗尽的字节都不会写入高速缓存,就高速缓存层而言,它仅具有部分响应。

代理
每个代理都有其自己的完全独立的Socket Pool。 他们有自己的专属TransportSocketPool,在其上方有自己的协议专用池,在其上方有自己的SSLSocketPool。 HTTPS代理在HttpProxyClientSocketPool和TransportSocketPool之间还有第二个SSLSocketPool,因为要分别与代理服务器和目标服务器进行SSL连接。
在调用ClientSocketPoolManager创建套接字之前,HttpStreamFactory::Job执行的第一步是将URL传递给Proxy服务,以获取应尝试对该URL进行代理的有序列表(如果有)。 然后,当ClientSocketPoolManager尝试获取作业的套接字时,它将使用该代理列表将请求定向到正确的套接字池。

QUIC
服务器在响应头中使用“Alternate-Protocol” 头表示支持QUIC。 然后HttpServerProperties跟踪已公告QUIC支持的服务器。
当一个新的请求进入HttpStreamFactory来连接支持QUIC的服务器时,它将为QUIC创建第二个HttpStreamFactory::Job,如果成功,它将返回一个QuicHttpStream。 这两个Job(一个用于QUIC,一个用于所有HTTP)将相互竞争,并且将使用首先连接的HttpStream。
与HTTP/2一样,一旦建立QUIC连接,它将与连接到同一服务器的其他Job共享,以后的Job将仅重用现有的QUIC session。

优先级
URLRequest在创建时被分配了优先级。 它用于以下几种情况:

  1. DNS查找是根据最高优先级请求启动的。
  2. 套接字池分发并根据优先级创建套接字。但是,当套接字空闲时,即使对另一个服务器有更高优先级的请求,该套接字也将被分配给与其连接过的原服务器。
  3. HTTP/2和QUIC均支持通过有线发送优先级。

在套接字池层,仅在套接字连接并协商SSL(如果需要)后,才将套接字分配给套接字请求。 因为如果在建立连接之前,更高优先级请求到达了套接字池,则第一个可用连接将转到最高优先级的套接字请求。

其他
Redirects、数据压缩、Socket Pool关系、SSL连接、HTTP/2等细节 可以参考Chromium源码文档,因为随时可能更新,所以这里只贴链接。
https://github.com/chromium/chromium/blob/master/net/docs/life-of-a-url-request.md

阅读 10.2k
23 声望
0 粉丝
0 条评论
你知道吗?

23 声望
0 粉丝
文章目录
宣传栏