HTTP与okhttp: 应对TCP拆包、粘包(二)

510 阅读10分钟

此文承接HTTP与okhttp:应对TCP拆包、粘包(一)

HTTP/1.1应对方案

参考文档:RFC 7230: Message Syntax and Routing

在HTTP/1.0的基础上,HTTP/1.1又做了一些演化。

CONNECT请求的2xx response不许带body

HTTP/1.1新引入了一种method: CONNECT,用于建立HTTP隧道代理(HTTP tunnel)。

可能有用小知识:
HTTP隧道代理将client和自己之间的TCP连接上的字节流,不做任何修改地转发给自己和server之间的TCP连接;以及同样地,将自己和server之间的连接上的字节流,转发给client和自己之间的连接。

代理.drawio.png

隧道代理一旦建立,就相当于client和server之间建立了TCP连接,只是client的真实ip和端口被代理隐藏了。client和server之后进行通信,可以使用任何基于TCP的协议,比如SSH、FTP、TLS,以及我们正在讨论的HTTP。

如何建立HTTP隧道代理呢?[参见:RFC7231 section-4.3.6]

  1. client发送HTTP CONNECT request给代理,request示例: 截屏2024-05-28 22.40.41.png
  2. 代理收到该request后,解析出client的目标host以及目标port(在本例中,目标host是server.example.com,目标port是80),然后建立自己和目标host:目标port之间的连接。连接建立成功后,向client回复状态码为2XX的response,提示其隧道代理建立成功;
  3. 之后client和server就可以像代理并不存在一样进行通信了。
    整个过程如下图所示(图改自《HTTP权威指南》): 隧道.drawio.png

除HTTP/1.0规定的那些不许带body的response外,HTTP/1.1规定CONNECT的状态码为2XX的response,也一定没有body。

让我们看看okhttp是如何implement这一点的。

首先,okhttp仅在如下情况下,会先建立HTTP隧道,然后再进行用户想要的网络请求(代码参见Route#requiresTunnel()):首先,该请求在client和server之间的传输会经过HTTP代理,且

  1. 该网络请求的目标资源的scheme为https,即client与server之间需进行由TLS保障的加密通信。这种情况下,通过建立HTTP隧道,在隧道桥接下完成client和server的TLS handshake,最后再进行数据传输,就可以保障client到代理、代理到server之间的数据都是加密传输的。否则的话,client到HTTP代理之间的数据就是明文(cleartext)传输的了(因为client和HTTP代理之间不进行TLS handshake);

    还有一种方案可以让client到代理、代理到server之间的数据均加密传输,即client和代理之间进行TLS handshake、建立TLS连接,此时代理不再起一个单纯桥接两个TCP连接的作用,而是执行一个普通代理的职能:解析client发来的请求,代替client向server发起请求,并将server的响应转发给client。关于这种方案可行性,在HTTPs proxy server only works in SwitchOmega 这个stackoverflow里有相关讨论。这个讨论里提到的参考的非英语的文章是这篇:HTTP代理原理及实现(一)(发现这篇文章关于代理讲得很好,其实大家就看这篇就行了,似乎完全不用看我写的。。。)无论如何,目前okhttp是不支持设置这种HTTPs代理的。

  2. 或者,该网络请求将按照HTTP/2 cleartext协议(明文HTTP/2)进行传输。这种情况说明用户知道目标资源所在server支持HTTP/2 cleartext,因此可以放心按照HTTP/2传输请求数据给server,并基于HTTP/2解析server发来的数据。但是HTTP代理是否支持明文HTTP/2,我们却并不知道,很大可能是并不支持的。所以我们选择通过一个HTTP/1.1的CONNECT请求创建隧道代理,之后就可以如HTTP代理不存在一样,使用明文HTTP/2与server进行通信了。

难道每个请求要用啥HTTP协议版本,都要用户提前知道server会使用啥协议版本处理数据吗?并不是如此,这涉及到ALPN。之后应该会有一篇文章分享okhttp是如何为请求选择适合的HTTP协议版本的。

确定需要先通过CONNECT请求建立隧道后,okhttp会发起CONNECT请求,请求中不带body。对于response,okhttp并不根据其状态码来决定如何处理它的body,仅根据response中Content-Length的值,确定body长度,从TCP连接中读完body。

// ConnectPlan.kt
private fun createTunnel(): Request? {
  ...
    val response =
      tunnelCodec.readResponseHeaders(false)!!
        .request(nextRequest)
        .build()
    // 根据response里Content-Length,读完response剩余的body部分
    tunnelCodec.skipConnectBody(response)

  ...
}

tunnelCodec.skipConnectBody(response: Response):

// Http1ExchangeCodec.kt
 * The response body from a CONNECT should be empty, but if it is not then we should consume it
 * before proceeding.
 */
fun skipConnectBody(response: Response) {
  val contentLength = response.headersContentLength()
  if (contentLength == -1L) return
  val body = newFixedLengthSource(contentLength)
  body.skipAll(Int.MAX_VALUE, MILLISECONDS)
  body.close()
}

这种处理,在HTTP代理发送的是非2XX的response,且有body,同时又选择使用chunked(关于chunked,在Transfer-Encoding章节进行详细介绍)来进行body传输时,会因为reponse没有Content-Length字段,而误判其不带body,使body残存在TCP连接里。 不过我们不必担心这种情形,因为okhttp只会在response状态码是200/407(Proxy Authentication Required)的时候保留TCP连接,用于后续的网络请求/带上Proxy-Authorization头部重试CONNECT请求。而这两种response,都不带body。其余场景下,连接都会被关闭,即使有body残存,也无所谓了。

// ConnectPlan.kt
private fun createTunnel(): Request? {
 ...
 while(true) {
    ...
    when (response.code) {
      // 200,隧道代理成功建立   
      HttpURLConnection.HTTP_OK -> return null
      // 407,如果可以得到Proxy-Authoirzation、生成nextRequest(里面主要有一些CONNECT请求要用的header信息,
      // 就在当前TCP连接上重试其对应的CONNECT请求
      //(但如果发现代理在response中使用Connection header指明了将要关闭连接,就直接返回nextRequest,
      // 后面会在新的TCP连接上进行其对应的CONNECT请求),否则抛出异常
      HttpURLConnection.HTTP_PROXY_AUTH -> {
        nextRequest = route.address.proxyAuthenticator.authenticate(route, response)
          ?: throw IOException("Failed to authenticate with proxy")
        
        if ("close".equals(response.header("Connection"), ignoreCase = true)) {
          return nextRequest
        }
      }
      // 其余状态码,抛出异常
      else -> throw IOException("Unexpected response code for CONNECT: ${response.code}")
    }
  }
  // 方法调用方在捕获到异常后,会关闭当前TCP连接
}

状态码1XX被启用

在HTTP/1.0中,就已经提到状态码为1XX的response禁止带body,但是当时并没有定义这类状态码,只是起到占位作用。
在HTTP/1.1中,正式定义了100和101两种状态码。

状态码100与Expect: 100-continue - 减少不必要body传输

HTTP/1.1引入了状态码100(Continue),server用该状态码表明自己根据已经收到的request信息(比如一些request header)确认自己可以接收全部的request并进行处理。client在收到这种response后,就可以继续发送完剩下的request,并等待server发来对request的最终response。
该状态码是与request中的header - Expect结合使用的,client可以在Expect中列出想要提前告知server的信息,供server判断自己是否可以处理整个request。
HTTP/1.1仅定义了一种header值:100-continue(其实到目前为止,也仅这一种)。其含义是告知server,该request会有一个body(一般来说,这个body比较大),如果server:

  1. 根据当前收到的请求行和header信息,已经可以给出最终的代表成功的2XX response,那可以直接发送该2XX response;
  2. 根据当前收到的请求行和header信息,确认该次request自己无法正确处理,已经可以给出3XX,4XX,5XX的response(比如根据header中的content-length,发现body太大,自己不愿接收,就可以直接给一个4XX的response),那可以直接发送该response;
  3. 否则的话,发送状态码为100的临时响应(interim response)。client在收到这个response后可以继续发送当前request剩下的body。

100continue.jpg

这种机制,可以减少不必要的对body的传输。在这套机制下,client应该完成的工作有:

  1. 先发送request的headers;
  2. 读取response的headers:
    1)如果发现状态码为100,则继续发送request的body。发送完后,由于根据HTTP/1.1规定,状态码为1XX的response禁止携带body,因此不用担心之前状态码为100的response还有body残存在连接中,可以直接开始从连接中读取server发来的对request的最终response;
    2)如果发现状态码为其它(2XX,3XX,4XX,5XX),则不用再发送request的body,当前收到的response就是最终response,直接处理当前response即可。

okhttp implement了这套机制,它支持用户在request的header中添加Expect:100-continue,以保证在必要条件下才发送request的body。okhttp已经做好了对1XX的response的处理,用户侧得到的response就是最终的response。

// CallServerInterceptor.kt
override fun intercept(chain: Interceptor.Chain): Response {
     ....
  try {
    //发送request的headers  
    exchange.writeRequestHeaders(request)

    if (HttpMethod.permitsRequestBody(request.method) && requestBody != null) {
      // 用户想使用100-continue机制,在header中带了"Expect: 100-continue",则开始读response
      if ("100-continue".equals(request.header("Expect"), ignoreCase = true)) {
        exchange.flushRequest()
        // 通过response,判断是否可以继续发request body。若server回复了状态码100,即需继续发送request body的情况,此处会返回一个null;否则,即server回复的状态码为非100的其它1XX,2XX,3XX,4XX,或5XX,此处会返回一个已经填充好了headers的responseBuilder
        responseBuilder = exchange.readResponseHeaders(expectContinue = true)
        ....    
      }
      
      if (responseBuilder == null) {
        // header里不带Expect:100-continue,
        // 或者header中有Expect: 100-continue, 且server回复了100,请client继续发送request body,
        // 则要继续发送request body
        if (requestBody.isDuplex()) {
            ...
        } else {
          ...
        }
      } else {
            // 否则,说明对于当前的Expect: 100-continue request,server不需要request继续传输body过去
        exchange.noRequestBody()
        if (!exchange.connection.isMultiplexed) {
        // 由于这个TCP连接上有本应发而未发的request body,
        // 之后再复用这个连接,有状态管理混乱的风险,
        // 因此干脆指定该连接不能再被复用。
        // 等到当前response被读取完,连接会进入idle态,达到用户设定的连接复用的keep-alive时间后,连接就会被连接池的cleanup task回收关闭
          exchange.noNewExchangesOnConnection()
        }
      }
    } else {
      exchange.noRequestBody()
    }
...
  } catch (e: IOException) {
    ...
  }

  try {
    if (responseBuilder == null) {
      // 对于无request body的,
      // 或者有request body但没有Expect: 100-continue的,
      // 或者有Expect: 100-continue,同时又收到了状态码100的response
      // 此时已经发送完了request,开始读取"最终"response
      // 最终加引号是因为此时只能保证不会收到针对Expect: 100-continue的临时响应,但这个response仍有状态码为1XX、是一个临时响应的可能性。
      responseBuilder = exchange.readResponseHeaders(expectContinue = false)!!
        ...
    }
  ...
    // 根据读到的“最终”response的状态行和headers,判断它是否是1XX临时响应
    if (shouldIgnoreAndWaitForRealResponse(code, exchange)) {
      // 若当前response是1XX临时响应,则下一个response才是真正的最终响应。
      // 根据HTTP规范,1XX的response肯定没有body,所以不用读取response body,可以直接开始读取下一个response的header
      responseBuilder = exchange.readResponseHeaders(expectContinue = false)!!
        ...
    }
    // 现在response终于是正经最终response了
    exchange.responseHeadersEnd(response)
    ...
}

状态码101与websocket

Transfer-Encoding

stay tuned 写文速度真的太慢了,写半天,完全没进入HTTP/1.1关于message定界的重点。 6月11日补充了100-continue的部分,希望6月14日可以写完全部。