探索 OkHttp 原理

前言

1. OkHttp 请求处理流程概述

OkHttp 请求处理流程.png

当我们用 OkHttp 发起同步请求时,请求会被 OkHttp 的请求分发器 Dispatcher 放到同步请求队列中,然后直接执行请求。

当发起异步请求时,Dispatcher 会把请求放到异步请求队列,然后在合适的时机把异步请求提交到线程池中执行。

请求的执行由拦截器链负责,各个拦截器处理的顺序为:重试与重定向拦截器首部构建拦截器缓存拦截器连接拦截器数据交换拦截器

当数据交换拦截器 CallServerInterceptor 接收到请求时,会通过数据交换器 Exchange 写入请求信息,而 Exchange 会通过 Socket 提供的输出流写入请求信息,通过输入流读取响应信息。

当 CallServerInterceptor 读取完了响应信息后,就会往上传递,直到把响应信息返回给最开始发起请求的地方。

2. OkHttp 基本用法

接下来将基于 OkHttp 4.9.0 进行讲解,演示代码是用 Kotlin 写的。

1. 添加依赖
image
2. 发起请求
image

3. 内容概览

探索 OkHttp 原理.png

接下来的内容会把 OkHttp 的实现和 HTTP、HTTPS、HTTP/2 等网络知识结合在一起讲,因为如果忘了这些基础知识的话,就不知道 OkHttp 背后做的这些事情有什么意义了。

这篇文章不会讲用于分析网络情况的 EventListener,也不会讲 WebSocket 相关的实现,由于连接建立好后,数据交换就没啥大问题了,所以关于 CallServerInterceptor 的实现也不会讲。

1. 请求报文 Request

Request.png

Request 是请求报文,包含了请求相关信息,比如请求方法和请求地址和请求头等信息,Request 有下面 6 个字段。

  • 请求地址
  • 请求方法
  • 请求头
  • 请求体
  • 标签
  • 缓存控制

关于缓存控制在 5.4 小节会讲,下面来看下 Request 中的其他 5 个字段的内容和作用。

1.1.1 统一资源定位器 HttpUrl

Request 会把我们传入 url() 函数中的请求地址转化为 HttpUrl 对象。

HttpUrl.png
1. 协议

使用 http: 或 https 等协议方案名获取访问资源时,要指定协议类型,不区分字母大小写,最后加一个冒号( : )。

2. 登录信息(认证)

指定用户名和密码作为从服务端获取资源时必要的登录信息(身份认证),这是可选项。

3. 主机

主机组件标识了因特网上能够访问资源的宿主机器,比如 www.xxx.com192.168.1.66

4. 端口号

端口组件标识了服务器正在监听的网络端口,对下层使用了 TCP 协议的 HTTP 来说,默认端口为 80

5. 查询路径

服务器上资源的本地名,由斜杠( / )将其与前面的 URL 组件分隔开来,路径组件的语法与服务器的方案有关。

的路径组件说明了资源位于服务器的什么地方,类似于分级的文件系统路径,比如 /goods/details

6. 查询参数

某些方案会用这个组件传递参数以激活应用程序,查询组件的内容没有通用格式,用问号( ? )将其与 URL 其余部分分隔开来;

很多资源,比如数据库服务,都是可以通过提供查询参数缩小请求资源范围的,比如传入页码和页大小查询列表 http://www.xxx.com/?page=1&pageNum=20 ,这里 page = 1 表示 page 的值为 1 ,pageNum 的值为 20 。

7. 片段

片段(fragment)表示一部分资源的名字,该字段不会发送给服务器,是在客户端内部使用的,通过井号(#)将其与 URL 其余部分分割开来。

有的资源类型,比如 HTML ,除了资源级以外,还可以作进一步划分,比如想看某一篇长文的某一小节。

为了引用部分资源或资源的一个片段,URL 支持使用片段组件表示一个资源内部的片段,比如 HTML 文档中特定的图片或小节,比如在 http:://www.xxx.com/tools.html#drills 中,片段 drills 引用了 tools.html 中的一部分,这部分的名字就叫 drills。

HTTP 服务器通常只处理整个对象而不是对象的片段,客户端不能把片段发送给服务器,客户端从服务器获取整个资源后,会根据片段显示用户感兴趣的部分资源。

1.1.2 请求方法 method

Request 中有下面几个设置请求方法的函数。

  • get()

  • post()

  • head()

  • delete()

  • put()

  • patch()

  • method()

    把传进来的参数作为请求方法,比如method("POST")

下面是不同的 HTTP 请求方法的作用。

1. GET:获取资源

GET 是最常用的方法,通常用于请求服务器发送某个资源,HTTP/1.1 要求服务器实现该方法。

2. POST:发送数据

POST 方法起初用于向服务器输入数据,通常用它来发送表单数据,然后由服务器将其发送到它要去的地方,比如送到一个服务器网关程序中,然后由这个程序对其进行处理。

3. HEAD:获取首部

HEAD 方法与 GET 方法类似,但服务器在响应中只会返回首部,不会返回实体的主题部分。

这就允许客户端在未获取实际资源的情况下,对资源的首部进行检查,使用 HEAD 方法可以做下面几件事情。

  • 在不获取资源的情况下了解资源的情况,比如判断资源类型;
  • 通过查看响应中的状态码查看某个对象是否存在;
  • 通过查看首部测试资源是否被修改;

服务器开发者必须确保返回的首部与 GET 请求返回的首部完全相同。

4. DELETE:删除资源

DELETE 用于请服务器删除 URL 指定的资源。

5. PUT:传输文件

与 GET 从服务器读取文档相反,PUT 方法会向服务器写入文档,有些发布系统允许用户创建 Web 页面,并用 PUT 直接将其安装到 Web 服务器上去。

PUT 方法的语义就是让服务器用请求的主体部分创建一个由所请求的 URL 命名的新文档,如果 URL 已经存在的话,就用新的主体替换它。

因为 PUT 允许用户对内容进行修改,所以很多 Web 服务器都要求在执行 PUT 前用密码登录。

6. PATCH:修改部分资源

在 HTTP 协议中,PATCH 用于对资源进行部分修改。

1.1.3 首部字段 Headers

Header 用于存放 HTTP 首部,Headers 中只有一个字段,就是 namesAndValues ,类型为 Array<String> ,比如 addHeader(a, 1) 对应的 namesAndValues 为 [a, 1]

HTTP 协议的请求和响应报文中必定包含 HTTP 首部,首部内容为客户端和服务器分别处理请求和响应提供所需要的信息,下面是 HTTP 请求报文和 HTTP 响应报文中包含的首部字段。

1. HTTP 请求报文

在请求中,HTTP 报文由方法、URI、HTTP 版本、HTTP 首部字段等部分构成。

image
2. HTTP 响应报文

在响应中,HTTP 报文由 HTTP 版本、状态码(数字和原因短语)、HTTP 首部字段 3 部分构成。

[图片上传失败...(image-a5e446-1606899237993)]

1.1.4 请求体 RequestBody

RequestBody.png

RequestBody 是一个抽象类,有下面 3 个方法。

  1. 内容类型 contentType()

    比如 application/x-www-form-urlencoded

  2. 内容长度 contentLength()

  3. 写入内容 writeTo()

    把请求的内容写入到 okio 提供的 Sink 中;

RequestBody 中还有 4 个用于创建 RequestBody 的扩展方法 xxx.toRequestBody() 。

  • String.toRequestBody()

    比如 Map.toString().toRequestBody()

  • ByteString.toRequestBody()

  • ByteArray.toRequestBody()

  • File.asRequestBody()

1.1.5 标签

我们可以用 tag() 方法给请求加上标签,然后在拦截器中根据不同的标签栏做不同的操作。

val request = Request.Builder()
    .url(...)
  .tag("666")
    .build()

在 Retrofit 中用的则是 @Tag 注解,比如下面这样。

@POST("app/login")
suspend fun login(
  @Query("account") phone: String,
  @Query("password") password: String,
  @Tag tag: String
) : BaseResponse<User>

然后在拦截器中,就能根据 tag 的类型来获取 tag。

override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val tag = request.tag(String::class.java)
    Log.e("intercept", "tag: ${tag}")
    return chain.proceed(request)
}

2. OkHttp 请求分发机制

2.1 请求操作 Call

image

在我们创建请求 Request 后,我们要用 OkHttpClient.newCall() 创建一个 RealCall 对象,然后调用 execute() 发起同步请求或调用 enqueue() 发起异步请求。

RealCall 实现了 Call 接口,也是这个接口唯一的实现类,按注释来说,RealCall 是一个 OkHttp 应用与网络层之间的桥梁,该类暴露了高级应用层的原语(primitives):连接、请求、响应与流。

你也可以把 RealCall 理解为同步请求操作,而 RealCall 的内部类 AsyncCall 则是异步请求操作

下面我们来看下 RealCall 中比较中要的两个方法的实现:execute() 与 enqueue() 。

1. 发起同步请求 execute()
RealCall.execute__1.png

当我们调用 RealCall.execute() 发起同步请求时,如果该请求已执行,那么会抛出非法状态异常,所以我们在发起同步请求的地方要注意捕获异常。

image

如果请求没有被执行的话,execute() 方法则会调用 AsyncTimeout 的 enter() 方法让 AsyncTimeout 做请求超时判断,AsyncTimeout 中有一个继承了 Thread 的内部类 WatchDog,而 AsyncTimeout 会用 Object.wait/notify() 阻塞和唤醒 Watchdog 线程。

当请求超时时,AsyncTimeout 会调用 RealCall 中实现的 timeOut() 方法关闭连接,关于 AsyncTimeout 的实现后面会进一步讲解。

RealCall 的 execute() 方法调用完 enter() 方法后,会调用 Dispatcher 的 executed() 把请求加入同步请求队列,然后调用 getResponseWithInterceptorChain() 方法获取响应,获取到响应后就会让 Dispatcher 把请求从同步请求队列中移除。

2. 发起异步请求 enqueue()
RealCall.enqueue__1.png

和同步请求一样,当我们调用 enqueue() 方法发起异步请求时,如果该请求已经执行了,那么 RealCall 就会抛出非法状态异常。

当该请求还没有被执行时,execute() 方法就会创建一个异步请求操作 AsyncCall,并把它交给 Dispatcher 处理。

RealCall.enqueue__2.png

AsyncCall 实现了 Runnable 接口,Dispatcher 接收到 AsyncCall 后,会把 AsyncCall 添加到待执行异步请求队列 readyAsyncCalls 中,然后调用自己的 promoteAndExecute() 方法,关于 Dispatcher 的实现后面再讲。

把 AsyncCall 加入到异步请求队列后,Dispatcher 会看情况决定什么时候执行该异步请求,要执行的时候就会把请求任务提交到线程池 ExecutorService 中。

和同步请求一样,在 AsyncCall 的 run() 方法中做的第一件事情就是让 AsyncTimeout 进入超时判断逻辑,然后用拦截器链获取响应。

RealCall.enqueue()

当请求的过程中没有遇到异常时,AsyncCall 的 run() 方法就会调用我们设定的 Callback 的 onResposne() 回调,如果遇到了异常,则会调用 onFailure() 方法。

不论异步请求是成功还是失败,RealCall 最后都会调用 Dispatcher 的 finished() 方法把请求从已运行异步请求队列 runningAsyncCalls 中移除。

2.2 请求分发器 Dispatcher

OkHttp 请求处理流程2.png

当我们调用了 RealCall 的 execute() 或 enqueue() 方法后,RealCall 会调用 Dispatcher 对应的 execute() 和 enqueue() 方法,请求分发器 Dispatcher 的 execute() 方法只是简单地把同步请求加入同步请求队列,下面我们来看下 Dipsatcher 中比较重要的 enqueue() 方法的实现。

Dispatcher.enqueue__1.png

Dispatcher 的 enqueue() 方法首先会把请求加入到待运行请求队列,然后重用 AsyncCall 的 callsPerHost 字段,callsPerHost 表示当前请求的主机地址的已执行请求数量

在我们把某个异步请求加入队列时,Dispatcher 会从已运行异步请求队列和待运行异步请求队列中找出与该请求主机地址相同的请求,找到主机相同的请求的话,就重用该请求的 callsPerHost 字段,也就是是每执行一个该主机地址的请求时,这个值就会加 1 。

如果我们的应用中经常会发起多个请求,并且请求的主机地址不多时,我们可以修改 Dispatcher 中的 maxRequestsPerHost 的值,比如下面这样。

okHttpClient.dispatcher.maxRequestsPerHost = 10

这个值默认为 5 ,也就是单个主机地址在某一个时刻的并发请求只能是 5 个。

Dispatcher.enqueue()

做完重用操作后,Dispatcher 就会创建一个可执行异步请求列表 executableCalls ,然后遍历待运行异步请求队列。

在遍历时,Dispatcher 会判断已运行的异步请求数量是否超出了允许的并发请求的最大值 maxRequests ,这个值默认为 64 ,也是可以被修改的。

当异步请求数量不超过最大值,并且对应主机地址的请求数量不超过最大值时,就会把该异步请求加入到 executableCalls ,然后把 executableCalls 中的请求都提交到线程池中执行

2.3 拦截器链 RealInterceptorChain

OkHttp 请求处理流程.png

当同步请求执行或异步请求被提交到线程池后执行时,就会 RealCall 的 getResponseWithInterceptorChain() 方法就会被调用。

image

在 getResponseWithInterceptorChain() 方法中,首先会创建一个 interceptors 列表,然后按下面的顺序添加拦截器。

  1. 自定义拦截器
  2. 重试拦截器(RetryAndFollowUpInterceptor)
  3. 网络请求构建拦截器(BridgeInterceptor)
  4. 缓存拦截器(CacheInterceptor)
  5. 连接拦截器(ConnectInterceptor)
  6. 自定义网络拦截器(NetworkInterceptor)
  7. 数据传输拦截器(CallServerInterceptor)

添加完这些拦截器后,就会用 interceptors 创建一个拦截器链 RealInterceptorChain() ,然后调用拦截器链的 proceed() 方法,最后返回响应。

3. OkHttp 重试机制

OkHttp 请求处理流程4.png

当 RealCall 的 getResponseWithInterceptor() 方法被调用后,拦截器链就会先调用我们自定义的拦截器,然后再调用重试拦截器。

image

重试与重定向拦截器 RetryAndFollowUpInterceptor 负责在请求失败时重试和重定向,在重试拦截器的 intercept() 方法中的代码是放在 while 中执行的,只有当重试超过了一定次数或遇到异常时,执行才会被中断。

重试拦截器的 intercept() 方法首先会为请求操作 RealCall 初始化一个 ExchangeFinder,ExchangeFinder 是用来查找可重用的连接的,关于 ExchangeFinder 的实现后面会讲。

初始化 ExchangeFinder 后,intercept() 方法会通过拦截器链往下传递 Request 给其他拦截器处理,如果在这个过程中遇到了 IO 异常或路线异常,则会调用 rocover() 方法判断是否恢复请求,不恢复的话则抛出异常。

如果抛出的异常不是 IO 异常的话,那么 RealCall 就会调用 cancel() 方法关闭连接,下面来看下 recover() 方法的实现。

recover__.png

在 recover() 方法中,首先会判断 OkHttpClient 的 retryOnConnectionFailure 的值是否为 true,这个值表示是否所有请求都会要失败后重试,默认为 true ,如果我们不想取消掉所有请求都会失败重试的机制,可以在创建 OkHttpClient 时调用 retryOnConnectionFailure() 方法修改这个值。

如果 retryOnConnectionFailure 为 true,则判断当前请求是否只请求一次,这个对应的是 RequestBody 的 isOneShot() 方法,这个方法是可以重写的,也就是我们如果在调用 Post 方法创建 RequestBody 时,不想让这个请求在失败后重试,我们就可以重写 RequestBody 的 isOneShot() 方法,isOneShot() 的默认返回值为 false ,false 表示失败后重试。

如果 isOneShot() 为 false,那么 recover() 方法就会判断是否遇到了致命异常,如果是致命异常的话则不重试,这里的致命异常指的是下面这些异常。

  • 协议异常 ProtocolException
  • 证书异常 CertificateException
  • SSL 对端验证失败异常 SSLPeerUnverifiedException

如果不是致命异常,则通过 RealCall 的 retryAfterFailure() 方法判断是否还有路线可以尝试,如果还有其他路线可尝试的话,则尝试其他路线。

4. OkHttp 重定向机制

重试与重定向处理流程.png

如果其他拦截器处理当前请求时没有抛出异常的话,那么 RetryAndFollowUpInterceptor 的 intercept() 方法就会判断上一个响应(priorResponse)是否为空,如果不为空的话,则用上一个响应的信息创建一个新的响应(Response),创建完新响应后,就会调用 followUpRequest() 方法获取重定向请求

image

followUpRequest() 方法会根据不同的状态码构建重定向请求,状态码为 407 ,并且协议为 HTTP ,则返回一个包含认证挑战的请求,而获取这个请求用的是 Authenticator 。

Authenticator 有一个 authenticate() 方法,默认的是一个空实现 NONE,如果我们想替换的话,可以在创建 OkHttpClient 的时候调用 authenticator() 方法替换默认的空实现。

除了 NONE 以外,Authenticator 中还提供了另一个实现 JavaNetAutheitcator,对应的静态变量为 Authenticator.JAVA_NET_AUTHENTICATOR 。

在 JavaNetAuthenticator 的 authenticate() 方法中,会获取响应中的 Challenge(质询)列表,Challenge 列表就是对 WWW-AuthenticateProxy-Authenticate 响应头解析后生成的。

4.1 质询/响应

HTTP 提供了一个原生的质询/响应(Challenge/Response)框架,简化了对用户的认证过程,而基本认证就是认证方式之一。

当 Web 应用程序收到一条 HTTP 请求报文时,服务器没有按照请求执行动作,而是以一个“认证质询”进行响应,要求用户提供一些保密信息说明他是谁,从而对其进行质询。

当用户再次发起请求时,要附上保密证书(用户名和密码)如果证书不匹配,服务器可以再次质询客户端或返回一条错误信息,如果证书匹配,则可以正常完成请求。

4.2 基本认证

HTTP 通过一组可定制的控制首部,为不同的认证协议提供了一个可扩展框架,下面列出的首部格式和内容会随认证协议的不同而发生变化,认证协议也是在 HTTP 认证首部中指定的。

基本(BASIC)认证是 HTTP 定义的官方认证协议之一,基本认证相关的首部如下。

  • WWW-Authenticate

    服务器上可以会分为不同的区域,每个区域都有自己的密码,所以服务器会在 WWW-Authenticate 首部对保护区域进行描述。

  • Authorization

    客户端收到 401 状态码后,重新发出请求,这次会附加一个 Authorization 首部,用于说明认证算法以及用户名和密码;

  • Authentication-Info

    如果授权书是正确的,服务器就会返回指定资源。有的授权算法会在可选的 Authentication-Info 首部返回一些与授权会话相关的附加信息;

Basic 认证步骤
1. 发送请求

当请求的资源需要 BASIC 认证时,服务器会随状态码 401 Authorization Required 返回带 WWW-Authenticate 首部字段的响应,该字段内包含认证的方式(BASIC)以及 Request-URI 安全域字符串(realm)。

2. 发送用户 ID 与密码

接收到 401 状态码的客户端为了通过 BASIC 认证,要把用户 ID 与密码发送给服务器,发送的字符串内容是由用户 ID 和密码构成,两者中间以冒号(:)连接后,再经过 Base64 编码处理。

假设用户 ID 为 guest,密码也是 guest,连接起来就是 guest:guest ,经过 Base64 编码后的结果为 Z3Vlc3Q6Z3Vlc3Q= ,客户端把这串字符串写入首部字段 Authorization 后就会发出请求。

当用户代理为浏览器时,用户只需要输入用户 ID 和密码即可,浏览器会自动完成 Base64 编码的转换工作。

3. 返回认证结果

服务器端接收到包含首部字段 Authorization 请求后,会对认证信息的正确性进行验证,验证通过则返回一条包含 Request-URI 资源的响应。

BASIC 认证虽然采用 Base64 编码,但不是加密处理,不需要任何附加信息就可以对其进行解码,由于明文解码后就是用户 ID 和密码,在 HTTP 等非加密通信的线路上进行 BASIC 认证的过程中,如果被窃听,那账号被盗的可能性极高。

BASIC 认证使用上不够便捷灵活,达不到多数 Web 网站期望的安全性等级,所以并不常见。

3.1.3 代理认证

客户端与服务器中间的代理服务器也可以实现认证功能,有的组织会在用户访问服务器、LAN 或无线网络前用代理服务器对其进行认证。

为了对组织内部资源的访问进行集中管理,有的组织会通过代理服务器对统一控制内部资的访问,这个过程的第一步就是通过代理认证(Proxy Authentication)识别身份。

代理认证的步骤与 Web 服务器身份验证的步骤相同,但首部和状态码不同,区别如下。

Web 服务器 代理服务器
状态码 401 407
质询 WWW-Authenticate Proxy-Authenticate
授权 Authorization Proxy-Authorization
成功 Authentication-Info Authentication-Info

下面是代理认证的流程。

image

代理可以作为访问控制设备使用,HTTP 定义了一种名为代理认证(proxy authentication)的机制,这种机制可以组织对内容的请求,直到用户向代理提供了有效的访问权限证书为止。

1. 返回 407 响应

对受限内容的请求到达一台代理服务器时,代理服务器可以返回一个要求使用访问证书的 407 Proxy Authorization Required 状态码,以及一个用于描述怎样提供这些证书的 Proxy-Authenticate 首部字段;

2. 发送证书

客户端收到 407 响应时,会尝试从本地数据库中,或通过提示用户收集所需要的证书,获得证书后客户端就会重新发送请求,并在 Proxy-Authorization 首部字段中提供要求的证书。

3. 往下传送

当证书有效时,代理就会把原始请求沿着传输链路往下传送,否则就发送另一条 407 响应;

4.3 处理 3XX 重定向状态码

image

当响应的状态码为 300、301、302、303、307、308 时, followUpRequest() 方法就会调用 buildRedirectRequest() 构建重定向请求,下面我们来看下这些状态码的含义。

3xx 重定向状态码要么告诉客户端使用替代位置访问客户端感兴趣的资源,要么提供一个替代的响应而不是资源的内容。

当资源被移动后,服务器可发送一个重定向状态码和一个可选的 Location 首部告诉客户端资源已被移走,以及现在哪里可以找到该资源,这样客户端就可以在不打扰使用者的情况在新的位置获取资源了。

1. 300 Multiple Choices

客户端请求一个实际指向多个资源的 URL 时会返回 300 状态码,比如服务器上有某个 HTML 文档的英语和法语版本。

返回这个代码时会带有一个选项列表,这样用户就可以选择他希望使用的那一项了。

2. 301 Moved Permanently

永久性重定向,该状态码表示请求的资源已被分配了新的 URI ,以后应使用资源现在所指的 URI ,也就是如果已经把资源对应的 URI 保存为输钱了,这时应按 Location 首部字段提示的 URI 重新保存。

比如当指定资源路径的最后忘记加斜杠 "/" ,就会产生 301 状态码。

3. 302 Found

临时性重定向,该状态码表示请求的资源已被分配了新的 URI ,希望用户(本次)能使用新的 URI 访问资源。

4. 303 See Other

该状态码表示由于请求对应的资源存在另一个 URI ,应使用 GET 方法定向获取请求的资源。

比如当客户端使用 POST 方法访问 CGI 程序,CGI 程序执行后的结果是希望客户端能以 GET 方法重定向到另一个 URI 上时就会返回 303 。

5. 304 Not Modified

该状态码表示客户端发送附带条件的请求时,服务器端允许请求访问资源,但因发生请求为满足条件的情况后,直接返回 304 Not Modified 表示服务器端资源未改变,可直接使用客户端未过期的缓存。

6. 305 Use Proxy

用来说明必须通过一个代理访问资源,代理的位置由 Location 首部给出,很重要的一点是,客户端是相对某个特定资源来解析这条响应的,不能假定所有请求,甚至所有对持有请求资源的服务器的请求都通过这个代理进行。

如果客户端错误地让代理介入了某条请求,可能会引发破坏性行为,而且会造成安全漏洞。

7. 307 Temporary Redirect

与 301 状态码类似,但客户端应使用 Location 首部给出的 URL 临时定位资源,将来的请求应使用老的 URL 。

8. 308 Permanent Redirect

308 与 301 定义一致,唯一的区别是 308 状态码不允许浏览器把原本为 POST 的请求重定向到 GET 请求上。

4.4 处理 408、421 与 503 状态码

image

当状态码为 408(请求超时)时,followUpRequest() 方法就会判断是否进行重试,判断的依据也是 OkHttpClient 的 retryOnConnectionFailure 字段和 RequestBody.isOneShot() 方法。

当状态码为 503(服务不可用)时,followUpRequest() 方法就会判断上一次的响应是不是也是服务不可用,如果是的话则返回空。

当状态码为 42(无法产生响应)的服务器时,followUpRequest() 方法就会按原样返回请求。

5.OkHttp 首部构建机制

image

看完了 RetryAndFollowUpInterceptor 相关的重试与重定向机制,接下来看下网络请求构建拦截器 BridgetInterceptor。

重试与重定向拦截器只有在请求的过程中遇到异常或需要重定向的时候才有活干,在它收到请求后会把请求直接通过拦截器链交给下一个拦截器,也就是 BridgeInterceptor 处理。

之所以把 BridgeInterceptor 叫首部构建拦截器,是因为我们给 Request 设置的信息缺少了部分首部信息,这时就要 BridgeInterceptor 把缺失的首部放到 Request 中。

下面是 BridgeInterceptor 为请求添加的首部字段。

  • Content-Type:实体主体的媒体类型
  • Content-Length:实体主体的大小(字节)
  • Transfer-Encoding:指定报文主体的传输方式
  • Host:请求资源所在的服务器
  • Connection:逐跳首部、连接的管理
  • Accept-Encoding:优先的内容编码
  • Cookie:
  • User-Agent:HTTP 客户端程序的信息

下面我们来看下这些首部的作用。

1. Content-Type:实体主体的媒体类型

Content-Type: text/html; charset-UTF-8

首部字段 Content-Type 说明了实体主体内对象的媒体类型,字段值用 type/subtype 形式赋值,比如下面这些媒体类型。

  • 文本文件
    • Text/html
    • text/plain
    • text/css
  • 图片文件
    • image/jpeg
    • image/gif
  • 视频文件
    • video/mpeg
    • video/quicktime
  • 应用程序使用的二进制文件
    • application/octec-stream
    • application/zip
2. Content-Length:实体主体的大小(字节)

首部字段 Content-Length 表明了实体主体部分的大小(单位是字节),对实体主体进行内容编码传输时,不能再使用 Content-Length 首部字段。

3. Transfer-Encoding:指定报文主体的传输方式

Transfer-Encoding: chunked

首部字段 Transfer-Encoding 规定了传输报文主体时采用的编码方式,HTTP/1.1 的传输编码方式仅对分块传输编码有效。

4. Host:请求资源所在的服务器

Host: www.xxx.com

首部字段 Host 告诉服务器请求的资源所处的互联网主机名和端口号,Host 首部字段在 HTTP/1.1 规范中是一个必须被包含在请求内的首部字段。

请求被发送至服务器时,请求中的主机名会用 IP 地址直接替换解决,这时如果相同的 IP 地址下部署运行着多个域名,那么服务器就无法理解是哪个域名对应的请求,所以就要用 Host 明确指出请求的主机名。

5. Connection

HTTP 允许在客户端和最终的源服务器之间存在一串 HTTP 的中间实体(代理、高速缓存等),可以从客户端开始,逐跳地将 HTTP 报文经过这些中间设备转发到源服务器上。

在某些情况下,两个相邻的 HTTP 应用程序会为它们共享的连接应用一组选项,而 Connection 首部字段中有一个由逗号分隔的链接标签列表,这些标签为此连接指定了一些不会被传播到其他连接中的选项,比如用 Connection:close 说明发送完下一条报文后必须关闭的连接。

Connection 首部可以承载 3 种不同类型的标签。

  • HTTP 首部字段名,列出了只与此连接有关的首部;
  • 任意标签值,用于描述此连接的非标准选项;
  • close,说明操作完成后要关闭这条持久连接;

如果连接标签中包含了一个 HTTP 首部字段的名称,那么这个首部字段就包含了与一些连接有关的信息,不能将其转发出去,也就是要在报文转发出去前,必须删除 Connection 首部列出的所有首部字段。

由于 Connection 首部可以放置无意对本地首部的转发,因此将逐条首部名放入 Connection 首部也叫“对首部的保护”,比如 Connection:meter 表示不应该转发 Meter 首部。

HTTP 应用程序收到一条带有 Connection 首部的报文时,接收端会解析发送端请求的所有选项并将其应用,然后会在将此报文转发给下一跳地址前,删除 Connection 首部以及 Connection 中列出的所有首部。

在 BridgeInterceptor 中,当我们没有设置 Connection 首部时,BridgeInterceptor 会传一个值为 Keep-Alive 的 Connection 首部用于开启持久连接,关于持久连接后面会讲到。

6. Cookie

管理服务器与客户端之间状态的 Cookie 虽然没用被编入标准化 HTTP/1.1 的 RFC2616 中,但在 Web 网站方面得到了广泛的应用。

Cookie 的工作机制是用户识别与状态管理,Web 网站为了管理用户状态会通过 Web 浏览器把一些数据临时写入用户的计算机内,当用户访问该 Web 网站时,可以通过通信方式取回之前存放的 Cookie。

调用 Cookie 时,由于可校验 Cookie 的有效期以及发送方的域、路径和协议等信息,所以正规发布的 Cookie 内的数据不会因来自其他 Web 站点和攻击者的攻击而泄漏。

下面是两个与 Cookie 有关的首部字段。

  • 响应首部字段 Set-Cookie:开始状态管理所使用的 Cookie 信息
  • 请求首部字段 Cookie:服务器接收到的 Cookie 信息

Cookie: status=enable

首部字段 Cookie 会告诉服务器,当客户端想获得 HTTP 状态管理支持时,就会在请求中包含从服务器接收到的 Cookie,接收到多个 Cookie 时,同样可以以多个 Cookie 形式发送。

在 BridgeInterceptor 中,与 Cookie 相关的实现为 CookieJar 接口,默认是一个空实现类,如果我们想传 Cookie 给服务器端的话,可以在创建 OkHttpClient 时调用 cookieJar() 传入我们自己的实现。

7. User-Agent:HTTP 客户端程序的信息

首部字段 User-Agent 会将创建请求的浏览器和用户代理名称等信息传达给服务器,由网络爬虫发起请求时,有可能会在字段内添加爬虫作者的电子邮件地址,如果请求经过代理,中间也有可能被添加上代理服务器的名称。

在 BridgeInterceptor 中,当我们没有设置 User-Agent 时,默认的 UserAgent 为 okhttp:版本号,也就是User-Agent: okhttp:4.9.0

6. OkHttp 缓存机制

image

当首部构建拦截器 BridgeInterceptor 把要传给服务器端的首部放到 Request 中后,就会把请求交给缓存拦截器 CacheInterceptor 处理,为了更好地了解 CacheInterceptor 的实现,我们先来看下 HTTP 缓存机制以及相关的缓存控制首部。

6.1 HTTP 缓存机制

Web 缓存是可以自动保存常见文档副本的 HTTP 设备,当 Web 请求抵达缓存时,如果本地有已缓存的副本,就可以从本地存储设备中读取文档,不需要去源服务器提取,使用缓存有下面几个好处。

  • 减少冗余的数据传输,节省用户的流量;
  • 缓解网络瓶颈,不需要更多的贷款就能更快地加载页面;
  • 降低对源服务器的要求,服务器可以更快地响应,避免过载;
  • 降低了距离时延,因为从较远的地方加载页面会慢一些;

6.1.1 冗余的数据传输

有很多客户端访问一个流行的原始服务器页面时,服务器会多次传输同一份文档,每次传送给一个客户端,一些相同的字节会在网络中一遍遍地传输,冗余的数据传输会对导致的网络带宽费用增加、降低传输速度,加重 Web 服务器的负载。

如果有缓存,就可以保留第一条服务器响应的副本,后续请求就可以由缓存的副本来应对了,这样可以降低流量的消耗。

1. 带宽瓶颈

缓存还可以缓解网络的瓶颈问题,很多网路欧威本地服务器客户端提供的带宽比为远程服务器提供的带宽要宽,客户端会以路径上最慢的网速访问服务器,如果客户端从一个快速局域网的缓存中得到了一份副本,那么缓存就可以提高性能,尤其是传输大文件时。

2. 瞬间拥塞

缓存在破坏瞬间拥塞(Flash Crowds)时显得非常重要,突发事件(比如爆炸性新闻)会让很多人同时去访问同一个资源,这时机会出现拥塞,由此造成的流量峰值可能会导致 Web 服务器产生灾难性的崩溃。

3. 距离时延

即使带宽不是问题,距离也可能成为问题,每台网络路由器都会增加因特网流量的时延,即使客户端和服务器之间没有太多路由器,光速自身也会造成显著的时延。

6.1.2 命中和未命中的

image

我们可以用已有的复本位某些到达缓存的请求提供服务,这被称为缓存命中(Cache Hit),其他一些到达缓存的请求可能会由于没有副本可用,而被转发给原始服务器,这杯称为缓存未命中(Cache MIss)。

原始服务器的内容可能会发生变化,缓存要是不是对内容的新鲜度进行监测,看看它们保存的副本是否仍是服务器上最新的副本,这些新鲜度监测被称为 HTTP 再验证(revalidation)。

为了有效地进行再验证,HTTP 定义了一些特殊的请求,不用从服务器上获取整个对象,就可以快速检测出内容是否是最新的。

缓存可以在任意时刻,以任意的频率对副本进行再验证,但是由于缓存中通常会包含很多的文档,而且网络带宽很珍贵,所以大部分缓存只有在客户端发起请求,并且副本旧得足以需要检测时,才会对副本进行再验证。

缓存对缓存的副本进行再验证时,会向原始服务器发送一个小的再验证请求,如果内存没有变化,服务器会以一个小的 304 Not Modified 进行响应。

只要缓存知道副本仍然有效,就会再次把副本标识为暂时新鲜的,并且把副本提供给客户端,这被称为再验证命中(revalidate hit)或缓慢命中(slow hit)。

HTTP 为我们提供了几个用来对已缓存对象进行再验证的工具,最常用的是 If-Modified-Since 首部,把这个首部添加到 GET 请求中,就可以告诉服务器,只有在缓存了对象的副本后,又对其进行了修改的情况下,才发送此对象。

下面列出了在 3 种情况下,服务器收到 GET If-Modified-Since 请求时会发生的情况。

  • 再验证命中

    如果服务器对象未被修改,服务器会向客户端发送一个 HTTP 304 Not Modified 响应。

  • 再验证未命中

    如果服务器对象与已缓存副本不同,服务器向客户端发送一条普通的、带有完整内容的 HTTP 200 OK 响应。

  • 对象被删除

    如果服务器对象已经被删除,服务器就会返回一个 404 Not Found 响应,这时缓存会将对应的副本删除;

5.1.3 缓存的处理步骤

image

对一条 HTTP GET 报文的基本缓存处理包括下面 7 个步骤。

  1. 接收:缓存从网络中读取抵达的请求报文;
  2. 解析:缓存对报文进行解析,提取出 URL 和各种首部;
  3. 查询:缓存查看是否有本地副本可用,如果没有就获取一份副本并将其保存在本地;
  4. 新鲜度监测:缓存查看已缓存副本是否足够新鲜,如果不是就询问服务器是否有新的资源;
  5. 创建响应:缓存会用新的首部和已缓存的主题来构建一条响应报文;
  6. 发送:缓存通过网络把响应发挥给客户端;
  7. 日志:缓存可选地创建一个日志文件条目描述这个事务;

CacheInterceptor 大致上也是按这个流程来处理缓存的,只是在这个而基础上进行了一些细化。

6.2 缓存控制首部 CacheControl

CacheControl.png

由于通用请求首部 Cache-Control 在 OkHttp 的缓存机制中发挥着主要作用,所以下面先来看下 CacheControl 中各个字段对应的指令的作用。

通过指定通用首部字段 Cache-Control 的指令,就能操作缓存的工作机制,该指令的参数是可选的,多个指令之间通过“,”分隔。

Cache-Control: private, max-age=0, no-cache

Cache-Control 可用的指令按请求和响应的分类如下。

  • 缓存请求指令
    • no-cache
    • no-store
    • max-age
    • max-stale
    • min-refreh
    • only-if-cached
  • 缓存响应指令
    • public
    • private
    • no-cache
    • no-store
    • must-revalidate
    • proxy-revalidate
    • max-age
    • s-maxage
    • cache-extension
1. public

当指定 puiblic 指令后,则明确表示其他用户也可以利用缓存。

2. private

当指定 private 指令后,响应只以特定的用户作为对象,这与 public 指令的行为相反。

3. no-cache

使用 no-cache 指令的目的是为了防止从缓存中返回过期的资源。

如果客户端发送的请求中包含 no-cache 指令,则表示客户端不会接收缓存过的响应,于是缓存服务器必须把请求转发给源服务器。

如果服务器返回的响应中包含 no-cache 指令,那么缓存服务器就不能对资源进行缓存,源服务器以后也不再对缓存服务器请求中提出的资源有效性进行确认,且禁止其对响应资源进行缓存操作。

4. no-store

当使用 no-store 指令时,暗示请求和对应的响应或响应中包含机密信息,所以该指令规定缓存不能再本地存储请求或响应的任一部分。

5. s-maxage
Cache-Control: s-maxage=604800(单位:秒)

s-maxage 指令的功能与 max-age 指令类似,不同的是 s-maxage 指令只适用于供多位用户使用的公共缓存服务器

6. max-age
Cache-Control: s-age=604800(单位:秒)

当客户端发送的请求中包含 max-age 指令时,如果判定缓存资源的缓存时间数值比指定时间的数值更小,那么客户端就接收缓存的资源换。

当服务器返回的响应中包含 max-age 指令时,缓存服务器将不对资源的有效性再作确认,而 max-age 数值代表资源保存为缓存的最长时间。

7. min-refresh
Cache-Control: min-refresh=60(单位:秒)

min-refresh 指令要求缓存服务器返回至少还未过指定时间的缓存资源。

8. max-stale
Cache-Control: max-stale=60(单位:秒)

使用 max-stale 指令可指示缓存资源,即使过期也照常接受,如果指令未指定参数值,那么无论经过多久,客户端都会接收响应。

9. only-if-cached

使用 only-if-cached 指令表示客户端只在缓存服务器本地缓存目标资源的情况下才会要求其返回,也就是该指令要求缓存服务器不重新加载响应。

10. must-revalidate

使用该指令后,代理会向源服务器再次验证即将返回的响应缓存目前是否仍然有效,如果代理无法连通源服务器再次获取有效资源的话,缓存必须给客户端一条 504(网关超时)状态码。

11. proxy-revalidate

该指令要求所有的缓存服务器在接收到客户端带有该指令的请求返回响应前,必须再次验证缓存的有效性。

12. no-transform

该指令规定无论是在请求还是响应中,缓存都不能改变实体的媒体类型,这样做可以放置缓存或代理做压缩图片等操作。

13. cache-extension token

通过 cache-extention 标记(token),可以扩展 Cache-Control 首部字段内的指令。

6.3 获取缓存

image

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() 获取候选缓存。

image

在 Cache.get() 方法中,首先会根据请求地址获取 key ,而缓存快照的 key 就是 URL 经过 md5 处理后的值,而缓存快照 Snapshot 就是 Cache 中的磁盘缓存 DiskLruCache 缓存的值,并且快照中有对应缓存文件的输入流。

当 get() 方法获取到快照后,就会用快照的输入流创建 Entry ,在 Entry 的构造方法中,会从输入流读取缓存的请求和响应的相关信息,读取完后就会完毕输入流。

image

创建完 Entry 后,Cache.get() 就会判断缓存中的请求地址和请求方法与当前请求是否匹配,匹配的话则返回响应,不匹配的话则关闭响应体并返回 null ,这里说的关闭响应体指的是关闭要用来写入响应体的文件输入流。

6.4 缓存策略 CacheStrategy

image

获取完候选缓存响应后,CacheInterceptor 就会用缓存策略工厂的 compute() 方法生产一个缓存策略 CacheStrategy ,CacheStrategy 中比较重要的方法就是用来判断是否对当前请求和响应进行缓存的 isCacheable() 。

6.4.1 可缓存响应的状态码

image

在 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

关于 3xx 重定向状态码在 3.3 小节已经讲过了,下面就只讲其他的状态码的含义。

1. 200 OK

表示从客户端发来的请求在服务器端被正常处理了。

在响应报文内,随状态码一起返回的信息会因为方法的不同而发生改变,比如用 GET 方法时,对应请求资源的实体会作为响应返回。

而使用 HEAD 方法时,对应请求资源的实体主体不随报文首部作为响应返回,也就是在响应中只返回首部,不会返回实体的主体部分。

2. 203 Not Authoritative Information

203 状态码为实体首部,包含的信息不是来自源服务器,而是来自资源的一份副本,如果中间节点上有一份资源副本,但无法或没有对它所发送的与资源有关的首部进行验证,就会返回 203 状态码。

3. 204 No Content

204 状态码代表服务器接收的请求已成功处理,但在返回的响应报文中不含实体的主体部分,而且也不允许返回任何实体的主体。

比如当从浏览器发出请求处理后返回 204 响应,那么浏览器显示的页面不发生变更。

204 状态码一般在只需要从客户端往服务器发送信息,而对客户端不需要发送新信息内容的情况下使用。

3. 404 Not Found

该状态码表明服务器上无法找到请求的资源,也可以在服务器拒绝请求且不想说明理由时使用。

4. 405 Method Not Allowed

405 状态码表示发起的请求中有所请求 URL 不支持的方法,使用此追昂泰马应在响应中包含 Allow 首部,以告知客户端对所请求的资源可以使用哪些方法。

5. 410 Gone

与 404 类似,不同的是还表示了服务器曾经拥有过该资源。

6. 414 Request-URI Too Large

414 状态码表示客户端发送的请求 URL 比服务器能够或希望处理的要大。

7. 501 Not Implemented

501 表示客户端发起的请求超出服务器的能力范围,比如使用了服务器不支持的请求方法时。

6.4.2 临时重定向状态码的缓存判断

image

当响应的状态码为 302 或 307 时,isCacheable() 方法就会根据响应的 Expires 首部和 Cache-Control 首部判断是否返回 false(不缓存)。

Expires 首部的作用是服务器端可以指定一个绝对的日期,如果已经过了这个日期,就说明文档不“新鲜”了。

6.5 获取响应

image

在 CacheInterceptor 调用 compute() 方法创建 CacheStrategy 时,如果 CacheControl 中有 onlyIfCached(不重新加载响应)指令,那么 CacheStrategy 的 cacheResponse 字段也为空。

当 CacheControl 中有 onlyIfCached 指令时,表明不再用其他拦截器获取响应,这时 CacheInterceptor 就会直接返回一个内容为空的响应。

当请求还是新鲜的(存在时间 age 小于新鲜时间 fresh ),那么 CacheStrategy 的 networkRequest 字段就为空,这时 CacheInterceptor 就会返回缓存中的响应。

当请求已经不新鲜时,CacheInterceptor 就会通过 ConnectInterceptor 和 CallServerInterceptor 获取响应。

6.6 保存响应

image

在获取到响应后,CacheInterceptor 会判断缓存响应的是否为空,如果不为空,并且状态码为 304(未修改)的话,则用新的响应替换 LruCache 中的缓存。

如果缓存响应为空,就把响应通过 Cache.put() 方法保存到磁盘中,保存后,如果请求方法为 PATCH、PUT、DELETE 会 MOVE 等修改资源的方法,那就把响应从缓存中删除。

7. OkHttp 连接建立机制

image

看完了缓存处理机制后,下面我们来看下 OkHttp 中负责建立连接的 ConnectInterceptor。

ConnectInterceptor 的 intercept() 方法没做什么事情,主要就是调用了 RealCall 的 initExchange() 方法建立连接。

image

在 RealCall 的 initExchange() 方法中,会用 ExchangeFinder.find() 查找可重用的连接或创建新连接,ExchangeFinder.find() 方法会返回一个 ExchangeCodec。

ExchangeCodec 是数据编译码器,负责编码 HTTP 请求进行以及解码 HTTP 响应,Codec 为 Coder-Decoder 的缩写。

RealCall 获取到 ExchangeCodec 后,就会用 ExchangeCodec 创建一个数据交换器 Exchange ,而下一个拦截器 CallServerInterceptor 就会用 Exchange 来写入请求报文和获取响应报文。

ExchangeFinder 的 find() 方法会辗转调用到它最核心的 findConnection() 方法,在看 findConnection() 方法的实现前,我们先来了解一些 HTTP 连接相关的知识。

7.1 HTTP 连接管理

HTTP 规范对 HTTP 报文解释得很清楚,但对 HTTP 连接介绍的并不多,HTTP 连接是 HTTP 报文传输的文件通道,为了更好地理解网络编程中可能遇到的问题,HTTP 应用程序的开发者需要理解 HTTP 连接的来龙去脉以及如何使用这些连接。

世界上几乎所有的 HTTP 通信都是由 TCP/IP 承载的,TCP/IP 是全球计算机及网络设备都在使用的一种常用的分组交换网络分层鞋以及。

客户端应用程序可以打开一条 TCP/IP 连接,连接到可能运行在世界任何地方的服务器应用程序,一旦连接建立起来了,在客户端与服务器的计算机之间交换的报文就永远不会丢失、受损或失序。

1. TCP 流是分段的、由 IP 分组传送
image

TCP 的数据是通过 IP 分组(或 IP 数据报)的小数据块来发送的,HTTP 是位于协议栈的最顶层,而 HTTPS 就是在 HTTP 与 TCP 之间插入一个(TLS/SSL)密码加密层。

HTTP 要传送一条报文时,会以流的形式把报文数据的内容通过一条打开的 TCP 连接按序传输,TCP 收到数据流后,会把数据流分割为段,并将段封装在 IP 分组中,通过因特网进行穿啊书,这些工作都是由 TCP/IP 软件处理的。

每个 TCP 段都是由 IP 分组承载的,从一个 IP 地址发送到另一个 IP 地址的,每个 IP 分组中都包括:

  • 一个 IP 分组首部(通常为 20 字节)
  • 一个 TCP 端首部(通常为 20 个字节)
  • 一个 TCP 数据块(0 个或多个字节)

IP 首部包含了源和目的 IP 地址、长度和其他一些标记,TCP 端的首部包含了 TCP 端口号、TCP 控制标记以及用于数据排序和完整性检查的一些数字值。

2. TCP/IP 的分层管理

TCP/IP 协议族里最重要的一点就是分层,TCP/IP 协议族按层次分别分为应用层、传输层、网络层和链路层。

分层的好处在于各层之间的接口部分规划好后,当某个地方需要改变设计时,每个层次内部的设计就能够自由改动了。

  1. 应用层

    应用层决定了向用户提供应用服务时通信的活动,TCP/IP 协议族内预存了各类通用的应用服务,比如 FTP 和 DNS 服务就是其中两类,而且 HTTP 协议也处于该层。

  1. 传输层

    传输层对应用层提供处于网络连接中的两台计算机之间的数据传输,在数据层有 TCP(传输控制协议,Transmission Control Protocol) 和 UDP(用户数据报协议,User Data Protocol) 两个性质不同的协议。

  2. 网络层

    网络层用于处理在网络上流动的数据包,数据包是网络传输的最小数据单位,网络层规定了通过怎样的路径(传输路线)到达对方计算机,并把数据包传送给对方。

  3. 链路层

    链路层又叫数据链路层,用于处理连接网络的硬件部分,包括控制操作系统、硬件的设备驱动、网络适配器(网卡,Network Interface Card),以及光纤等物理可见部分。

3. TCP/IP 通信传输流
通信传输流.gif

用 TCP/IP 协议族进行网络通信时,会通过分层顺序与对方进行通信,发送端从应用层往下走,接收端从链路层往上走。

以 HTTP 为例,首先作为发送端的客户端在应用层(HTTP 协议)发出一个想看某个 Web 页面的 HTTP 请求。

接着发送端在传输层把从应用层收到的 HTTP 报文进行分割,并在各个报文上打上标记序号及端口号转发给网络层,然后接收端的服务器在链路层接收到数据,按顺序往上层发送,一直到应用层。

也就是发送端在层与层之间传输数据时,每经过一层就会被打上该层所属的首部信息,接收端在层与层传输数据时,每经过一层就会把对应的首部消去,这种把数据信息包装起来的做法称为封装(encapsulate)。

4. TCP 套接字编程
image

操作系统提供了一些操作 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 端点进行连接,并对数据流进行读写。

7.2 释放连接

看完了 HTTP 连接的相关知识,下面我们来看下 ExchangeFinder 的 findConnection() 方法的实现。

findConnection() 方法大致做了 3 件事,首先是释放 RealCall 已有的连接,然后是尝试从连接池中获取已有的连接以进行复用,如果没有获取到连接时,则创建一个新连接并返回给 CallServerInterceptor 使用。

image

在 ExchangeFinder 的 findConnection() 方法中,首先会看下是否要释放当前 RealCall 的连接。

ExchangeFInder 会判断 RealCall 的 connection 字段是否为空,如果不为空,表示该请求已经被调用过并且成功建立了连接。

这时 ExchangeFinder 就会判断 RealCall 的 connection 的 noNewExchanges 是否为 true,这个值表示不能创建新的数据交换器,默认为 false。

当请求或响应有 Connection 首部,并且 Connection 首部的值为 close 时,那么 Connection 的 noNewExchanges 的值就会被改为 true ,因为 Connection:close 表示不重用连接,如果你忘了 Connection 首部的作用,可以回到第 4 大节首部拦截器看一下。

image

当连接的 noNewExchanges 的值为 true 时,或当前请求地址的主机和端口号和与有连接中的主机和端口号不相同时,ExchangeFinder 就会调用 RealCall 的 releaseConnectionNoevents() 方法尝试释放连接,如果如果连接未释放,则返回该连接,否则关闭连接对应的 Socket。

RealCall 的 connection 的类型为 RealConnection,RealConnection 中维护了一个 Call 列表,每当有一个 RealCall 复用该连接时,RealConnection 就会把它添加到这个列表中。

而释放连接的操作,其实就是看下 RealConnection 的 Call 列表中有没有当前 RealCall ,有的话就把当前 RealCall 从列表中移除,这时就表示连接已释放,如果连接的 Call 列表中没有当前 Call 的话,则返回当前 Call 的连接给 CallServerInterceptor 用。

7.3 从连接池获取连接

image

当 RealCall 的连接释放后 ExchangeFinder 就会尝试从连接池 RealConnectionPool 获取连接,RealConnectionPool 中比较重要的两个成员是 keepAliveDuration 和 connection。

keepAliveDuration 是持久连接时间,默认为 5 分钟,也就是一条连接默认最多只能存活 5 分钟,而 connections 是连接队列,类型为 ConcurrentLinkedQueue 。

每次建立一条连接时,连接池就会启动一个清理连接任务,清理任务会交给 TaskRunner 运行,在 DiskLruCache 中,也会用 TaskRunner 来清理缓存。

image

当第一次从连接池获取不到连接时,ExchangeFinder 会尝试用路线选择器 RouteSelector 来选出其他可用路线,然后把这些路线(routes)传给连接池,再次尝试获取连接,获取到则返回连接。

7.4 创建新连接

image

当两次从尝试从连接池连接都获取不到时,ExchangeFinder 就会创建一个新的连接 RealConnection,然后调用它的 connect() 方法,并返回该连接。

7.5 连接 Socket

image

在 RealConnection 的 connect() 方法中,

RealConnection 的 connect() 方法首先会判断当前连接是否已连接,也就是 connect() 方法被调用过没有,如果被调用过的话,则抛出非法状态异常。

如果没有连接过的话,则判断请求用的是不是 HTTPS 方案,是的话则连接隧道,不是的话则调用 connectSocket() 方法连接 Socket。

关于连接隧道在后面讲 HTTPS 的时候会讲到,下面先来看下 connectSocket() 方法的实现。

image

在 RealConnection 的 connectSocket() 方法中,首先会判断代理方式,如果代理方式为无代理(DIRECT)或 HTTP 代理,则使用 Socket 工厂创建 Socket,否则使用 Socket(proxy) 创建 Socket。

创建完 Socket 后,RealConnection 就会调用 Platform 的 connectSocket() 方法连接 Socket ,再初始化用来与服务器交换数据的 Source 和 Sink。

Platform 的 connectSocket() 方法调用了 Socket 的 connect() 方法,后面就是 Socket API 的活了。

7.6 建立协议

image

创建完 Socket 后,RealConnection 的 connect() 方法就会调用 establishProtocol() 方法建立协议。

image

在 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 请求。

关于 HTTP2 的实现后面会讲。

8. HTTPS 连接建立机制

在看 connectTls() 方法的实现前,我们先来看一些 HTTPS 相关的基础知识,如果你已经了解的话,可以跳过这一段直接从 8.2 小节看起。

8.1 HTTPS 基础知识

明文传输.png

在 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 TLS/SSL 历史

我们可以把 TLS(传输层安全协议,Transport Layer Security)协议和 SSL(安全套接字协议,Secure Sockets Layer)协议看成是一回事,TLS 协议是 SSL 协议的升级版,接下来会用 TLS/SSl 协议表示 TLS 协议或 SSL 协议。

网景浏览器可以说是最早的浏览器,极大地推动了 HTTP 的发展,为了解决 HTTP 的安全问题,网景公司在 1994 年创建了 SSL 协议作为 浏览器的一个扩展,主要应用于 HTTP 。

后来网景意识到互联网还有其他的应用协议页面领同样的安全问题,比如 SMTP 和 FTP 协议,于是就想能不能出一个统一的方案,解决互联网通信的安全问题。

基于此考虑,SSL 协议逐渐成为一个独立的协议,该协议能保证网络通信的认证与安全问题,SSL 有三个版本:SSL v1、SSL v2、SSL v3 。

1196 年,IETF 组织在 SSL v3 的基础上进一步标准化了该协议,微软为这个新协议取名 TLS v1.0 ,目前比较稳定的版本是 TLS v1.2 。

7.1.3 握手层与加密层

握手层与加密层.png

HTTPS(TLS/SSL协议)设计得很巧妙,主要由握手层和加密层两层组成,握手层在加密层的上层,提供加密所需要的信息(密钥块)。

对于一个 HTTPS 请求来说,HTTP 消息在没有完成握手前,是不会传递给加密层的,一旦握手层处理完毕,最终应用层所有的 HTTP 消息都会交给密钥层进行加密。

1. 握手层

客户端与服务器端交换一些信息,比如协议版本号、随机数、密码套件(密码学算法组合)等,经过协商,服务器确定本次连接使用的密码套件,该密码套件必须双方都认可。

客户端通过服务器发送的证书确认身份后,双方开始密钥协商,最终双方协商出预备主密钥、主密钥、密钥块,有了密钥块,代表后续的应用层数据可以进行机密性和完整性保护了,接下来由加密层处理。

2. 加密层

加密层有了握手层提供的密钥块,就可以进行机密性和完整性保护了,加密层相对来说逻辑比较简单明了,而握手层在完成握手前,客户端和服务器需要经过多个来回才能握手完成,这也是 TLS/SSL 协议缓慢的原因。

下面分别是使用 RSA 密码套件和 DHE_RSA 密码套件的 TLS 协议流程图。

使用 RSA 密码套件的 TLS 协议流程图.png
使用 DHE_RSA 密码套件的 TLS 协议流程图.png

7.1.4 握手

握手指的是客户端和服务器端互相传数据前要互相协商,达成一致后才能进行数据的加密完整性处理认证密码套件协商握手的关键步骤和概念。

1. 认证

客户端在进行密钥交换前,必须认证服务器的身份,否则就会存在中间人攻击,而服务器实体并不能自己证明自己,所以要通过 CA 机构来认证。

认证的技术解决方案就是签名的数字证书,证书中会说明 CA 机构采用的数字签名算法,客户端获取到证书后,会采用相应的签名算法进行验证,一旦验证通过,则表示客户端成功认证了服务器端的身份。

2. 密码套件协商

密码套件是 TLS/SSL 中最重要的一个概念,理解了密码套件就相当于理解了 TLS/SSL 协议,客户端与服务器端需要协商出双方都认可的密码套件,密码套件决定了本次连接客户端和服务器端采用的加密算法、HMAC 算法、密钥协商算法等各类算法。

密码套件协商的过程类似于客户采购物品的过程,客户(客户端)在向商家(服务器)买东西前要告诉商家自己的需求、预算,商家了解了客户的需求后,根据客户的具体情况给用户推荐商品,只有双方都满意时,交易才能完成。

而对于 TLS/SSl 协议来说,只有协商出密码套件,才能进行下一步的工作。

HTTP 是没有握手过程的,完成一次 HTTP 交互,客户端和服务器端只要一次请求/响应就能完成。

而一次 HTTP 交互,客户端和服务器端要进行多次交互才能完成,交互的过程就是协商,泌乳客户端告诉服务器端其支持的密码套件,服务器端从中选择一个双方都支持的密码套件。

密码套件的构成如下图所示。

密码套件结构.png

密码套件是一系列密码学算法的组合,主要包含多个密码学算法:

  • 身份验证算法

  • 密码协商算法

  • 加密算法或加密模式

  • HMAC 算法的加密基元

  • PRF 算法的加密基元

    不同的 TLS/SSL 协议版本、密码套件,PRF 算法最终使用的加密基元和 HMAC 算法使用的加密基元是不一样的;

7.1.5 加密

与握手层相比,加密层的处理相对简单,握手层协商出加密层需要的算法和算法对应的密钥块,加密层接下进行加密运算和完整性保护。

在 TLS/SSL 协议中,主要有流密码加密模式、分组加密模式、AEAD 模式三种常见的加密模式。

7.1.6 X.509 标准和 PKI

PKI(公钥基础设施,Public Key Infrastructure)和证书虽然不是 TLS/SSL 的一部分,但是是 HTTPS 非常关键的一环,网站引入证书才能避免中间人攻击。

PKI 是一个集合体,由一系列的软件、硬件、组织、个体、法律和流程组成,主要目的就是向客户端提供服务器身份验证,服务器也可以认证客户端的身份。

认证的基础就是必须找到一个可信的第三方组织,认证的技术方案就是数字签名技术,第三方组织能够使用数字签名技术管理证书,比如创建证书、存储证书、更新证书和撤销证书等。

1. X.509 标准

为了规范化运用 PKI 技术,出现了很多标准,HTTPS 中最常用的标准就是 X.509 标准,证书是 PKI 最核心、最重要的内容,提到证书时,也可以认为是 X.509 标准证书。

X.509 标准来自国际电信联盟电信标准(ITU-T)的 X.500 标准,1995 年 IETF 的 PKIX 小组成立,用于建设互联网的 PKI 标准,建立的标准就是 X.509 ,该标准也可以叫作 IETF 的 PKIX X.509 标准。

X.509 标准有三个版本,目前最通用的就是 X.509 V3 版本,V3 引入了扩展的概念,让 X.509 标准更规范、更利于扩展。

证书是 PKI 的核心,而扩展时证书的核心,证书验证时必须严格处理证书扩展。

2. PKI 的组成
image

服务器实体(end entity)就是需要申请证书的实体,比如 www.example.com 域名的拥有者可以申请一张证书,证书能够证明 www.example.com 域名所有者的身份。

CA 机构是证书签发机构,在审核服务器实体的有效身份后,给服务器实体签发证书,证书是用 CA 机构的密钥对(比如 RSA 密钥对)对服务器实体证书进行签名。

RA 机构是注册机构,主要审核服务器实体的身份,一般情况下可以认为 CA 机构包含了 RA 机构。

证书仓库,CA 机构签发的证书全部保存在仓库中,证书可能过期或被吊销,CA 机构吊销的证书称为证书吊销列表 CRI 。

证书校验方,校验证书真实性的软件,在 Web 领域,我们最熟悉的证书校验方就是客户端,比如浏览器或 APP 。

7.1.8 TLS/SSL 握手协议

TLS 记录协议中加密参数(Security Paramters)的值都是 TLS/SSL 握手协议填充完成的,对应的值是由客户端和服务器端共同协商完成的,独一无二。

对于一个完整握手会话,客户端和服务器端要经过几个来回才能协商出加密参数。

与加密参数关联最大的就是密码套件,客户端与服务器端会列举出支持的密码套件,然后选择一个双方都支持的密码套件,基于密码套件协商出所有的加密参数,加密参数中最重要的是主密钥(master secret)。

在讲解流程前,有几点要说明。

握手协议由很多子消息构成,对于完整握手来说,客户端与服务器端一般要经过两个来回才会完成握手。

ChangeCipherSpec 不是握手协议的一部分,在理解时可以认为 ChangeCipherSpec 是握手协议的一个子消息。

星号标记( * )表示对应的子消息是否发送,取决于不同的密码套件,比如 RSA 密码套件不会出现 ServerKeyExchange 子消息。

在 HTTPS 中,服务器和客户端都可以提供证书让对方进行身份校验

下面是完整的 TLS/SSL 握手协议交互流程。

完整握手协议交互流程.png

握手协议的主要步骤如下:

互相交互 hello 子消息,该消息交换随机值和支持的密码套件列表,协商出密码套件以及对应的算法,检查会话是否可恢复;

交换证书和密码学信息,允许服务器端与客户端相互校验身份;

交互必要的密码学参数,客户端与服务器端获得一致的预备主密钥;

通过预备主密钥和服务器/客户端的随机值生成主密钥

握手协议提供加密参数(主要是密码块)给 TLS 记录层协议;

客户端与服务器端校验对方的 Finished 子消息,以避免握手协议的消息被篡改;

7.1.9 扩展

通过扩展,客户端与服务器端可以在不更新 TLS/SSL 协议的基础上获取更多的能力。

在 RFC 5246 文档中,只对扩展定义了一些概念框架和设计规范,具体扩展的详细定义由 RFC 6066 制定,每个扩展由 IANA 统一注册和管理。

扩展的工作方式如下:

  • 客户端根据自己的需求发送多个扩展给服务器,扩展列表消息包含在 Client Hello 消息中;
  • 服务器解析 Client Hello 消息中的扩展,根据 RFC 的定义逐一解析,并在 Server Hello 消息中返回相同类型的扩展;

7.1.10 基于 Session Ticket 的会话恢复

SessionTicket 解决了 Session ID 会话恢复存在的缺点,是一种更好地会话恢复方式。SessionTicket 的处理标准定义在 RFC 5077 中,在 TLS/SSL 协议中,SessionTicket 以 TLS 扩展的方式完成会话恢复,SessionTicket 扩展的实现定义在 RFC 4507 上。

如果遇到以下问题,那就特别适合用 SessionTicket。

Session ID 会话信息存储在服务器端,对于大型 HTTPS 网站来说,占用的内存量非常大,是非常大的开销。

HTTPS 网站提供者希望会话信息的生命周期更长一些,尽量使用简短的握手。

HTTPS 网站提供者希望会话信息能够跨主机访问,Session ID 会话恢复显然不能。

嵌入式的服务器没有太多的内存存储会话信息

2.12.1 SessionTicket 的交互流程

SessionTicket 从应用的角度来看,原理很简单,服务器将会话信息加密后,以票据(ticket)的方式发送给客户端,服务器本身不存储会话信息

客户端受到票据后,将其存储到内存中,如果想恢复会话,则下一次连接时把票据发送给服务器端,服务器端解密后,如果确认无误,则表示可以进行会话恢复,这就完成了一次简短的握手。

SessionTicket 相对于 Session ID 来说,有两点变化:

  • 会话信息由客户端保存

  • 会话信息需要由服务器端解密

    客户端不参与解密过程,只负责存储和传输;

SessionTicket 在具体实现时有很多种情况,下面一一说明。

1. 基于 SessionTIcket 进行完整的握手
基于 SessionTicket 进行完整的握手.png

对于一次新连接,如果期望服务器支持 SessionTicket 会话恢复,则在客户端Client Hello 消息中包含一个空的 SessionTicket TLS 扩展

如果服务器支持 SessionTicket 会话恢复,那么服务器的 Server Hello 消息中也要包含一个空的 SessionTicket TLS 扩展

服务器端对会话信息进行加密保护,生成一个票据,然后在 NewSessionTicket 子消息中发送该票据,NewSessionTicket 子消息是握手协议的一个独立子消息。由于是完整的握手,其他的一些子消息也会正常处理。

客户端收到 NewSessionTicket 子消息后,把票据存储起来,以便下次使用。

2. 基于 SessionTicket 进行简短的握手
image

基于 SessionTicket 进行会话恢复的流程如下。

  1. 客户端存储一个票据,如果恢复会话,则在客户端的 Client Hello 消息中包含一个非空的 SessionTicket TLS 扩展;
  2. 服务器端接收到非空票据后,对票据进行解密校验,如果可以恢复,则在服务器 Server Hello 消息中发送一个空的 SessionTicket TLS 扩展;
  3. 由于是简短握手,所以 Certificate 和 ServerKeyChange 等子消息不发送,接下来发送一个 NewSessionTicket 子消息更新票据,票据也是有有效期的;
  4. 客户端与服务器端接着校验 Finished 子消息表示简单握手完成,顺利完成会话恢复;

8.2 连接隧道

1. 隧道
image

隧道(tunnel)是建立起来后,就会在两条连接之间对原始数据进行盲转发的 HTTP 应用程序,HTTP 隧道通常用来在一条或多条 HTTP 连接上转发非 HTTP 数据,转发时不会窥探数据。

HTTP 隧道的一种常见用途是通过 HTTP 连接承载 SSL(加密的安全套接字层,Secure Sockets Layer)流量,这样 SSL 流量就可以穿过只允许 Web 流量通过的防火墙了。

HTTP/SSL 隧道收到一条 HTTP 请求,要求建立一条到目的地之和端口的输出连接,然后在 HTTP 信道上通过隧道传输加密的 SSL 流量,这样就可以将其盲转发到目的服务器上去了。

2. connectTunnel()
image

RealConnection 的 connect() 方法首先会判断当前连接是否已连接,也就是 connect() 方法被调用过没有,如果被调用过的话,则抛出非法状态异常。

如果没有连接过的话,则判断 URL 是否用的 HTTPS 方案,是的话则连接隧道。

``

image

而 RealConnection 调用 connectTunnel() 方法后,connectTunnel() 会调用 connectSocket() 和 createTunnel() 方法创建Socket 和隧道。

8.3 创建隧道

image

在 RealConnection 的 createTunnel() 方法中,首先会创建 Http1ExchangeCodec ,然后用它来写入请求行,写完了就再刷新缓冲区,然后读取响应报文首部。

如果状态码为 200 ,表示服务器要等待客户端发送 ClientHello 消息后才会发送 ServerHello 消息。

如果状态码为 407 ,表示需要进行代理认证,这时就会使用 Authenticator 的 authenticate() 方法创建并返回一个认证请求。

8.4 获取连接规格

image

在 6.5 小节提到了 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 。

8.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 扩展
image

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 证书锁定

image

在调用了 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()

9. HTTP/2 连接建立机制

9.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 消息类似,二进制帧通常由下层客户端或类库处理。

2. 多路复用

HTTP/1 是一种同步的、独占的请求—响应协议,客户端发送 HTTP/1 消息,然后服务器返回 HTTP/1 响应,为了能更快地收发更多数据,HTTP/1 的解决办法就是打开多个连接,并且使用资源合并,以减少请求数,但是这种解决办法会引入其他问题和带来性能开销。

![]https://s3.ax1x.com/2020/11/30/D2lJL6.png)

上图显示了如何使用三个 TCP 连接并行发送和接收三个 HTTP/1 请求,初始页面的请求结果未显示,因为在初始请求后,才需要第 2~4 个请求中并行请求多个资源。

image

而 HTTP/2 允许在单个连接上同时执行多个请求,每个 HTTP 请求或响应使用不同的流,通过使用二进制分帧层(Binary Framing Layer)给每个镇分配一个流标识符,以支持同时发出多个独立请求,当接收到该流的所有帧时,接收方可以把帧组合成完整消息。

帧是同时发送多个消息的关键,每个镇都有标签表明它属于哪个消息(流),这样在一个连接上就可以同时有两个、三个甚至上百个消息。

从严格意义上来说,HTTP/2 请求并不是同时发出去的,因为帧在 HTTP/TCP 连接上也需要依次发送,HTTP/1.1 本质上也是这样的,因为虽然看起来有多个连接,但是在网络层上通常只有一个连接,所以最终会从网络层排队发送每个请求。

HTTP/1 与 HTTP/2 同时发送多个请求的重要区别就是 HTTP/2 连接在请求发出后不需要阻塞到响应返回。

HTTP/2 使用多个二进制帧发送 HTTP 请求和响应,使用单个 TCP 连接,以流的方式多路复用

HTTP2 与 HTTP/1 的不同主要在消息发送的层面上,在更上层,HTTP 的核心概念不变,比如请求包含一个方法(如 GET)、想要获取的资源(如 Img.png)、首部、正文、状态码、缓存和 Cookie 等,这些与 HTTP/1 保持一致。

3. HTTP/2 中的流
image

HTTP2 中每个流的作用类似于 HTTP/1 中的连接,但是 HTTP/2 中没有重用流,而且流不是完全独立的。

流在传输完资源后会被关闭,当请求信资源时,会弃用一个新的流,流是一个虚拟的概念,它是在每个帧上标示的一个数字,也就是流 iD 。

关闭或创建一个流的开销远小于创建 HTTP/1.1 连接,HTTP/1.1 连接包含三次握手,可能在发送请求前还有 HTTPS 协议协商的开销。

HTTP/2 连接比 HTTP/1 连接开销更高,因为它额外添加了前奏消息和 SETTINGS 帧,但是 HTTP/2 的流开销很低。

4. HTTP/2 前奏消息

不管使用哪种方法启用 HTTP/2 连接,在 HTTP/2 连接上发送的第一个消息必须是 HTTP/2 连接前奏,该消息是客户端在 HTTP/2 连接上发送的第一个消息,是一个 24 个 8 位字节的序列,这个序列被转换为 ASCII 字符串后如下所示:

PRI * HTTP/2.0

SM

该消息说明,HTTP 请求方法是 PRI,而不是 GET 或 POST ,请求的资源是 * ,所用的 HTTP 版本是 HTTP/2.0 ,然后是请求体 SM 。

前奏消息的作用是检测兼容性,让客户端提前发现服务器端支不支持 HTTP/2 ,如果服务器器不支持 HTTP/2 ,则无法进行解析,这时会拒绝此消息。

如果服务器端支持 HTTP/2 ,则可以根据前奏消息推断出客户端支持 HTTP/2 ,这时客户端必须发送 SETTINGS 帧作为第一条消息(可以为空)。

5. SETTINGS 帧

SETTINGS 帧是服务器和客户端必须发送的第一个帧(在前奏消息后),该帧不包含数据或只包含如下若干键值对。

  • SETTINGS_HEADER_TABLE_SIZE

    用于 HPACK HTTP 首部压缩;

  • SETTINGS_ENABLE_PUSH

    用于禁用服务器推送,初始值为 1,表示允许服务器推送;

  • SETTINGS_MAX_CONCURRENT_STREAMS

    在连接上同时进行的流数量不得超过这个值;

  • SETTINGS_INITIAL_WINDOW_SIZE

    用于流量控制;

  • SETTINGS_MAX_FRAME_SIZE

    客户端不能发送超出这个尺寸的帧;

  • SETTINGS_MAX_HEADER_LIST_SIZE

    不能发送超过这个尺寸的未压缩的头部;

  • SETTINGS_ACCEPT_CACHE_DIGEST

  • SETTINGS_ENABLE_CONNECT_PROTOCOl

6. HTTP/2 帧组成结构

每个 HTTP/2 帧由一个固定长度的头部和不定长度的负载组成,包含下面 4 个字段:

  • 帧长度 Length

  • 帧类型 Type

  • 标志位 Flags

  • 保留位 Reserved Bit

  • 流标识符 Stream Identifier

    用于标记帧所属的流 ID ;

帧类型有下面 13 个:

  • DATA
  • HEADERS
  • PRIORITY
  • RST_STREAM
  • SETTINGS
  • PUSH_PROMISE
  • PING
  • GOAWAY
  • WINDOW_UPDATE
  • CONTINUATION
  • ALTSVC
  • ORIGIN
  • CACHE_DIGEST

9.2 Http2Connection.start()

image

在 6.6 小节的时候,讲到了在 establishProtocol() 方法中会判断是否使用 HTTPS,如果使用 HTTPS,则调用 connectTls() 方法连接 TLS,也就是第 7 大节讲的内容。

连接 TLS 后,establishProtocol() 方法就会判断是否使用 HTTP/2.0 ,是的话则调用 startHttp2() 方法发送初始帧,并且开始从对端(服务器端)读取帧。

在 startHttp2() 方法中,首先会创建一个 Http2Connection,然后调用 Http2Connection.start() 方法。

RealConnection.connect__1.png

在 Http2Connection 的 start() 方法中,首先会用 Http2Writer 的 connectionPreface() 方法发送前奏消息,这时 Http2Writer 就会用把下面这个常量发送到服务器端。

object Http2 {
  
  // 前奏消息
  val CONNECTION_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".encodeUtf8()
  
  // ...
}

发送了前奏消息后,start() 方法就会发送 SETTINGS 帧。

RealConnection.connect__2.png

发送完前奏消息和 SETTINGS 帧后,start() 方法就会判断初始化窗口大小(initialWindowSize)的值是否为默认值,如果不是的话,则把这个值与默认值的差发送给服务器端。

RealConnection.connect__.png

发送完初始窗口大小的变化给服务器端后,start() 方法就会用 TaskRunner 创建一个新的 TaskQueue ,然后调用 TaskQueue 的 execute() 方法把 ReaderRunnable 放到队列中执行。

ReaderRunnable 实现了 Http2Reader.Handler 和 Kotlin 提供的 Function 0 接口,Function0 也就是高阶函数 () -> Unit

TaskQueue 的 execute() 方法中会创建一个 Task,并在 runOnce() 方法中执行 ReaderRunnable 的 invoke() 方法。

在 ReaderRunnavle 的 invoke(() 方法中,调用了 Http2Reader 的 nextFrame() 方法,在 Http2Reader 中,会根据不同的帧类型调用不同的方法发送对应类型的数据,比如当帧类型为 DATA 时,就会调用 ReaderRunnable 的 data() 方法获取发送数据。

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容