自 WWDC 2016 苹果传递出从 2017 年 1 月起强制启用应用程序安全传输协议(App Transport Security)的信号,各大厂均开始了 HTTPS 化的征程。虽然目前苹果将此计划延期,但 HTTPS 协议已经在各大厂开花结果。
前言
HTTPS 协议是以 SSL 协议为基础的安全版 HTTP 协议,好处不言自明,即为安全。对于用户来说,HTTPS 协议不仅能保障自己的隐私与数据安全,同时也降低了“页面小弹窗”的困扰,极大地提升了用户体验。本文将介绍 58 同城 App 在 HTTPS 改造方面的一些经验,并对 Android 端 HTTPS 实践中遇到的问题进行总结。
项目准备
58 同城平台为了推动各业务线进行 HTTPS 改造,需要提供各端的完整改造方案。所以,我们在项目准备阶段,主要做了两部分事情:
- 调研 HTTPS 协议与部署相关问题;
- 输出具体改造方案。
在调研 HTTPS 协议与部署相关问题之后,各端均输出了一份具体的改造方案,如下:
- 服务端:动态适配请求协议头,消灭硬编码,域名升级;
- 前端:页面静态路径去掉协议头;
- 客户端:升级 Native 网络库支持 HTTPS 及 WebView 升级(此仅 iOS 端);
- 测试:HTTPS 测试方法与测试点、上线流程。
接下来,笔者将主要对上述改造方案中的 Android 客户端实践及其涉及原理进行详细介绍,对于 HTTPS 协议与 HTTP2 协议原理分析感兴趣的读者,可以阅览《HTTPS 与 HTTP2 协议分析》了解更多。
改造 Android 端 HTTPS 实践
改造后的项目架构如图 1 所示,相对于 58 同城 App 原有架构,添加了 OkHttp 网络库进行网络层收敛,而 API 请求、图片请求、H5 页面资源请求最终均会在 OkHttp 创建的连接上进行数据传输。
图 1 项目架构设计
需要说明的是,这里之所以引入了 OkHttp 网络库,主要是因为 HTTP2 协议的支持。
因为当考虑进行 HTTPS 改造时,我们首先想到的一个问题便是 HTTPS 性能低下。相对 HTTP 协议来说,HTTPS 协议建立数据通道更耗时,若直接部署到 App 中,势必会降低数据传递的效率,间接影响用户体验。
HTTP2 协议本是为了解决 HTTP/1.X 协议的低效率而诞生的,不过在实际应用中,只会在 HTTPS 协议握手阶段进行协议协商,所以 HTTP2 目前直接改善的其实是 HTTPS 的低效率。为此,HTTP2 主要提出了两大改进点:
- 多路复用。同一域名下的请求,可通过同一条链路进行传输,不必单独建立链路,有效节省开销;
- 压缩头信息。将头部字段缓存为索引,客户端与服务端维护索引表,通信过程中尽可能采用索引进行通信,收到索引后查询索引表,才能解析出真正的头部信息。
因此,我们在 Android 端的具体改造方案主要在于 OkHttp 库与调用库之间的交互与包装,其中:
- Volley 底层连接替换 OkHttp,只需要创建 OkHttpStack 类实现 HTTPStack 接口并替换 HurlStack 即可,网上成型方案较多,这里不再赘述。
- Fresco 底层连接替换 OkHttp 更加简单,官方已经提供了 OkHttpNetworkFetcher 类,直接通过 ImagePipelineConfig 设置 NetworkFetcher 即可完成替换。在后面的具体实践部分,还会讲到对 Fresco 官方提供的 OkHttpNetworkFetcher 在取消加载部分的优化。
部署实施
对 App 进行 HTTPS 改造需要服务端、前端、客户端一同配合开发,QA 进行质量把控。同时,由于 58 同城 App 涵盖了众多业务线与第三方,每个业务乃至接口的部署都可能会对其他业务造成影响。所以,各业务开发与部署的时序、整体进度的把控是我们面临的最大难题。
部署实施步骤
经过与各业务线的充分讨论,我们最终确立了如下实施步骤:
- 以业务线为单位进行服务梳理,确定并理清各业务线的依赖关系。
- 业务线基于依赖关系进行改造排期预估,并着手开发。
58 同城 App 平台方及时主动地跟进各业务线,解决改造期间的技术问题与协调业务线间联调配合等。同时,开发必要的风险规避策略(譬如降级策略),以降低后续灰度上线风险。
业务线完成改造并通过测试后,58 同城 App 平台方修改业务线入口跳转协议,提供 HTTPS 入口进行灰度测试,若效果符合预期,则逐步提高灰度测试比例直至全量。
实施注意事项
通过以上步骤,基本保证了业务线间能够高效并行开发,但在实施过程中,有几点需要特别注意:
-
业务线间由于历史问题,有些业务存在严重的交叉依赖情况,需要及时协调业务线进行暂时的依赖解除。
何为“暂时的依赖解除”?多个业务线由于并行进行 HTTPS 改造,服务的相互依赖导致单个业务线无法测试。此时进度较快的业务线可以将依赖的服务使用 HTTP 协议代替访问,或通过 host 配置相关服务的测试机,待其他业务线完成部署后再改回 HTTPS 协议。
-
虽然以业务线为单位进行并行开发可以将开发、测试等流程分发到业务线内部完成,但 HTTPS 改造涉及到的服务众多,改造成本很高,可能会与业务线的业务需求开发产生冲突。因此,平台方需要及时跟进业务线的进度,及时妥善地处理阻塞因素。
HTTPS 实践问题汇总
鉴于 HTTPS 用户体验更好,以及可以解决 HTTPS 性能问题的切实方案,58 同城 App 便开展了全站 HTTPS 化的改造。当然,在改造过程中,我们也遇到了一些问题,主要有以下几类:
- HTTPS 调试问题
- 性能问题
- 环境问题
- OkHttp 接入问题
下面将对以上问题进行依次分析。
HTTPS 调试问题
进行 HTTPS 改造遇到的第一个问题就是 HTTPS 不好调试。当我们绑定了 PC 作为代理,通过 Charles 或 Fiddler 抓取请求时,它们即成为我们的代理服务器。若不安装 Charles 或 Fiddler 的证书到设备上,便无法完成对代理服务器的身份认证,后续的应用数据传输也就无从谈起,直接的表现即为 HTTPS 请求失败。
面对这种问题,最简单的方式是给设备安装证书,之后便可以调试 HTTPS 请求了。但每台 PC 的代理证书各异,若需要像 HTTP 请求一样方便地调试,须对每台手机安装每台 PC 的代理证书。这点对于仅需要验证请求数据的测试同学来说比较痛苦,只是为了看下数据,为什么要这么麻烦?
在此给出两点可行的建议:
- 客户端将 HTTPS 请求结果作为日志输出,开发与测试同学可以针对日志分析接口问题;
- 采用类似 Chuck 项目(https://github.com/jgilfelt/chuck)的思路,为 OkHttp 添加 interceptor 以收集请求结果,并将其以 UI 形式直观地展示出来。
通过以上两种方式,可以有效地简化请求结果的验证与查看。若是需要修改请求的结果进行调试开发,是否是 HTTPS 协议已无关紧要,此时借助 Charles 与 Fiddler 调试 HTTP 接口也非常简单。
性能问题
HTTPS 协议性能较 HTTP 协议稍差,也由此造成了弱网情况下的连接超时问题。
- 多路复用特性提升 HTTPS 性能
HTTPS 协议通信效率较 HTTP 协议通信效率低是众所周知的事实,当 App 全面升级为 HTTPS 时,通信效率的降低会直接影响用户体验。我们经过线上数据对比发现,通过 HTTPS 协议访问,其耗时是 HTTP 协议访问耗时的 1.3-2.1 倍。
那么,HTTPS 该如何提高通信效率呢?
在建立安全通道部分,由于涉及到身份认证与算法、密钥协商,两次网络往返是很难优化的。但在建立了安全通道后,若能复用此通道,则后续请求便可避免两次网络往返。所以,基于这种思路,58 同城 App 主要借助 HTTP2(或 SPDY)协议的多路复用特性,提高通道使用率,进而提高通信效率。
由于多路复用特性是域名级复用,所以最重要的一点便是收敛域名。收敛效果越好,通道的复用率越高。因此,我们对 API 接口、图片等资源接口进行了域名收敛,尽可能地收敛多级域名至二级域名、收敛零散域名至统一域名。
综上,借助 HTTP2(或 SPDY)协议的多路复用特性,以及对现有业务的域名收敛进行优化,通过线上数据对比得出,其访问耗时是 HTTP 协议访问耗时的 1.2 倍左右。
- 提高列表页 HTTPS 图片加载速度
58 同城 App 使用的图片库是 Fresco,在 OkHttp 接入后,我们也顺势将 Fresco 的 Fetcher 替换为 OkHttp 实现,以提高 HTTPS 图片的加载速度。但官方提供的 OkHttpNetworkFetcher 却仍有优化空间。比如,OkHttpNetworkFetcher 的加载任务取消操作是通过调用 Call.cancel()来实现的。具体代码如下:
//OkHttpNetworkFetcher 对 Call 进行取消
fetchState.getContext().addCallbacks(
new BaseProducerContextCallbacks(){
@Override
public void onCancellationRequested(){
if(Looper.myLooper()! = Looper.getMainLooper()){
call.cancel();
}else{ mCancellationExecutor.execute(new Runnable(){
@Override public void run() {
call.cancel();
}
});
}
}
});
对 Call.cancel()执行加载取消操作后,加载仍然会被线程池调用执行,直到 RetryA