Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器

1. QUIC 介绍

QUIC是快速UDP网络连接(英语:Quick UDP Internet Connections)的缩写,这是一种实验性的传输层网络传输协议,由Google公司开发,在2013年实现。QUIC使用UDP协议,它在两个端点间创建连接,且支持多路复用连接。在设计之初,QUIC希望能够提供等同于SSL/TLS层级的网络安全保护,减少数据传输及创建连接时的延迟时间,双向控制带宽,以避免网络拥塞。Google希望使用这个协议来取代TCP协议,使网页传输速度加快,计划将QUIC提交至互联网工程任务小组(IETF),让它成为下一代的正式网络规范。 [百度百科]

2. CRONET 引入

官方项目里或者说高星开源项目中,支持 Android 上 quic 的靠谱网络库只此一家,Android 工程引入 cronet release 有两种办法。

  1. gradle 引用,最新只能引用到 113 版本,见:Maven Repository: org.chromium.net >> cronet-embedded (mvnrepository.com)
  1. 直接去 GoogleCloud 里去下载 chromium-cronet 的最新版本。比如:下面这个链接就可以下载最新的 121.0.6167.0 版本,优点是版本新,缺点是可能不稳定 chromium-cronet -- 存储桶详情 -- Cloud Storage -- Google Cloud Console

3. 直接用 cronet 不就行了吗?为啥还搭配 OKHTTP 呢?

我之前分享的文章里面就反复提及三个点 - 风险、收益、成本

  • 低风险: 为了最小化线上风险,能够满足快速回退的需求,最好还是让 cronet 能够插拔,变成一个现有网络库的可选功能项
  • 低成本: 许多项目都是深度使用 okhttp,已经基于 okhttp 干了很多基建了,要把那些东西全部切到 cronet 上成本还是比较高的
  • 网络库切换必须是等到线上验证完 cronet 的性能/稳定性后才能够被讨论的话题

当然,考虑归考虑,最终要上线 cronet 的时候,还是推荐大家要么进行网络库替换,要么改造 cronet,让 cronet 能够完美和 okHttp 融合。举个最简单的例子,OKHttp 和 cronet 的线程池是不共用的,那app 运行过程中发起网络请求时,就比单独用 okHttp 或者 cronet 时,会创建更加多的线程。

google 官方其实有相关实现,但个人觉得实现的太过复杂:

google/cronet-transport-for-okhttp: This package allows OkHttp and Retrofit users to use Cronet as their transport layer, benefiting from features like QUIC/HTTP3 support or connection migration. (github.com)

4. 实现方案 - Cronet 逻辑

我们已知,OKHttp 的拦截器设计支持调用者能够自定义自己的拦截器,每个拦截器都是有机会去消费请求生产响应的。所以我们为了实现如下需求:

  • cronet 嵌入到 okhttp 的请求流程中
  • cronet 接管 okhttp 的发起请求流程
  • 保留原先用于设置请求 UA 等自定义功能的拦截器

我们将自定义一个 Cronet 的拦截器,并且将这个拦截器添加到自定义拦截器的末尾,确保 CronetInteceptor 执行前,之前自定义功能的拦截器都能正常工作。

我们提前预想一下,看看我们的 cronet 拦截器需要实现哪些东西:

  1. OKhttp request 转 cronet request
  2. cronet 请求流程
  3. cronet response 转 OKHttp response
  4. 可能需要的性能统计逻辑

CronetEnv.kt

初始化 cronet,配置支持 quic 的域名、端口,注入网络性能打点逻辑

kotlin 复制代码
object CronetEnv {
    private var cronetEngine: CronetEngine? = null
    private val executorService = Executors.newSingleThreadExecutor()

    fun cronetEngine(): CronetEngine? {
        return cronetEngine
    }

    fun initializeCronetEngine(
        context: Context,
    ) {
        if (cronetEngine != null) {
            return
        }
        val builder = CronetEngine.Builder(context)
            .enableHttp2(true)
            .enableQuic(true)
            .enableBrotli(true)
            .addQuicHint("test.quic.com", 443, 443)
        }

        cronetEngine = builder.build()
        cronetEngine?.addRequestFinishedListener(object :
            RequestFinishedInfo.Listener(Executors.newSingleThreadExecutor()) {
            override fun onRequestFinished(requestInfo: RequestFinishedInfo) {
                CronetLogHelper.logMatrix(requestInfo)
            }
        })
    }
}

CronetInterceptor.kt

主要负责:

  • OKHttp request 转换成 cronet request
  • 使用 cronet request 发起请求,并接收响应
kotlin 复制代码
class CronetInterceptor : Interceptor {
    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        if (chain.call().isCanceled()) {
            throw IOException("Canceled")
        }

        //检查 cronet 初始化
        CronetEnv.cronetEngine() ?: return chain.proceed(chain.request())

        //用 cronet 来代理网络请求
        return try {
            val callback = CronetRequestCallback(chain.request())
            val urlRequest = buildRequest(chain.request(), callback, CronetEnv.cronetEngine(), CronetEnv.executorService)
            urlRequest.start()
            callback.blockForResponse()
        } catch (e: Exception) {
            Log.e(TAG, "got exception for ${chain.request().url}, message=${e.message}")
            if (e is IOException) {
                throw e
            } else {
                throw IOException("canceled due to $e", e)
            }
        }
    }

    fun buildRequest(
        request: Request,
        callback: UrlRequest.Callback?,
        cronetEngine: CronetEngine,
        executorService: ExecutorService
    ): UrlRequest {
        val url = request.url.toString()
        val requestBuilder = cronetEngine.newUrlRequestBuilder(url, callback, executorService)
        requestBuilder.setHttpMethod(request.method)
        request.headers.forEach {
            requestBuilder.addHeader(it.first, it.second)
        }

        val requestBody = request.body
        if (requestBody != null) {
            val contentType = requestBody.contentType()
            if (contentType != null) {
                requestBuilder.addHeader("Content-Type", contentType.toString())
            }
            val buffer = Buffer()
            requestBody.writeTo(buffer)
            val uploadDataProvider = UploadDataProviders.create(buffer.readByteArray())
            requestBuilder.setUploadDataProvider(uploadDataProvider, executorService)
        }
        return requestBuilder.build()
    }
}

CronetRequestCallback

主要负责:

  • 接收 croent response 转成 OKHttp response
  • 处理成功、失败、重定向逻辑
kotlin 复制代码
class CronetRequestCallback internal constructor(
    private val originRequest: Request
) : UrlRequest.Callback() {

    private var redirectCount = 0
    private var response: Response
    private var iOException: IOException? = null
    private val responseLock = ConditionVariable()
    private val receivedByteArrayOutputStream = ByteArrayOutputStream()
    private val receiveChannel = Channels.newChannel(receivedByteArrayOutputStream)

    init {
        response = Response.Builder()
            .sentRequestAtMillis(System.currentTimeMillis())
            .request(originRequest)
            .build()
    }

    @Throws(IOException::class)
    fun blockForResponse(): Response {
        responseLock.block()
        if (iOException != null) {
            throw iOException as IOException
        }
        return response
    }

    override fun onRedirectReceived(
        request: UrlRequest,
        responseInfo: UrlResponseInfo,
        newLocationUrl: String
    ) {
        if (redirectCount > 20) {
            request.cancel()
            iOException = ProtocolException("Too many follow-up requests: $redirectCount")
            return
        }
        redirectCount += 1
        request.followRedirect()
    }

    override fun onResponseStarted(request: UrlRequest, responseInfo: UrlResponseInfo) {
        response = toOkResponse(response, responseInfo)
        request.read(ByteBuffer.allocateDirect(32 * 1024))
    }

    @Throws(Exception::class)
    override fun onReadCompleted(
        request: UrlRequest,
        responseInfo: UrlResponseInfo,
        byteBuffer: ByteBuffer
    ) {
        byteBuffer.flip()
        try {
            receiveChannel.write(byteBuffer)
        } catch (e: IOException) {
            iOException = e
        }
        byteBuffer.clear()
        request.read(byteBuffer)
    }

    override fun onSucceeded(request: UrlRequest, responseInfo: UrlResponseInfo) {
        val contentType = response.header("Content-Type", "text/html")
        val mediaType: MediaType? = (contentType
            ?: """text/plain; charset="utf-8"""").toMediaTypeOrNull()
        val responseBody = receivedByteArrayOutputStream.toByteArray().toResponseBody(mediaType)

        val httpStatusCode = responseInfo.httpStatusCode
        if ((httpStatusCode == 204 || httpStatusCode == 205) && responseBody.contentLength() > 0) {
            iOException =
                ProtocolException("HTTP " + httpStatusCode + " had non-zero Content-Length: " + responseBody.contentLength()); }

        val newRequest = originRequest.newBuilder()
            .url(responseInfo.url)
            .build()
        response = response.newBuilder()
            .body(responseBody)
            .request(newRequest).build()
        responseLock.open()
    }

    override fun onFailed(request: UrlRequest, info: UrlResponseInfo?, error: CronetException?) {
        iOException = error
        responseLock.open()
    }

    override fun onCanceled(request: UrlRequest, info: UrlResponseInfo?) {
        if (iOException == null) {
            iOException = IOException("The request was canceled!")
        }
        responseLock.open()
    }

    companion object {
        private fun toOkResponse(response: Response, responseInfo: UrlResponseInfo): Response {
            val protocol = CronetLogHelper.protocolFromCronet(
                responseInfo.negotiatedProtocol
            )
            val headersBuilder = Headers.Builder()
            for ((key, value) in responseInfo.allHeadersAsList) {
                headerBuilder.add(key, value)
            }
            return response.newBuilder()
                .receivedResponseAtMillis(System.currentTimeMillis())
                .protocol(protocol)
                .code(responseInfo.httpStatusCode)
                .message(responseInfo.httpStatusText)
                .headers(headersBuilder.build())
                .build()
        }
    }
}

CronetLogHelper

主要负责:

  • 收集 cronet 请求的性能指标数据,对标 OKHttp 的 EventListener,对各个阶段进行统计。
  • 如果还想统计更多上下文内容,可以通过在前置通过 requestBuilder.addRequestannotation 注入,后续通过 requestFinishedInfo.annotations 取出
ini 复制代码
object CronetLogHelper {
    fun logMatrix(requestFinishedInfo: RequestFinishedInfo) {
        val matrix = requestFinishedInfo.metrics
        val success = requestFinishedInfo.finishedReason == RequestFinishedInfo.SUCCEEDED
        val requestStart = matrix.requestStart?.time ?: 0
        val dnsStart = matrix.dnsStart?.time ?: 0
        val dnsEnd = matrix.dnsEnd?.time ?: 0
        val connectStart = matrix.connectStart?.time ?: 0
        val connectEnd = matrix.connectEnd?.time ?: 0
        val sslStart = matrix.sslStart?.time ?: 0
        val sslEnd = matrix.sslEnd?.time ?: 0
        val sendStart = matrix.sendingStart?.time ?: 0
        val sendEnd = matrix.sendingEnd?.time ?: 0
        val responseStart = matrix.responseStart?.time ?: 0
        val responseEnd = matrix.requestEnd?.time ?: 0
        val requestEnd = matrix.requestEnd?.time ?: 0
        val responseBodySize = matrix.receivedByteCount ?: 0
        val totalTime = matrix.totalTimeMs ?: 0
        val map: MutableMap<String, Any> = mutableMapOf()

        map["url"] = requestFinishedInfo.url.toString()
        map["call_begin"] = requestStart
        map["dns_begin"] = dnsStart
        map["dns_end"] = dnsEnd
        map["connect_begin"] = connectStart
        map["secure_connect_begin"] = sslStart
        map["secure_connect_end"] = sslEnd
        map["connect_end"] = connectEnd
        map["request_begin"] = sendStart
        map["request_end"] = sendEnd
        map["response_begin"] = responseStart
        map["response_end"] = responseEnd
        map["response_body_size"] = responseBodySize
        map["call_end"] = requestEnd
        map["task_interval"] = totalTime
        map["quic_jetsam"] = false

        requestFinishedInfo.responseInfo?.let {
            map["result"] = it.httpStatusCode
            map["protocol"] = it.negotiatedProtocol
            map["is_cache"] = if (it.wasCached()) 1 else 0
        }

        if (!success) {
            map["result"] = -1
            map["error_desc"] = requestFinishedInfo.exception?.message ?: ""
        }
        
        //拿着数据进行日志上报或者其他
    }
}

5.实现方案 - 将 CronetInterceptor 嵌入到 OKHttp

ini 复制代码
OKhttpClient.Builder builder = xxxx;
builder.addInterceptor(xxxx);
builder.addInterceptor(xxxx);
builder.addInterceptor(CronetIntecerptor())

6. 收工

至此我们就在 OKhttp 的基础上,通过嵌入 cronet 来实现了 quic 协议栈的接入。 当然,如果想要在大项目中上线 quic,实际上还需要做的更多,如:

  • 完备的性能监控
  • 完备的报警机制
  • 及时的容灾机制
  • 可能的针对 cronet 的性能优化
  • cronet的完全改造或者网络库的完全替换(两个人在一起总得抹平棱角,当然,也可能是大家保留各自特点各自安好)

你可能感兴趣

Android QUIC 实践 - 基于 OKHttp 扩展出 Cronet 拦截器 - 掘金 (juejin.cn)

Android启动优化实践 - 秒开率从17%提升至75% - 掘金 (juejin.cn)

如何科学的进行Android包体积优化 - 掘金 (juejin.cn)

Android稳定性:Looper兜底框架实现线上容灾(二) - 掘金 (juejin.cn)

基于 Booster ASM API的配置化 hook 方案封装 - 掘金 (juejin.cn)

记 AndroidStudio Tracer工具导致的编译失败 - 掘金 (juejin.cn)

Android 启动优化案例-WebView非预期初始化排查 - 掘金 (juejin.cn)

chromium-net - 跟随 Cronet 的脚步探索大致流程(1) - 掘金 (juejin.cn)

Android稳定性:可远程配置化的Looper兜底框架 - 掘金 (juejin.cn)

一类有趣的无限缓存OOM现象 - 掘金 (juejin.cn)

Android - 一种新奇的冷启动速度优化思路(Fragment极度懒加载 + Layout子线程预加载) - 掘金 (juejin.cn)

Android - 彻底消灭OOM的实战经验分享(千分之1.5 -> 万分之0.2) - 掘金 (juejin.cn)

相关推荐
找藉口是失败者的习惯14 分钟前
从传统到未来:Android XML布局 与 Jetpack Compose的全面对比
android·xml
Jinkey1 小时前
FlutterBasic - GetBuilder、Obx、GetX<Controller>、GetxController 有啥区别
android·flutter·ios
大白要努力!3 小时前
Android opencv使用Core.hconcat 进行图像拼接
android·opencv
天空中的野鸟4 小时前
Android音频采集
android·音视频
hgdlip4 小时前
主IP地址与从IP地址:深入解析与应用探讨
网络·网络协议·tcp/ip
小白也想学C5 小时前
Android 功耗分析(底层篇)
android·功耗
曙曙学编程5 小时前
初级数据结构——树
android·java·数据结构
lwprain6 小时前
安装支持ssl的harbor 2.1.4 docker 19.03.8 docker-compose 1.24.0
网络协议·ssl·harbor
软件技术员6 小时前
Let‘s Encrypt SSL证书:acmessl.cn申请免费3个月证书
服务器·网络协议·ssl
乐闻x6 小时前
Vue.js 性能优化指南:掌握 keep-alive 的使用技巧
前端·vue.js·性能优化