- 查询:缓存查看是否有本地副本可用,如果没有就获取一份副本并将其保存在本地;
- 新鲜度监测:缓存查看已缓存副本是否足够新鲜,如果不是就询问服务器是否有新的资源;
- 创建响应:缓存会用新的首部和已缓存的主题来构建一条响应报文;
- 发送:缓存通过网络把响应发挥给客户端;
- 日志:缓存可选地创建一个日志文件条目描述这个事务;
CacheInterceptor 大致上也是按这个流程来处理缓存的,只是在这个而基础上进行了一些细化。
5.2 缓存控制首部 CacheControl
由于通用请求首部 Cache-Control 在 OkHttp 的缓存机制中发挥着主要作用,所以下面先来看下 CacheControl 中各个字段对应的指令的作用。
通过指定通用首部字段 Cache-Control 的指令,就能操作缓存的工作机制,该指令的参数是可选的,多个指令之间通过“,”分隔。
Cache-Control: private, max-age=0, no-cache
5.3 获取缓存
RealCall 在创建 CacheInterceptor 时,会把 OkHttpClient 中的 cache 字段赋值给 CacheInterceptor ,默认是空,如果我们想使用缓存的话,要在创建 OkHttpClient 的使用使用 cache() 方法设置缓存,比如下面这样。
/**
- 网络缓存数据的最大值(字节)
*/
const val MAX_SIZE_NETWORK_CACHE = 50 * 1024 * 1024L
private fun initOkHttpClient() {
val networkCacheDirectory = File(cacheDir?.absolutePath + “networkCache”)
if (!networkCacheDirectory.exists()) {
networkCacheDirectory.mkdir()
}
val cache = Cache(networkCacheDirectory, MAX_SIZE_NETWORK_CACHE)
okHttpClient = OkHttpClient.Builder()
.cache(cache)
.build()
}
这里要注意的是,CacheInterceptor 只会缓存 GET
和 HEAD
等获取资源的方法的请求,而对于 POST
和 PUT
等修改资源的请求和响应数据是不会进行缓存的。
在 CacheInterceptor 的 intercept() 方法中,首先会通过 Cache.get() 获取候选缓存,而在 Cache.get() 方法中,首先会根据请求地址获取 key ,缓存快照的 key 就是 URL 经过 md5 处理后的值,而缓存快照 Snapshot 就是 Cache 中的磁盘缓存 DiskLruCache 缓存的值,并且快照中有对应缓存文件的输入流。
当 get() 方法获取到快照后,就会用快照的输入流创建 Entry ,在 Entry 的构造方法中,会从输入流读取缓存的请求和响应的相关信息,读取完后就会完毕输入流。
创建完 Entry 后,Cache.get() 就会判断缓存中的请求地址和请求方法与当前请求是否匹配,匹配的话则返回响应,不匹配的话则关闭响应体并返回 null ,这里说的关闭响应体指的是关闭要用来写入响应体的文件输入流。
5.4 缓存策略 CacheStrategy
获取完候选缓存响应后,CacheInterceptor 就会用缓存策略工厂的 compute() 方法生产一个缓存策略 CacheStrategy ,CacheStrategy 中比较重要的方法就是用来判断是否对当前请求和响应进行缓存的 isCacheable() 。
1. 可缓存响应的状态码
在 CacheStrategy 的 isCacheable() 方法中,首先会判断响应的状态码是否为“可缓存的状态码”。
为了简化 isCacheable() 的活动图,我把下面的状态码称为“可缓存的状态码”;
- 200 OK
- 203 Not Authoritative Information
- 204 No Content
- 300 Multiple Choices
- 301 Moved Permanently
- 308 Permanent Redirect
- 404 Not Found
- 405 Method Not Allowed
- 410 Gone
- 414 Request-URI Too Large
- 501 Not Implemented
2. 临时重定向状态码的缓存判断
当响应的状态码为 302 或 307 时,isCacheable() 方法就会根据响应的 Expires 首部和 Cache-Control 首部判断是否返回 false(不缓存)。
Expires 首部的作用是服务器端可以指定一个绝对的日期,如果已经过了这个日期,就说明文档不“新鲜”了。
5.5 获取响应
在 CacheInterceptor 调用 compute() 方法创建 CacheStrategy 时,如果 CacheControl 中有 onlyIfCached(不重新加载响应)指令,那么 CacheStrategy 的 cacheResponse 字段也为空。
当 CacheControl 中有 onlyIfCached 指令时,表明不再用其他拦截器获取响应,这时 CacheInterceptor 就会直接返回一个内容为空的响应。
当请求还是新鲜的(存在时间 age 小于新鲜时间 fresh ),那么 CacheStrategy 的 networkRequest 字段就为空,这时 CacheInterceptor 就会返回缓存中的响应。
当请求已经不新鲜时,CacheInterceptor 就会通过 ConnectInterceptor 和 CallServerInterceptor 获取响应。
5.6 保存响应
在获取到响应后,CacheInterceptor 会判断缓存响应的是否为空,如果不为空,并且状态码为 304(未修改)的话,则用新的响应替换 LruCache 中的缓存。
如果缓存响应为空,就把响应通过 Cache.put() 方法保存到磁盘中,保存后,如果请求方法为 PATCH、PUT、DELETE 会 MOVE 等修改资源的方法,那就把响应从缓存中删除。
6. OkHttp 连接建立机制
看完了缓存处理机制后,下面我们来看下 OkHttp 中负责建立连接的 ConnectInterceptor。
ConnectInterceptor 的 intercept() 方法没做什么事情,主要就是调用了 RealCall 的 initExchange() 方法建立连接。
在 RealCall 的 initExchange() 方法中,会用 ExchangeFinder.find() 查找可重用的连接或创建新连接,ExchangeFinder.find() 方法会返回一个 ExchangeCodec。
ExchangeCodec 是数据编译码器,负责编码 HTTP 请求进行以及解码 HTTP 响应,Codec 为 Coder-Decoder 的缩写。
RealCall 获取到 ExchangeCodec 后,就会用 ExchangeCodec 创建一个数据交换器 Exchange ,而下一个拦截器 CallServerInterceptor 就会用 Exchange 来写入请求报文和获取响应报文。
ExchangeFinder 的 find() 方法会辗转调用到它最核心的 findConnection() 方法,在看 findConnection() 方法的实现前,我们先来了解一些 HTTP 连接相关的知识。
6.1 HTTP 连接管理
HTTP 规范对 HTTP 报文解释得很清楚,但对 HTTP 连接介绍的并不多,HTTP 连接是 HTTP 报文传输的文件通道,为了更好地理解网络编程中可能遇到的问题,HTTP 应用程序的开发者需要理解 HTTP 连接的来龙去脉以及如何使用这些连接。
世界上几乎所有的 HTTP 通信都是由 TCP/IP 承载的,TCP/IP 是全球计算机及网络设备都在使用的一种常用的分组交换网络分层鞋以及。
客户端应用程序可以打开一条 TCP/IP 连接,连接到可能运行在世界任何地方的服务器应用程序,一旦连接建立起来了,在客户端与服务器的计算机之间交换的报文就永远不会丢失、受损或失序。
1. TCP/IP 通信传输流
用 TCP/IP 协议族进行网络通信时,会通过分层顺序与对方进行通信,发送端从应用层往下走,接收端从链路层往上走。
以 HTTP 为例,首先作为发送端的客户端在应用层(HTTP 协议)发出一个想看某个 Web 页面的 HTTP 请求。
接着发送端在传输层把从应用层收到的 HTTP 报文进行分割,并在各个报文上打上标记序号及端口号转发给网络层,然后接收端的服务器在链路层接收到数据,按顺序往上层发送,一直到应用层。
也就是发送端在层与层之间传输数据时,每经过一层就会被打上该层所属的首部信息,接收端在层与层传输数据时,每经过一层就会把对应的首部消去,这种把数据信息包装起来的做法称为封装
(encapsulate)。
2. TCP 套接字编程
操作系统提供了一些操作 TCP 连接的工具,下面是 Socket API 提供的一些主要接口,Socket API 最初是为 Unix 操作系统开发的,但现在几乎所有的操作系统和语言中都有其变体存在。
- socket():创建一个新的、未命名、未关联的套接字;
- bind():向 Socket 赋一个本地端口号和接口;
- listen():标识一个本地 Socket,使其可以合法地接收连接;
- accept():等待某人建立一条到本地端口的连接;
- connect():创建一条连接本地 Socket 与远程主机及端口的连接;
- read():尝试从套接字向缓冲区读取 n 个字符;
- write():尝试从缓冲区向套接字写入 n 个字节;
- close():完全关闭 TCP 连接;
- shutdown():只关闭 TCP 连接的输入或输出端;
Socket API 允许用户创建 TCP 的端点和数据结构,把这些端点与远程服务器的 TCP 端点进行连接,并对数据流进行读写。
6.2 释放连接
看完了 HTTP 连接的相关知识,下面我们来看下 ExchangeFinder 的 findConnection() 方法的实现。
findConnection() 方法大致做了 3 件事,首先是释放 RealCall 已有的连接,然后是尝试从连接池中获取已有的连接以进行复用,如果没有获取到连接时,则创建一个新连接并返回给 CallServerInterceptor 使用。
在 ExchangeFinder 的 findConnection() 方法中,首先会看下是否要释放当前 RealCall 的连接。
ExchangeFInder 会判断 RealCall 的 connection 字段是否为空,如果不为空,表示该请求已经被调用过并且成功建立了连接。
这时 ExchangeFinder 就会判断 RealCall 的 connection 的 noNewExchanges 是否为 true,这个值表示不能创建新的数据交换器,默认为 false。
当请求或响应有 Connection 首部,并且 Connection 首部的值为 close 时,那么 Connection 的 noNewExchanges 的值就会被改为 true ,因为 Connection:close
表示不重用连接,如果你忘了 Connection 首部的作用,可以回到第 4 大节首部拦截器看一下。
当连接的 noNewExchanges 的值为 true 时,或当前请求地址的主机和端口号和与有连接中的主机和端口号不相同时,ExchangeFinder 就会调用 RealCall 的 releaseConnectionNoevents() 方法尝试释放连接,如果如果连接未释放,则返回该连接,否则关闭连接对应的 Socket。
RealCall 的 connection 的类型为 RealConnection,RealConnection 中维护了一个 Call 列表,每当有一个 RealCall 复用该连接时,RealConnection 就会把它添加到这个列表中。
而释放连接的操作,其实就是看下 RealConnection 的 Call 列表中有没有当前 RealCall ,有的话就把当前 RealCall 从列表中移除,这时就表示连接已释放,如果连接的 Call 列表中没有当前 Call 的话,则返回当前 Call 的连接给 CallServerInterceptor 用。
6.3 从连接池获取连接
当 RealCall 的连接释放后 ExchangeFinder 就会尝试从连接池 RealConnectionPool 获取连接,RealConnectionPool 中比较重要的两个成员是 keepAliveDuration 和 connection。
keepAliveDuration 是持久连接时间,默认为 5 分钟,也就是一条连接默认最多只能存活 5 分钟,而 connections 是连接队列,类型为 ConcurrentLinkedQueue 。
每次建立一条连接时,连接池就会启动一个清理连接任务,清理任务会交给 TaskRunner 运行,在 DiskLruCache 中,也会用 TaskRunner 来清理缓存。
当第一次从连接池获取不到连接时,ExchangeFinder 会尝试用路线选择器 RouteSelector 来选出其他可用路线,然后把这些路线(routes)传给连接池,再次尝试获取连接,获取到则返回连接。
6.4 创建新连接
当两次从尝试从连接池连接都获取不到时,ExchangeFinder 就会创建一个新的连接 RealConnection,然后调用它的 connect() 方法,并返回该连接。
6.5 连接 Socket
在 RealConnection 的 connect() 方法中,RealConnection 的 connect() 方法首先会判断当前连接是否已连接,也就是 connect() 方法被调用过没有,如果被调用过的话,则抛出非法状态异常。
如果没有连接过的话,则判断请求用的是不是 HTTPS 方案,是的话则连接隧道,不是的话则调用 connectSocket() 方法连接 Socket。
关于连接隧道在后面讲 HTTPS 的时候会讲到,下面先来看下 connectSocket() 方法的实现。
在 RealConnection 的 connectSocket() 方法中,首先会判断代理方式,如果代理方式为无代理(DIRECT)或 HTTP 代理,则使用 Socket 工厂创建 Socket,否则使用 Socket(proxy)
创建 Socket。
创建完 Socket 后,RealConnection 就会调用 Platform 的 connectSocket() 方法连接 Socket ,再初始化用来与服务器交换数据的 Source 和 Sink。
Platform 的 connectSocket() 方法调用了 Socket 的 connect() 方法,后面就是 Socket API 的活了。
6.6 建立协议
创建完 Socket 后,RealConnection 的 connect() 方法就会调用 establishProtocol() 方法建立协议。
在 establishProtocol() 方法中会判断,如果使用的方案是 HTTP 的话,则判断是否基于先验启动 HTTP/2(rfc_7540_34),先验指的是预先知道,也就是客户端知道服务器端支持 HTTP/2
,不需要不需要升级请求,如果不是基于先验启动 HTTP/2 的话,则把协议设为 HTTP/1.1 。
OkHttpClient 默认的协议有 HTTP/1.1 和 HTTP/2 ,如果我们已经知道服务器端支持明文 HTTP/2
,我们就可以把协议改成下面这样。
val client = OkHttpClient.Builder()
.protocols(mutableListOf(Protocol.H2_PRIOR_KNOWLEDGE))
.build()
如果请求使用的方案为 HTTP 的话,establishProtocol() 方法则会调用 connectTls()
方法连接 TLS ,如果使用的 HTTP 版本为 HTTP/2.0 的话,则开始 HTTP/2.0 请求。
7. HTTPS 连接建立机制
在看 connectTls() 方法的实现前,我们先来看一些 HTTPS 相关的基础知识,如果你已经了解的话,可以跳过这一段直接从 8.2 小节看起。
7.1 HTTPS 基础知识
在 HTTP 模式下,搜索或访问请求以明文信息
传输,经过代理服务器、路由器、WiFi 热点、服务运营商等中间人
通路,形成了“中间人”获取数据、篡改数据的可能。
但是从 HTTP 升级到 HTTPS,并不是让 Web 服务器支持 HTTPS 协议这么简单,还要考虑 CDN、负载均衡、反向代理等服务器、考虑在哪种设备上部署证书与私钥,涉及网络架构和应用架构的变化。
7.1.1 中间人攻击
接下来我们来看下什么是中间人攻击
,中间人攻击分为被动攻击
和主动攻击
两种。
中间人就是在客户端和服务器通信之间有个无形的黑手,而对于客户端和服务器来说,根本没有意识到中间人的存在,也没有办法进行防御。
1. 被动攻击
是对着手机设备越来越流行,而移动流量的资费又很贵,很多用户会选择使用 WiFi 联网,尤其是在户外,用户想方设法使用免费的 WiFI 。
很多攻击者会提供一些免费的 WiFi,一旦连接上恶意的 WiFI 网络,用户将毫无隐私。提供 WiFI 网络的攻击者可以截获所有的 HTTP 流量,而 HTTP 流量是明文的,攻击者可以知道用户的密码、银行卡信息以及浏览习惯,不用进行任何分析就能获取用户隐私,而用户并不知道自己的信息已经泄露,这种攻击方式也叫被动攻击
。
2. 主动攻击
很多用户浏览某个网页时,经常会发现页面上弹出一个广告,而这个广告和访问的网页毫无关系,这种攻击主要是 ISP(互联网服务提供商,Internet Service Provider)发送的攻击,用户根本无法防护。
用户访问网站时肯定经过 ISP ,ISP 为了获取广告费等目的,在响应中插入一段 HTML 代码,就导致了该攻击的产生,这种攻击称为主动攻击
,也就是攻击者知道攻击的存在。
更严重的是 ISP 或攻击者在页面插入一些恶意的 JavaScript 脚本,脚本一旦在客户端运行,可能会产生更恶劣的后果,比如 XSS 攻击(跨站脚本攻击,Cross Site Scripting)。
7.1.2 握手层与加密层
HTTPS(TLS/SSL协议)设计得很巧妙,主要由握手层和加密层两层组成,握手层在加密层的上层,提供加密所需要的信息(密钥块)。
对于一个 HTTPS 请求来说,HTTP 消息在没有完成握手前,是不会传递给加密层的,一旦握手层处理完毕,最终应用层所有的 HTTP 消息都会交给密钥层进行加密。
1. 握手层
客户端与服务器端交换一些信息,比如协议版本号、随机数、密码套件(密码学算法组合)等,经过协商,服务器确定本次连接使用的密码套件,该密码套件必须双方都认可。
客户端通过服务器发送的证书确认身份后,双方开始密钥协商,最终双方协商出预备主密钥、主密钥、密钥块
,有了密钥块,代表后续的应用层数据可以进行机密性和完整性保护了,接下来由加密层处理。
2. 加密层
加密层有了握手层提供的密钥块,就可以进行机密性和完整性保护了,加密层相对来说逻辑比较简单明了,而握手层在完成握手前,客户端和服务器需要经过多个来回才能握手完成,这也是 TLS/SSL 协议缓慢的原因。
下面分别是使用 RSA 密码套件和 DHE_RSA 密码套件的 TLS 协议流程图。
7.1.3 握手
握手指的是客户端和服务器端互相传数据前要互相协商
,达成一致后才能进行数据的加密
和完整性处理
,认证
和密码套件协商
握手的关键步骤和概念。
1. 认证
客户端在进行密钥交换前,必须认证服务器的身份
,否则就会存在中间人攻击,而服务器实体并不能自己证明自己,所以要通过 CA 机构
来认证。
认证的技术解决方案就是签名的数字证书,证书中会说明 CA 机构采用的数字签名算法,客户端获取到证书后,会采用相应的签名算法进行验证,一旦验证通过,则表示客户端成功认证了服务器端的身份。
2. 密码套件协商
密码套件是 TLS/SSL 中最重要的一个概念,理解了密码套件就相当于理解了 TLS/SSL 协议
,客户端与服务器端需要协商出双方都认可的密码套件,密码套件决定了本次连接客户端和服务器端采用的加密算法、HMAC 算法、密钥协商算法等各类算法。
密码套件协商的过程类似于客户采购物品的过程,客户(客户端)在向商家(服务器)买东西前要告诉商家自己的需求、预算,商家了解了客户的需求后,根据客户的具体情况给用户推荐商品,只有双方都满意时,交易才能完成。
而对于 TLS/SSl 协议来说,只有协商出密码套件,才能进行下一步的工作。
HTTP 是没有握手过程的,完成一次 HTTP 交互,客户端和服务器端只要一次请求/响应就能完成。
而一次 HTTP 交互,客户端和服务器端要进行多次交互才能完成,交互的过程就是协商,泌乳客户端告诉服务器端其支持的密码套件,服务器端从中选择一个双方都支持的密码套件。
密码套件的构成如下图所示。
7.1.4 加密
与握手层相比,加密层的处理相对简单,握手层协商出加密层需要的算法和算法对应的密钥块,加密层接下进行加密运算和完整性保护。
在 TLS/SSL 协议中,主要有流密码加密模式、分组加密模式、AEAD 模式三种常见的加密模式。
7.1.5 TLS/SSL 握手协议
TLS 记录协议中加密参数(Security Paramters)的值都是 TLS/SSL 握手协议填充完成的,对应的值是由客户端和服务器端共同协商完成的,独一无二。
对于一个完整握手会话,客户端和服务器端要经过几个来回才能协商出加密参数。
与加密参数关联最大的就是密码套件,客户端与服务器端会列举出支持的密码套件,然后选择一个双方都支持的密码套件,基于密码套件协商出所有的加密参数,加密参数中最重要的是主密钥(master secret)。
在讲解流程前,有几点要说明。
握手协议由很多子消息构成
,对于完整握手来说,客户端与服务器端一般要经过两个来回才会完成握手。
ChangeCipherSpec 不是握手协议的一部分
,在理解时可以认为 ChangeCipherSpec 是握手协议的一个子消息。
星号标记( * )表示对应的子消息是否发送,取决于不同的密码套件
,比如 RSA 密码套件不会出现 ServerKeyExchange 子消息。
在 HTTPS 中,服务器和客户端都可以提供证书让对方进行身份校验
。
下面是完整的 TLS/SSL 握手协议交互流程。
握手协议的主要步骤如下:
互相交互 hello 子消息
,该消息交换随机值和支持的密码套件列表,协商出密码套件以及对应的算法,检查会话是否可恢复;
交换证书和密码学信息
,允许服务器端与客户端相互校验身份;
交互必要的密码学参数
,客户端与服务器端获得一致的预备主密钥;
通过预备主密钥和服务器/客户端的随机值生成主密钥
;
握手协议提供加密参数
(主要是密码块)给 TLS 记录层协议;
客户端与服务器端校验对方的 Finished 子消息
,以避免握手协议的消息被篡改;
7.1.6 扩展
通过扩展,客户端与服务器端可以在不更新 TLS/SSL 协议的基础上获取更多的能力。
在 RFC 5246 文档中,只对扩展定义了一些概念框架和设计规范,具体扩展的详细定义由 RFC 6066 制定,每个扩展由 IANA 统一注册和管理。
扩展的工作方式如下:
- 客户端根据自己的需求发送多个扩展给服务器,扩展列表消息包含在 Client Hello 消息中;
- 服务器解析 Client Hello 消息中的扩展,根据 RFC 的定义逐一解析,并在 Server Hello 消息中返回相同类型的扩展;
7.1.7 基于 Session Ticket 的会话恢复
SessionTicket 解决了 Session ID 会话恢复存在的缺点,是一种更好地会话恢复方式。SessionTicket 的处理标准定义在 RFC 5077 中,在 TLS/SSL 协议中,SessionTicket 以 TLS 扩展的方式完成会话恢复,SessionTicket 扩展的实现定义在 RFC 4507 上。
如果遇到以下问题,那就特别适合用 SessionTicket。
Session ID 会话信息存储在服务器端,对于大型 HTTPS 网站来说,占用的内存量非常大
,是非常大的开销。
HTTPS 网站提供者希望会话信息的生命周期更长一些
,尽量使用简短的握手。
HTTPS 网站提供者希望会话信息能够跨主机访问
,Session ID 会话恢复显然不能。
嵌入式的服务器没有太多的内存存储会话信息
。
7.1.8 SessionTicket 的交互流程
SessionTicket 从应用的角度来看,原理很简单,服务器将会话信息加密后,以票据(ticket)的方式发送给客户端,服务器本身不存储会话信息
。
客户端受到票据后,将其存储到内存中,如果想恢复会话,则下一次连接时把票据发送给服务器端,服务器端解密后,如果确认无误,则表示可以进行会话恢复,这就完成了一次简短的握手。
SessionTicket 相对于 Session ID 来说,有两点变化:
-
会话信息由客户端保存
-
会话信息需要由服务器端解密
客户端不参与解密过程,只负责存储和传输;
SessionTicket 在具体实现时有很多种情况,下面一一说明。
1. 基于 SessionTIcket 进行完整的握手
对于一次新连接,如果期望服务器支持 SessionTicket 会话恢复,则在客户端 Client Hello 消息中包含一个空的 SessionTicket TLS 扩展
。
如果服务器支持 SessionTicket 会话恢复,那么服务器的 Server Hello 消息中也要包含一个空的 SessionTicket TLS 扩展
。
服务器端对会话信息进行加密保护,生成一个票据,然后在 NewSessionTicket 子消息中发送该票据
,NewSessionTicket 子消息是握手协议的一个独立子消息。由于是完整的握手,其他的一些子消息也会正常处理。
客户端收到 NewSessionTicket 子消息后,把票据存储起来
,以便下次使用。
2. 基于 SessionTicket 进行简短的握手
基于 SessionTicket 进行会话恢复的流程如下。
- 客户端存储一个票据,如果恢复会话,则在客户端的 Client Hello 消息中包含一个非空的 SessionTicket TLS 扩展;
- 服务器端接收到非空票据后,对票据进行解密校验,如果可以恢复,则在服务器 Server Hello 消息中发送一个空的 SessionTicket TLS 扩展;
- 由于是简短握手,所以 Certificate 和 ServerKeyChange 等子消息不发送,接下来发送一个 NewSessionTicket 子消息更新票据,票据也是有有效期的;
- 客户端与服务器端接着校验 Finished 子消息表示简单握手完成,顺利完成会话恢复;
7.2 连接隧道
1. 隧道
隧道(tunnel)是建立起来后,就会在两条连接之间对原始数据进行盲转发的 HTTP 应用程序,HTTP 隧道通常用来在一条或多条 HTTP 连接上转发非 HTTP 数据,转发时不会窥探数据。
HTTP 隧道的一种常见用途是通过 HTTP 连接承载 SSL(加密的安全套接字层,Secure Sockets Layer)流量,这样 SSL 流量就可以穿过只允许 Web 流量通过的防火墙了。
HTTP/SSL 隧道收到一条 HTTP 请求,要求建立一条到目的地之和端口的输出连接,然后在 HTTP 信道上通过隧道传输加密的 SSL 流量,这样就可以将其盲转发到目的服务器上去了。
2. connectTunnel()
RealConnection 的 connect() 方法首先会判断当前连接是否已连接,也就是 connect() 方法被调用过没有,如果被调用过的话,则抛出非法状态异常。
如果没有连接过的话,则判断 URL 是否用的 HTTPS 方案,是的话则连接隧道。
而 RealConnection 调用 connectTunnel() 方法后,connectTunnel() 会调用 connectSocket() 和 createTunnel() 方法创建Socket 和隧道。
7.3 创建隧道
在 RealConnection 的 createTunnel() 方法中,首先会创建 Http1ExchangeCodec ,然后用它来写入请求行,写完了就再刷新缓冲区,然后读取响应报文首部。
如果状态码为 200 ,表示服务器要等待客户端发送 ClientHello 消息后才会发送 ServerHello 消息。
如果状态码为 407 ,表示需要进行代理认证,这时就会使用 Authenticator 的 authenticate() 方法创建并返回一个认证请求。
7.4 获取连接规格
在前面提到了 connectTls() 方法,下面来看下这个方法的实现。
在 connectTls() 方法中,首先会用 SSLSocket 工厂创建 SSLSocket,SSLSocket 是使用 TLS/SSL 协议的安全套接字,在基础网络传输协议(如 TCP)上添加了安全保护层,关于 SSLSocket 相关的实现在本文不会展开讲。
创建完 SSLSocket 后,connectTls() 方法就会调用 ConnectionSpecSelector 的 创建连接规格 ConnectionSpec,ConnectionSpec 包含了密码套件、 TLS 版本以及是否支持 TLS 扩展。
在 OkHttpClient 中默认的连接规格有 MODERN_TLS 与 CLEARTEXT 两种。
- MODERN_TLS
支持 TLS 扩展,TLS 版本为 1.2 和 1.3 ,密码套件列表对应的数组为 APPROVED_CIPHER_SUITES ,这个数组中的密码套件与 Chrome 72 使用的密码套件差不多;
- CLEARTEDX
明文,不支持 TLS 扩展,无 TLS 版本和密码套件;
如果我们想修改密码套件或使用的 TLS 版本的话,我们只需要在创建 OkHttpClient 时通过 connectionSpecs() 方法设置即可。
当 URL 的方案为 HTTPS 时,对应的连接规格就是 MODENR_TLS 。
7.5 TLS 扩展
1. ALPN 扩展
ALPN(应用层协议协商扩展,Application Layer Protocol Negotiation),HTTP 有两个版本,分别是 HTTP/1.1 和 HTTP/2 ,当用户在浏览器输入一个网址时,浏览器连接服务器时,并不知道服务器是否支持 HTTP/2 。
为了询问服务器是否支持特定的应用层协议,出现了 ALPN 扩展,客户端会在 Client Hello 消息中发送该扩展,一旦服务器支持 HTTP/2 ,则会在 Server Hello 消息中响应该扩展,这样客户端和服务器端就能统一使用 HTTP/2
。
2. 配置 TLS 扩展
RealConnection 的 connectTls() 方法在获取到 ConnectionSpec 后,会判断 ConnectionSpec 是否支持 TLS 扩展,如果支持的话,则调用特定平台(Platform)的 configureTlsExtentions() 方法配置 TLS 扩展。
比如 Android10Platform 会调用 Android10SocketAdapter 的 configureTlsExtrentions() 方法,而 Android10SocketAdapter 会 SSLSocket 设为使用 SessionTicket(useSessionTickets),然后再开启 ALPN 扩展。
配置完 TLS 扩展后,RealConnection 就会调用 SSLSocket 的 startHandshake() 方法开始 TLS/SSL 握手,SSLSocket 是一个抽象类,默认的 startHandshake() 的实现在 ConscryptFileDescriptorSocket 中,而 ConscryptFileDescriptor 最终会调用到 OpenSSL 提供的 SSL_do_handshake() 方法。
7.6 证书锁定
在调用了 SSLSocket 的 startHandshake() 方法后,RealConnection 就会创建一个 Handshake 对象,Handshake 包含了对端证书,对于我们客户端来说也就是服务器端的证书。
创建了 Handshake 后,RealConnection 就会用证书锁定器 CertificatePinner 检查对端证书与我们设定的 pin 是否匹配,如果匹配的话,则初始化用来交换数据的 Source 与 Sink。
CertificatePinner 会通过公钥锁定限制客户端信任的证书,在 CertificatePinner 的 check() 方法中,会把对端证书(peerCerficates)转换为 X509 证书(X509Certificate),然后判断对端证书公钥的哈希值与我们设定的 Pin 的哈希值是否相同,默认是不进行锁定的,如果我们想锁定的话,可以像下面这样做。
首先配置一个错误的哈希值。
val certificatePinner = CertificatePinner.Builder()
.add(
“publicobject.com”,
“sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=”)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
然后就会看到包含服务器端证书的公钥哈希值的信息,比如下面这样(前提是服务器端配置了证书)。
javax.net.ssl.SSLPeerUnverifiedException: Certificate pinning failure!
Peer certificate chain:
sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=: CN=publicobject.com, OU=PositiveSSL
sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=: CN=COMODO RSA Secure Server CA
sha256/grX4Ta9HpZx6tSHkmCrvpApTQGo67CYDnvprLg5yRME=: CN=COMODO RSA Certification Authority
sha256/lCppFqbkrlJ3EcVFAkeip0+44VaoJUymbnOaEUk7tEU=: CN=AddTrust External CA Root
Pinned certificates for publicobject.com:
sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
at okhttp3.CertificatePinner.check(CertificatePinner.java)
at okhttp3.Connection.upgradeToTls(Connection.java)
at okhttp3.Connection.connect(Connection.java)
at okhttp3.Connection.connectAndSetOwner(Connection.java)
然后把这些哈希值作为 pin 添加到 CertificatePinner 中即可。
val certificatePinner = CertificatePinner.Builder()
.add(“publicobject.com”, “sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=”)
.add(“publicobject.com”, “sha256/klO23nT2ehFDXCfx3eHTDRESMz3asj1muO+4aIdjiuY=”)
.add(“publicobject.com”, “sha256/grX4Ta9HpZx6tSHkmCrvpApTQGo67CYDnvprLg5yRME=”)
.add(“publicobject.com”, “sha256/lCppFqbkrlJ3EcVFAkeip0+44VaoJUymbnOaEUk7tEU=”)
.build()
8. HTTP/2 连接建立机制
8.1 HTTP/2 基础知识
HTTP/2 主要用来解决 HTTP/1 的性能问题,HTTP/2 新增了以下概念:
- 二进制协议
- 多路复用
- 流量控制功能
- 数据流优先级
- 首部压缩
- 服务端推送
HTTP/2 与 HTTPS 有很多相似点,它们都在发送前把标准 HTTP 消息用特殊的格式封装,收到响应时再解开,所以尽管客户端和服务器端需要了解发送和接收消息的细节,上层应用却不用区别对待不同的版本,因为它们所使用的 HTTP 概念相似。
1. 二进制格式
HTTP/1 和 HTTP/2 的主要区别之一,就是 HTTP/2 是一个二进制、基于数据报的协议,而 HTTP/1 是完全基于文本的,基于文本的协议方便人类阅读,但是机器解析起来比较困难。
使用基于文本的协议,要先发送请求,并接受完响应后,才能开始下一个请求。
HTTP/2 是一个完全的二进制协议,HTTP 消息被清晰定义的数据帧发送,所有的 HTTP/2 消息都使用分块的编码技术,这是标准行为,不需要显式地设置。
帧和支撑 HTTP 连接的 TCP 数据报类似,当收到所有的数据帧后,可以将它们组合为完整的 HTTP 消息。
HTTP/2 中的二进制表示用于发送和接收消息数据,但是消息本身和之前的 HTTP/1 消息类似,二进制帧通常由下层客户端或类库处理。
最后
答应大伙的备战金三银四,大厂面试真题来啦!
这份资料我从春招开始,就会将各博客、论坛。网站上等优质的Android开发中高级面试题收集起来,然后全网寻找最优的解答方案。每一道面试题都是百分百的大厂面经真题+最优解答。包知识脉络 + 诸多细节。
节省大家在网上搜索资料的时间来学习,也可以分享给身边好友一起学习。
给文章留个小赞,就可以免费领取啦~
《960全网最全Android开发笔记》
《379页Android开发面试宝典》
包含了腾讯、百度、小米、阿里、乐视、美团、58、猎豹、360、新浪、搜狐等一线互联网公司面试被问到的题目。熟悉本文中列出的知识点会大大增加通过前两轮技术面试的几率。
如何使用它?
1.可以通过目录索引直接翻看需要的知识点,查漏补缺。
2.五角星数表示面试问到的频率,代表重要推荐指数
《507页Android开发相关源码解析》
只要是程序员,不管是Java还是Android,如果不去阅读源码,只看API文档,那就只是停留于皮毛,这对我们知识体系的建立和完备以及实战技术的提升都是不利的。
真正最能锻炼能力的便是直接去阅读源码,不仅限于阅读各大系统源码,还包括各种优秀的开源库。
腾讯、字节跳动、阿里、百度等BAT大厂 2020-2021面试真题解析
资料收集不易,如果大家喜欢这篇文章,或者对你有帮助不妨多多点赞转发关注哦。文章会持续更新的。绝对干货!!!
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级安卓工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年最新Android移动开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
…(img-1gn5LtJN-1711017204936)]
资料收集不易,如果大家喜欢这篇文章,或者对你有帮助不妨多多点赞转发关注哦。文章会持续更新的。绝对干货!!!
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级安卓工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年最新Android移动开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-qSM4wLIk-1711017204937)]
[外链图片转存中…(img-69KjRQSB-1711017204937)]
[外链图片转存中…(img-9aNSXx0G-1711017204937)]
[外链图片转存中…(img-9ShSV3dv-1711017204938)]
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
[外链图片转存中…(img-nNXmN8C3-1711017204938)]