## 58同城 Android 端 HTTPS 实践之旅 文/赵岘 ### 前言 HTTPS 协议是以 SSL 协议为基础的安全版 HTTP 协议,好处不言自明,即为安全。对于用户来说,HTTPS 协议不仅能保障自己的隐私与数据安全,同时也降低了“页面小弹窗”的困扰,极大地提升了用户体验。本文将介绍58同城 App 在 HTTPS 改造方面的一些经验,并对 Android 端 HTTPS 实践中遇到的问题进行总结。 ### 项目准备 58同城平台为了推动各业务线进行 HTTPS 改造,需要提供各端的完整改造方案。所以,我们在项目准备阶段,主要做了两部分事情: - 调研 HTTPS 协议与部署相关问题; - 输出具体改造方案。 在调研 HTTPS 协议与部署相关问题之后,各端均输出了一份具体的改造方案,如下: - 服务端:动态适配请求协议头,消灭硬编码,域名升级; - 前端:页面静态路径去掉协议头; - 客户端:升级 Native 网络库支持 HTTPS 及 WebView 升级(此仅 iOS 端); - 测试:HTTPS 测试方法与测试点、上线流程。 接下来,笔者将主要对上述改造方案中的 Android 客户端实践及其涉及原理进行详细介绍,对于 HTTPS 协议与 HTTP2 协议原理分析感兴趣的读者,可以阅览[《HTTPS 与 HTTP2 协议分析》](http://geek.csdn.net/news/detail/188003)了解更多。 ### 改造 Android 端 HTTPS 实践 改造后的项目架构如图1所示,相对于58同城 App 原有架构,添加了 OkHttp 网络库进行网络层收敛,而 API 请求、图片请求、H5 页面资源请求最终均会在 OkHttp 创建的连接上进行数据传输。 需要说明的是,这里之所以引入了 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 入口进行灰度测试,若效果符合预期,则逐步提高灰度测试比例直至全量。 图1  项目架构设计 图1 项目架构设计 #### 实施注意事项 通过以上步骤,基本保证了业务线间能够高效并行开发,但在实施过程中,有几点需要特别注意: - 业务线间由于历史问题,有些业务存在严重的交叉依赖情况,需要及时协调业务线进行暂时的依赖解除。 何为“暂时的依赖解除”?多个业务线由于并行进行 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() 执行加载取消操作后,加载仍然会被线程池调用执行,直到 RetryAndFollowInterceptor 执行时 cancel 操作才会起作用。 因此,我们对 OkHttpNetworkFetcher 进行了改写。在 Fresco 取消加载的回调中,对图片加载任务对应的 future 进行 cancel 操作,便可以减少 RetryAndFollowInterceptor 之前的逻辑处理(主要是自定义 Interceptor 部分)。 具体代码实现与 HttpUrlConnectionNetworkFetcher 的取消回调实现类似。 #### HTTPS 环境问题 ##### **客户端证书验证问题** HTTPS 改造过程中,常见的一个问题便是客户端证书验证出错,究其原因,往往是因为: - 证书管理混乱,导致下发证书的域名与请求域名不符,无法通过验证。 - 证书过期。 - 证书签发 CA 未被内置于客户端。 - 证书链不完整,无法验证。 在此,我们具体剖析一下“CA 未被内置于客户端”与“证书链不完整”的问题。 证书签发 CA 未被内置于客户端——由于 CA 数量众多,质量也参差不齐,面对同样众多的手机厂商与自定义 ROM,无法保证 CA 能够内置到客户端证书列表中,所以 CA 存在不被客户端认可的可能性(Google 也会基于 CA 认可度进行证书列表的更新)。相对来说,顶级 CA 的设备兼容性较好,若遇到根证书路径找不到的异常,可以考虑更换 CA,签发证书。 证书链不完整,无法验证——在握手协议中,服务端会下发 Certificate 消息给客户端,消息中携带了由 CA 签发的证书与 CA 证书构成的证书链。客户端通过证书链信息,逐级向上寻找根证书,找到后再通过根证书的公钥逐级向下验证证书链,若证书链验证通过,则身份验证阶段完成。 倘若服务端只下发了自己的证书或下发的证书链不足以寻找到根证书,导致验证流程断裂,则无法通过身份验证。这是服务端证书配置部署问题,如果证书认证失败,很有可能是这个原因造成的。 ##### **DNS 劫持问题** 虽然我们采用 HTTPS 提高了通信安全,但 DNS 劫持的情况仍然无法解决。当我们希望获取 IP 地址时,需要通过 DNS 服务器进行查询,若访问的服务器被污染,返回给我们错误的 IP 地址,此时便产生了 DNS劫持问题。 相对于 HTTP 协议,HTTPS 协议 DNS 劫持的后果更为严重。于 HTTP 协议而言,DNS 劫持后会产生监听数据或插入数据的风险,而功能可能不受影响。但对于 HTTPS 协议来说,DNS 劫持后,服务器下发证书无法通过客户端认证,或是服务器根本没有开启443端口,均无法建立 HTTPS 连接。 面对 DNS 劫持情况,这里提供两种解决方案: - 下发(或内置)IP 列表,通过 DNS 接口由客户端进行 DNS 解析或 IP 地址比对; - 运维监控或第三方监控,对出现 DNS 劫持的区域向运营商投诉解决。 ##### **降级策略** 考虑到 HTTPS 存在证书验证、DNS 劫持及代理443未开启等诸多问题,在实践过程中,我们也添加了降级策略。启动 App 时,通过服务接口下发域名降级字典,譬如: ``` {"key" : [{"HTTPS://app.58.com" : "http://app.58.com"}]} ``` 在请求前,会将 URL 与降级字典匹配处理,经过匹配的 URL 再发起请求,避免 HTTPS 改造影响用户功能。 #### OkHttp 接入问题 OkHttp 是目前使用最广泛的支持 HTTP2 的 Android 端开源网络库,下面分享下58同城 App 在接入 OkHttp 过程中遇到的问题。 ##### **OkHttp 头部数据非法字符抛出异常** OkHttp 构造头部数据主要通过 Request.Builder 对象的 add(name,value) 与 set(name,value) 两种方法,而它们内部均会调用 checkNameAndValue(name,value),这个方法会对 name 与 value 分别进行字符检测,若字符不在 \u001f 至 \u007f 之间,则会抛出 IllegalArgumentException。 ``` private void checkNameAndValue(String name, String value) { if(name == null) throw new NullPointerException("name == null"); if(name.isEmpty()) throw new IllegalArgumentException("name is empty"); for(int i = 0, length = name.length(); i < length; i++) { char c = name.charAt(i); if (c <= '\u001f' || c >= '\u007f') { throw new IllegalArgumentException(Util.format( "Unexpected char %#04x at %d in header name: %s", (int) c, i, name)); } } if (value == null) throw new NullPointerException("value == null"); for (int i = 0, length = value.length(); i < length; i++) { char c = value.charAt(i); if (c <= '\u001f' || c >= '\u007f') { throw new IllegalArgumentException(Util.format( "Unexpected char %#04x at %d in %s value: %s", (int) c, i, name, value)); } } } ``` 从 OkHttp 底层代码可以发现,OkHttp 对字符串是通过 UTF-8 编码的,这里强制进行字符检测,猜想可能是基于对编码规范的考虑。 为了避免字符检测失败导致的异常,我们可以通过其他 API 绕过这个限制: - Header 添加通过 Headers.of 方法生成 Headers,并通过 Builder.headers 方法配置进去。 - 通过 Internal.instance.addLenient 方法直接设置 name 与 value,add 与 set 方法底层也是调用这个方法。不过这个方法需要保证 Internal.instance 已初始化,即 OkHttpClient 已创建,否则会抛出空指针异常。 ##### **OkHttp 中 post 请求抛出异常** 当我们使用 Request.Builder 类通过 post(RequestBody) 方法进行 post 请求构造时,若不对 RequestBody 做判空操作,则有可能会抛出 IllegalArgumentException。 post(RequestBody) 方法最终会调用 method() 方法,它会对请求类型与 body 做校验,若 post 请求对应的 body 为 null,则抛出异常。代码如下: ``` public Builder method(String method, RequestBody body) { if (method == null) throw new NullPointerException("method == null"); if (method.length() == 0) throw new IllegalArgumentException("method.length() == 0"); if (body != null && !HttpMethod.permitsRequestBody(method)) { throw new IllegalArgumentException("method " + method + " must not have a request body."); } //这里便是body为空时,异常抛出点 if (body == null && HttpMethod.requiresRequestBody(method)) { throw new IllegalArgumentException("method " + method + " must have a request body."); } this.method = method; this.body = body; return this; } ``` 所以在构造 post 请求时,需要对 RequestBody 进行非空判断,若 RequestBody 为空,则需要构造一个无内容的 RequestBody 对象。 ##### **OkHttp 在 HTTP2 协议下 Response 监听线程崩溃** 在接入 OkHttp 并使用 HTTP2 协议进行通信后,第三方应用崩溃检测工具(如 Bugly)会收集到线上版本关于 OkHttp 的两种崩溃,分别为 EOFException、ArrayIndexOutOfBoundsException。这两种崩溃的堆栈信息显示,均是在 FramedConnection 内部类 Reader 的 execute( ) 方法中抛出的。 ``` @Override protected void execute() { ...... try { ...... } catch (IOException e) { ...... } finally { try { //抛出点1 close(connectionErrorCode, streamErrorCode); } catch (IOException ignored) { } //抛出点2 Util.closeQuietly(frameReader); } } ``` 究其原因,Reader 的 execute 方法被独立线程调用进行 Response 的监听,在连接断开或异常中断的情况下,会进入代码中 finally 代码块,而 finally 代码块只 Catch 住了 OkHttp 可能抛出的异常,并没有关注 Okio 抛出的异常。 这个问题当前还无法稳定复现,可能出现在两端,也可能出现在国内复杂的网络环境下,排查较为复杂。而对连接断开与异常中断等场景,如果我们 Catch 住所有的异常,也不会对用户有任何影响。 目前我们的处理方式便是对 OkHttp 进行重打包,对整个 execute() 方法进行捕获,以解决 Response 监听线程崩溃的问题。 ##### **在 HTTP2 协议下头信息小写问题** HTTP2 为了解决 HTTP1.X 中头信息过大导致效率低下的问题,提出了通过 HPACK 压缩算法压缩头部信息的解决方案。 正因为 HPACK 以索引代替头部字段,所以相同头部字段若因为大小写的问题导致存在多个索引,是一种很大的浪费。举个例子,“accept-encoding”、“Accept-Encoding”与“ACCEPT-ENCODING”表达的是一个意思,所以 HTTP2 规定,头部信息统一用小写。 OkHttp 的实现,也是统一采用小写: ``` private static final Header[] STATIC_HEADER_TABLE = new Header[] { ...... new Header("accept-charset", ""), new Header("accept-encoding", "gzip, deflate"), new Header("accept-language", ""), new Header("accept-ranges", ""), new Header("accept", ""), ...... }; ``` 所以当我们需要针对某些头信息进行逻辑处理时,首先要对字段进行小写的格式化操作,以避免监听不到头部字段或添加的大写头部字段被小写头部字段覆盖。 ### 总结 站在技术的角度解决用户痛点是每个开发者的愿景,HTTPS 改造虽然只是一次普通的技术改造,但对用户隐私保护与用户体验优化却有着深远的影响。通过 HTTPS 改造项目,58同城 App 完成了接口的 HTTPS 化,数据监听与内容篡改已成往事,用户体验也得到了保障,但由于58同城 App 涉及到众多业务,HTTPS 性能方向上仍然有很大空间亟待我们后续优化。