Android 网络全栈攻略(四)—— 从 OkHttp 拦截器来看 HTTP 协议一

上一篇我们详解了 OkHttp 的众多配置,本篇来看 OkHttp 是如何通过责任链上的内置拦截器完成 HTTP 请求与响应的,目的是更好地深入理解 HTTP 协议。这仍然是一篇偏向于协议实现向的文章,重点在于 HTTP 协议的实现方法与细节,关于责任链模式这些设计模式相关的内容,由于与 HTTP 协议关联不大,因此只是有所提及但不会着重讲解。

1、责任链与内置拦截器

不论是同步还是异步执行网络请求,任务的执行最终都会落到 RealCall 的 getResponseWithInterceptorChain() 中:

kotlin 复制代码
  @Throws(IOException::class)
  internal fun getResponseWithInterceptorChain(): Response {
    // 1.构建完整的拦截器堆栈,按添加顺序形成链式处理流程
    val interceptors = mutableListOf<Interceptor>()
    // 1.1 先添加 OkHttpClient 配置的 interceptors
    interceptors += client.interceptors
    // 1.2 再添加 OkHttp 内置的拦截器
    interceptors += RetryAndFollowUpInterceptor(client)
    interceptors += BridgeInterceptor(client.cookieJar)
    interceptors += CacheInterceptor(client.cache)
    interceptors += ConnectInterceptor
    // forWebSocket 用于区分是普通 HTTP 请求还是 WebSocket 握手请求。如果是普通
    // HTTP 请求,这里要插入 OkHttpClient 配置的网络拦截器列表 networkInterceptors
    if (!forWebSocket) {
      interceptors += client.networkInterceptors
    }
    interceptors += CallServerInterceptor(forWebSocket)

    // 2.创建责任链的初始对象
    val chain = RealInterceptorChain(
        // RealCall 
        call = this,
        // 拦截器列表
        interceptors = interceptors,
        // 索引,表示当前处理的是 interceptors 中的哪一个拦截器,责任链的开始传 0
        index = 0,
        exchange = null,
        // 请求,责任链的开始传原始请求
        request = originalRequest,
        connectTimeoutMillis = client.connectTimeoutMillis,
        readTimeoutMillis = client.readTimeoutMillis,
        writeTimeoutMillis = client.writeTimeoutMillis
    )

    // 3.开启责任链的执行
    var calledNoMoreExchanges = false
    try {
      // proceed() 触发责任链
      val response = chain.proceed(originalRequest)
      if (isCanceled()) {
        response.closeQuietly()
        throw IOException("Canceled")
      }
      return response
    } catch (e: IOException) {
      calledNoMoreExchanges = true
      throw noMoreExchanges(e) as Throwable
    } finally {
      if (!calledNoMoreExchanges) {
        noMoreExchanges(null)
      }
    }
  }

由于各个拦截器我们马上就要逐个详解,因此这里就简单聊聊责任链的构成与执行。

被实例化的责任链对象 RealInterceptorChain 实现了 Interceptor.Chain 接口,该接口最重要的方法就是用于执行责任链的 proceed():

kotlin 复制代码
class RealInterceptorChain(
  // 实际的网络请求对象
  internal val call: RealCall,
  // 拦截器列表
  private val interceptors: List<Interceptor>,
  // 索引,表示当前处理的是 interceptors 中的哪一个拦截器,责任链的开始传 0
  private val index: Int,
  // 负责传输单个 HTTP 请求与响应对。在 [ExchangeCodec](处理实际 I/O 操作)的基础上,
  // 实现了连接管理和事件通知的分层逻辑。
  internal val exchange: Exchange?,
  // 请求
  internal val request: Request,
  // 连接超时时间
  internal val connectTimeoutMillis: Int,
  // 读超时
  internal val readTimeoutMillis: Int,
  // 写超时
  internal val writeTimeoutMillis: Int
) : Interceptor.Chain {

  @Throws(IOException::class)
  override fun proceed(request: Request): Response {
    // 生成下一个责任链节点对象,参数 index 告知下一个责任链节点应该取出
    // interceptors[index + 1] 这个拦截器进行处理
    val next = copy(index = index + 1, request = request)
    // 取构造函传入的 index 对应的拦截器
    val interceptor = interceptors[index]

    // 执行拦截器的处理逻辑并得到响应结果 response
    val response = interceptor.intercept(next) ?: throw NullPointerException(
        "interceptor $interceptor returned null")

    return response
  }
}

精简后的 proceed() 代码就是两点:

  1. 生成责任链的下一个节点,在拦截器执行拦截逻辑的 intercept() 时作为参数传入
  2. 执行拦截器的拦截逻辑 intercept() 得到响应结果

拦截器的拦截逻辑 intercept() 大致可以分为三部分:

kotlin 复制代码
  override fun intercept(chain: Interceptor.Chain): Response {
      // 1.获取 Request 并根据自身功能对 Request 做出修改...
      Request request = chain.request();
      ...
      
      // 2.执行参数传入的责任链上下一个责任链节点对象 chain 的 proceed(),交接接力棒到下一个节点
      val networkResponse = chain.proceed(requestBuilder.build())
      
      // 3.对第 2 步得到的响应 networkResponse 根据自身功能做出修改...
      
      return networkResponse
  }

由于每个拦截器的功能不同,因此 1、3 两步是否存在要看具体的拦截器功能。比如重试与重定向拦截器 RetryAndFollowUpInterceptor 就没有第 1 步修改 Request 的需求。但所有拦截器一定都有第 2 步接力棒交接的动作,如果没有,责任链就断了,后面的拦截器就无法工作。

通过 RealInterceptorChain 的构造函数能看到,虽然它保存了所有拦截器的列表 interceptors: List<Interceptor>,但是在进行处理时,每个 RealInterceptorChain 都是根据 index 取出 interceptors 中的一个拦截器进行拦截操作的,而拦截器的拦截逻辑又会通过 proceed() 执行下一个责任链节点。因此可以将这种责任链模式看成如下结构:

因此,先添加到拦截器列表中的拦截器,就越先获取并修改 Request,但越靠后拿到响应的 Response。

接下来我们会按照 getResponseWithInterceptorChain() 内向 interceptors 列表添加拦截器的顺序逐一介绍这些拦截器。

2、重试与重定向拦截器

RetryAndFollowUpInterceptor 顾名思义就是要做重试与重定向的:

kotlin 复制代码
  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    // 通过参数传入的 chain 获取 HTTP 请求 Request 请求以及请求任务 RealCall
    val realChain = chain as RealInterceptorChain
    var request = chain.request
    val call = realChain.call
    // 重定向次数
    var followUpCount = 0
    // 上一次请求的响应,用于在重定向返回结果时,把上一次请求的响应体放到本次
    // 也就是重定向的响应体 Response 中
    var priorResponse: Response? = null
    // 是否是新的 ExchangeFinder,当不是重试且允许新路由时为 true
    var newExchangeFinder = true
    // 可以重试的错误列表
    var recoveredFailures = listOf<IOException>()
    // 在满足重试或重定向的条件时会一直循环
    while (true) {
      // 1.准备工作
      call.enterNetworkInterceptorExchange(request, newExchangeFinder)

      var response: Response
      var closeActiveExchange = true
      try {
        ...

        try {
          // 2.中置工作:责任链交接棒
          response = realChain.proceed(request)
          newExchangeFinder = true
        } catch (e: RouteException) { // 3.请求出错了,进行重试判断
          // 3.1 路由异常,连接失败,请求还没有发出去,用 recover() 判断是否满足重试条件
          if (!recover(e.lastConnectException, call, request, requestSendStarted = false)) {
            throw e.firstConnectException.withSuppressed(recoveredFailures)
          } else {
            // if 未命中说明满足重试条件,那么就把这个异常放到 recoveredFailures 里面
            recoveredFailures += e.firstConnectException
          }
          // 由于满足重试条件要进行重试了,因此要把 newExchangeFinder 置为 false 了
          newExchangeFinder = false
          // 由于本次循环失败但满足重试条件,因此跳出本次 while 循环,重试进入下一次循环
          continue
        } catch (e: IOException) {
          // 3.2 请求可能已经发出,但与服务器通信失败,还是用 recover() 判断能否重试
          if (!recover(e, call, request, requestSendStarted = e !is ConnectionShutdownException)) {
            throw e.withSuppressed(recoveredFailures)
          } else {
            recoveredFailures += e
          }
          newExchangeFinder = false
          continue
        }

        // 4.代码能执行到这里说明前面没出错,不用进行重试,那么就开始做后置工作,
        // 根据返回的 Response 响应判断是否需要做重定向工作了
        // 4.1 如果之前做过重定向,那么 priorResponse 就不为空,将其放到本次响应体中
        if (priorResponse != null) {
          response = response.newBuilder()
              .priorResponse(priorResponse.newBuilder()
                  .body(null)
                  .build())
              .build()
        }

        // 4.2 检查是否需要进行重定向,如果 followUp 为空则不需要
        val exchange = call.interceptorScopedExchange
        val followUp = followUpRequest(response, exchange)

        if (followUp == null) {
          if (exchange != null && exchange.isDuplex) {
            call.timeoutEarlyExit()
          }
          closeActiveExchange = false
          return response
        }

        // 4.3 需要重定向的情况
        val followUpBody = followUp.body
        // 如果请求的 Request 有请求体,并且请求体中配置了只允许传输一次,那就不做重定向直接返回
        if (followUpBody != null && followUpBody.isOneShot()) {
          closeActiveExchange = false
          return response
        }

        response.body?.closeQuietly()

        // 重定向次数超过 MAX_FOLLOW_UPS(默认 20),也不做重定向
        if (++followUpCount > MAX_FOLLOW_UPS) {
          throw ProtocolException("Too many follow-up requests: $followUpCount")
        }

        // 将重定向请求赋值给 request 作为下一次循环的请求,从而实现重定向请求
        // response 赋值给 priorResponse 作为下一次请求的前置请求
        request = followUp
        priorResponse = response
      } finally {
        // 5、与 1 的准备工作相对应,现在本次请求结束了,要做收尾工作
        call.exitNetworkInterceptorExchange(closeActiveExchange)
      }
    }
  }

RetryAndFollowUpInterceptor 的 intercept() 如果细致划分的话,可以像注释中标记的那样分成 5 步。

第 1 步准备工作与第 5 步收尾工作可以放在一起看,调用的都是 RealCall 的方法:

kotlin 复制代码
  /**
   * 为可能遍历所有网络拦截器的流程做准备。此操作将尝试找到一个 Exchange 来承载请求。
   * 如果请求已被缓存满足,则不需要 Exchange。
   *
   * @param newExchangeFinder 如果这不是一次重试且允许执行新路由,则为 true。
   */
  fun enterNetworkInterceptorExchange(request: Request, newExchangeFinder: Boolean) {
    // 确保当前没有已绑定的 Exchange
    check(interceptorScopedExchange == null)

    // 如果上一个请求体与响应体未关闭则抛异常,确保前一次请求的资源已释放,防止资源泄漏
    synchronized(this) {
      check(!responseBodyOpen) {
        "cannot make a new request because the previous response is still open: " +
            "please call response.close()"
      }
      check(!requestBodyOpen)
    }
      
    // 创建新的路由查找器(如非重试场景)
    if (newExchangeFinder) {
      this.exchangeFinder = ExchangeFinder(
          connectionPool,
          createAddress(request.url),
          this,
          eventListener
      )
    }
  }
  /**
   * @param closeExchange 是否应关闭当前 Exchange(通常因异常或重试导致不再使用)
   */
  internal fun exitNetworkInterceptorExchange(closeExchange: Boolean) {
    // 线程安全校验:确保当前调用未被释放
    synchronized(this) {
      check(expectMoreExchanges) { "released" }
    }

    // 强制关闭当前 Exchange(如发生异常需要重试)
    if (closeExchange) {
      exchange?.detachWithViolence()
    }

    // 清理当前拦截器作用域下的 Exchange
    interceptorScopedExchange = null
  }

第 2 步执行下一个责任链,前面已经分析过。比较重要的是第 3 步重试与第 4 步重定向,我们详细介绍一下。

2.1 重试

从注释的 3.1 与 3.2 两步可以看出,允许进行重试判断的情况有两种:

  1. 抛出 RouteException,路由异常,连接失败,请求未能发出
  2. 抛出 IOException,请求可能已经发出,但与服务器通信失败

只有在这两种情况下才能使用 recover() 做进一步的细分判断是否能重试:

kotlin 复制代码
  /**
   * 报告并尝试从与服务器通信的失败中恢复。若异常 `e` 可恢复则返回 true,否则返回 false。
   * 注意:带请求体的请求仅在以下情况可恢复:
   * 1. 请求体已被缓冲;
   * 2. 失败发生在请求发送前。
   */
  private fun recover(
    e: IOException,
    call: RealCall,
    userRequest: Request,
    requestSendStarted: Boolean
  ): Boolean {
    // 情况 1:应用层禁止重试(通过 OkHttpClient 配置)
    if (!client.retryOnConnectionFailure) return false

    // 情况 2:请求已发送且为一次性请求体(无法重试发送 Body)
    if (requestSendStarted && requestIsOneShot(e, userRequest)) return false

    // 情况 3:异常不可恢复(如 SSL 证书错误)
    if (!isRecoverable(e, requestSendStarted)) return false

    // 情况 4:无更多可用路由(如所有 IP 尝试失败)
    if (!call.retryAfterFailure()) return false

    // 所有条件通过,允许恢复并重试
    return true
  }

下面看每种情况的具体内容。

客户端是否允许重试

取决于 OkHttpClient 的 retryOnConnectionFailure 属性,默认值为 true,可以通过 Builder 模式配置该属性决定是否允许由该 OkHttpClient 发送的所有 Request 进行重试:

kotlin 复制代码
  @get:JvmName("retryOnConnectionFailure") val retryOnConnectionFailure: Boolean =
      builder.retryOnConnectionFailure

单个 Request 是否允许重试

对于已经发送的一次性请求体是无法重试的,它取决于 requestSendStarted 与 requestIsOneShot() 两部分。

对于 requestSendStarted,我们看注释 3.1 与 3.2 传的是不一样的:

  • 当抛出 RouteException 时,这时由于路由问题并没有连接到服务器,可以断定请求一定没有被发送,所以直接给 requestSendStarted 传了 false
  • 当抛出 IOException 时,只有在这个异常的具体类型是 ConnectionShutdownException 时才认为由于连接中断没有发出请求,其他的情况都认为请求已发出。由于只有 HTTP2 才会抛出这个异常,所以对于 HTTP1 而言,requestSendStarted 一定是 true

再看 requestIsOneShot():

kotlin 复制代码
  private fun requestIsOneShot(e: IOException, userRequest: Request): Boolean {
    val requestBody = userRequest.body
    return (requestBody != null && requestBody.isOneShot()) ||
        e is FileNotFoundException
  }

两个判断条件:

  1. 如果有请求体,且是一次性请求体
  2. 发生 FileNotFoundException,即请求体依赖文件,但该文件不存在

第一个条件,RequestBody 的 isOneShot() 是一个默认值为 false 的 open 方法,也就是说默认情况下,请求不是一次性的。但如果需要配置为 OneShot 的话,可以通过重写该函数实现。

第二个条件,假设一个 HTTP 请求的 Body 是基于本地文件的(比如上传文件):

kotlin 复制代码
val file = File("image.jpg")
val request = Request.Builder()
    .url("https://api.example.com/upload")
    .post(file.asRequestBody("image/jpeg".toMediaType()))
    .build()

如果因为文件不存在导致 FileNotFoundException,重试也还是相同的结果,因此这种情况也不能重试。

是否为可重试的异常类型

某些异常发生时,是不允许重试的,因为这些异常是真的因为客户端或服务端的代码有问题,即便重试无数次得到的也都是错误结果;而有些异常可能是由于网络波动导致异常发生,换个路线重试可能就会解决问题。因此需要 isRecoverable() 判断,哪些异常可以重试,哪些不可以:

kotlin 复制代码
  private fun isRecoverable(e: IOException, requestSendStarted: Boolean): Boolean {
    // 1.协议异常,不重试
    if (e is ProtocolException) {
      return false
    }

    // 2.一般的 IO 中断异常不会重试,但是如果是 Socket 超时异常,有可能是网络波动造成的,
    // 可以尝试其它路线(如果有)
    if (e is InterruptedIOException) {
      return e is SocketTimeoutException && !requestSendStarted
    }

    // 3.SSL 握手异常,如果是证书出现问题,不能重试
    if (e is SSLHandshakeException) {
      // 由 X509TrustManager 抛出的 CertificateException,不重试
      if (e.cause is CertificateException) {
        return false
      }
    }
      
    // 4.SSL 未授权异常(证书校验失败,不匹配或过期等问题)不重试  
    if (e is SSLPeerUnverifiedException) {
      // 这里框架作者举的例子是 CertificatePinning 的错误,我们在上一篇详细讲过
      // e.g. a certificate pinning error.
      return false
    }

    return true
  }

四个判断条件中,后两条是证书验证相关的,这部分在上一篇讲 OkHttpClient 的配置时有讲过,比如 CertificatePinning 的使用、X509 证书验证等等,所以不再赘述。

第 2 条也比较好理解,如果是网络波动造成 Socket 连接超时,重试时可能会躲过网络波动,这时允许重试是合理的。

需要稍作解释的是第 1 条 ------ 协议异常。

ProtocolException 是一种 IOException,比如 OkHttp 的 CallServerInterceptor 在其处理责任链的 intercept() 内就抛出了这种异常:

kotlin 复制代码
  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
      try {
          ...
          var code = response.code
          ...
          if ((code == 204 || code == 205) && response.body?.contentLength() ?: -1L > 0L) {
        	throw ProtocolException(
            	"HTTP $code had non-zero Content-Length: ${response.body?.contentLength()}")
      	  }
      	  return response
      }
  }

看条件,是状态码为 204 或 205 且响应体内容长度大于 0 时会抛出 ProtocolException。两个状态码的含义分别为:

  • 204:No Content,表示无内容。服务器成功处理,但未返回内容。在未更新网页的情况下,可确保浏览器继续显示当前文档
  • 205:Reset Content,表示重置内容。服务器处理成功,用户终端(例如:浏览器)应重置文档视图。可通过此返回码清除浏览器的表单域

也就是说,状态码告诉我们服务器没有返回内容,即没有响应体,但是我们通过代码拿到响应体的内容长度大于 0,这两个条件前后矛盾,有可能是服务器响应本身就存在问题,就算重试也还是得到一个错误结果,于是抛出 ProtocolException 不进行重试。

是否有其他可用路由

我们在配置 OkHttpClient 的时候可能会为客户端配置多个代理,而服务器端的同一个域名也可能会有多个 IP 地址,当某条路线通信失败后,如果存在更多路线,就会换个路线尝试。比如说,restapi.amap.com 解析为 IP1 和 IP2,如果 IP1 通信失败会重试 IP2。

最后我们用一张图来总结一下重试的流程:

2.2 重定向

在进行重定向判断时,框架是将响应 Response 与 Exchange 对象传入 followUpRequest() 得到了一个新的 Request 请求 followUp。在 followUp 不为空且其 body 不为空、followUp 不是一次性 Request 以及重定向次数未超过阈值的情况下,才会进行重定向。那么 followUpRequest() 就是一个很关键的方法:

kotlin 复制代码
  /**
   * 根据接收到的 [userResponse] 确定要进行的 HTTP 请求。这将添加身份验证头、跟随重定向或处理
   * 客户端请求超时。如果后续请求不必要或不适用,则返回 null。
   */
  @Throws(IOException::class)
  private fun followUpRequest(userResponse: Response, exchange: Exchange?): Request? {
    // 获取服务器响应的状态码
    val route = exchange?.connection?.route()
    val responseCode = userResponse.code

    val method = userResponse.request.method
    when (responseCode) {
      // 407 代理认证:客户端使用了 HTTP 代理,需要在请求头中添加【Proxy-Authorization】,
      // 让代理服务器授权
      HTTP_PROXY_AUTH -> {
        val selectedProxy = route!!.proxy
        if (selectedProxy.type() != Proxy.Type.HTTP) {
          throw ProtocolException("Received HTTP_PROXY_AUTH (407) code while not using proxy")
        }
        // 调用 OkHttpClient 代理的鉴权接口
        return client.proxyAuthenticator.authenticate(route, userResponse)
      }

      // 401 未授权:有些服务器接口需要验证使用者身份,要在请求头中添加【Authorization】
      HTTP_UNAUTHORIZED -> return client.authenticator.authenticate(route, userResponse)

      // HTTP_PERM_REDIRECT:308 永久重定向、HTTP_TEMP_REDIRECT:307 临时重定向、
      // HTTP_MULT_CHOICE:300 多种选择、HTTP_MOVED_PERM:301 永久移动、
      // HTTP_MOVED_TEMP:302 临时移动、HTTP_SEE_OTHER:303:查看其它地址,这些情况构建重定向请求
      HTTP_PERM_REDIRECT, HTTP_TEMP_REDIRECT, HTTP_MULT_CHOICE, HTTP_MOVED_PERM, HTTP_MOVED_TEMP, HTTP_SEE_OTHER -> {
        return buildRedirectRequest(userResponse, method)
      }

      // 408:服务器等待客户端发送的请求时间过长,超时。实际中用的很少,像 HAProxy 这种服务器
	 // 会用这个状态码,不需要修改请求,只需再申请一次即可。
      HTTP_CLIENT_TIMEOUT -> {
        // 假如应用层(指 OkHttpClient)配置了不允许重试就返回 null
        if (!client.retryOnConnectionFailure) {
          return null
        }

        // 如果是一次性 RequestBody,不重试
        val requestBody = userResponse.request.body
        if (requestBody != null && requestBody.isOneShot()) {
          return null
        }
        // 如果上一次响应的状态码就是 408,不重试
        val priorResponse = userResponse.priorResponse
        if (priorResponse != null && priorResponse.code == HTTP_CLIENT_TIMEOUT) {
          // We attempted to retry and got another timeout. Give up.
          return null
        }

        // 如果服务器告诉我们需要在多久后重试,那框架就不管了
        if (retryAfter(userResponse, 0) > 0) {
          return null
        }

        // 返回原来的请求,没有修改,也就是重试了
        return userResponse.request
      }

      // 503:服务不可用。由于超载或系统维护,服务器暂时的无法处理客户端的请求。
      // 延时的长度可包含在服务器的 Retry-After 头信息中
      HTTP_UNAVAILABLE -> {
        val priorResponse = userResponse.priorResponse
        // 如果上一次响应的状态码也是 503 则不重试
        if (priorResponse != null && priorResponse.code == HTTP_UNAVAILABLE) {
          return null
        }

        // 如果服务器返回的 Retry-After 是 0,也就是立即重试的意思,框架才重新请求
        if (retryAfter(userResponse, Integer.MAX_VALUE) == 0) {
          return userResponse.request
        }

        return null
      }

      // 421:错误连接。HTTP/2 允许不同域名的请求共享连接(看 RealConnection.isEligible()),
      // 但若服务器返回 421,需断开合并连接。
      HTTP_MISDIRECTED_REQUEST -> {
        val requestBody = userResponse.request.body
        // 一次性请求体不重试
        if (requestBody != null && requestBody.isOneShot()) {
          return null
        }

        // 无关联的 Exchange 对象(表示无实际网络交互)或者当前连接未启用合并机制,不重试
        if (exchange == null || !exchange.isCoalescedConnection) {
          return null
        }

        // 标记连接禁止合并,禁止后续请求复用该连接进行合并(将 noCoalescedConnections 置位)
        exchange.connection.noCoalescedConnections()
        // 返回原请求重试
        return userResponse.request
      }

      else -> return null
    }
  }

可以看出,followUpRequest() 会根据不同的响应状态码做出判断是否进行重定向,如需要进行重定向就返回 Request 否则返回 null。

下面我们按照状态码分类介绍上述状态码的处理过程。

3xx

followUpRequest() 将所有 3xx 状态码都放在一个 case 中处理了,就是用 buildRedirectRequest() 创建一个重定向请求:

kotlin 复制代码
  private fun buildRedirectRequest(userResponse: Response, method: String): Request? {
    // 如果 OkHttpClient 配置了不允许重定向则返回 null
    if (!client.followRedirects) return null

    // 检查响应头中是否有 Location 字段,该字段的值能否解析成 URL,
    // 可以则解析并保存 URL,否则返回 null 不进行重定向
    val location = userResponse.header("Location") ?: return null
    val url = userResponse.request.url.resolve(location) ?: return null

    // 检查重需要定向的 URL 与此前请求的 URL 的 scheme 是否相同。scheme 在 OkHttp 这个
    // 框架中就只有 http 或 https 两个值。假如 OkHttpClient 配置 followSslRedirects 为
    // false,那就不允许在 SSL(HTTPS)与非 SSL(HTTP)协议之间重定向
    val sameScheme = url.scheme == userResponse.request.url.scheme
    if (!sameScheme && !client.followSslRedirects) return null

    // 大多数重定向请求都没有请求体
    val requestBuilder = userResponse.request.newBuilder()
    // 不是 GET 或 HEAD 方法的话,是允许有请求体的
    if (HttpMethod.permitsRequestBody(method)) {
      val responseCode = userResponse.code
      // 如果请求方法是 PROPFIND 或者响应状态码为 308、307,需要保持请求体
      val maintainBody = HttpMethod.redirectsWithBody(method) ||
          responseCode == HTTP_PERM_REDIRECT ||
          responseCode == HTTP_TEMP_REDIRECT
      // 如果请求方法不是 PROPFIND 且响应状态码不是 308、307,重定向方法要使用 GET
      if (HttpMethod.redirectsToGet(method) && responseCode != HTTP_PERM_REDIRECT && responseCode != HTTP_TEMP_REDIRECT) {
        requestBuilder.method("GET", null)
      } else {
        // 如果请求方法是 PROPFIND 或者响应状态码是 307、308 之一,则使用原请求方法与请求体
        val requestBody = if (maintainBody) userResponse.request.body else null
        requestBuilder.method(method, requestBody)
      }
      // 如果不需要保持请求体就移除以下请求头
      if (!maintainBody) {
        requestBuilder.removeHeader("Transfer-Encoding")
        requestBuilder.removeHeader("Content-Length")
        requestBuilder.removeHeader("Content-Type")
      }
    }

    // 当重定向到不同主机时,移除 Authorization 头
    if (!userResponse.request.url.canReuseConnectionFor(url)) {
      requestBuilder.removeHeader("Authorization")
    }

    return requestBuilder.url(url).build()
  }

buildRedirectRequest() 可以大致分为两部分:

  1. 检查是否满足重定向条件,不满足则返回 null,涉及到的不满足情况包括:
    • OkHttpClient 的重定向配置 followRedirects 设置为 false
    • 返回的响应体是 3xx 要求重定向,但响应头中没有 Location 或者 Location 的值无法解析为 URL
    • 原请求的 URL 与原请求对应的响应头中 Location 指定的 URL 发生了 SSL 协议切换(对于 OkHttp 框架而言,就是一个 URL 是 HTTP,另一个 URL 是 HTTPS),但 OkHttpClient 又关闭了跨 SSL 重定向的配置 followSslRedirects
  2. 满足重定向条件,就构造重定向的请求头与请求体:
    • 对于没有请求体的 GET 与 HEAD 方法而言,只需要额外再判断一个跨主机重定向时去掉 Authorization 请求头即可
    • 对于其他可以有请求体的方法,先判断是否保持原请求的请求体 maintainBody,对于明确需要保持的方法 PROPFIND(目前 redirectsWithBody() 只有传入 PROPFIND 才返回 true)或者状态码为 307、308 时,maintainBody 为 true
    • 随后决定重定向请求使用哪个方法以及请求体。如果原请求方法不是 PROPFIND(目前 redirectsToGet() 除了 PROPFIND 以外其余都是 true),且状态码不是 307 与 308,那么重定向请求的方法就是 GET 且没有方法体;否则重定向方法就是 PROPFIND 且请求体为原请求的请求体

为什么 307、308 以及 PROPFIND 需要做出特殊的判断与处理,强制保留原请求的方法和请求体?

其中 307、308 是 HTTP 重定向规范(RFC 7231 (HTTP/1.1) 和 RFC 7538 (HTTP/308))中规定的,要求客户端在重定向时保持原始请求方法和请求体。

至于 PROPFIND 是 WebDAV 协议的扩展方法,行为类似于标准 HTTP 的 GET,但需要携带 XML 请求体以描述查询条件。这个方法本身就需要保持请求体,因为服务器可能依赖请求体中的 XML 内容处理查询。

因此关于重定向的处理思路可以总结为如下表格:

条件 操作 示例场景
状态码为307/308 强制保留方法和请求体 服务器要求保持原始请求
方法为PROPFIND/POST/PUT 保留请求体(需配合307/308) WebDAV查询或表单提交
其他状态码(301/302) 改为GET并移除请求体 传统重定向逻辑

4xx

我们先说两个比较相近的:HTTP_UNAUTHORIZED(401 未授权)与 HTTP_PROXY_AUTH(407 代理认证)。

实际上这两个状态码我们在上一篇讲 OkHttp 配置时已经讲过:当你要访问的服务器资源需要授权才能访问,而你在请求中又没有携带授权信息相关的请求头时,服务器会返回 401 状态码;407 是类似的,当你使用代理服务器又没有提供鉴权请求头时服务器就会返回 407。

清楚原因后,对于两种状态码的处理方案也就呼之欲出了,就是在请求时带上授权信息呗。按照 OkHttp 框架给出的,对于 401 调用 OkHttpClient 的 authenticator 的 authenticate() 进行授权,对于 407 则调用 OkHttpClient 的 proxyAuthenticator 的 authenticate() 授权。但 authenticator 与 proxyAuthenticator 的默认值都是 Authenticator 接口的默认实现 Authenticator.NONE:

kotlin 复制代码
fun interface Authenticator {

  @Throws(IOException::class)
  fun authenticate(route: Route?, response: Response): Request?

  companion object {
    /** An authenticator that knows no credentials and makes no attempt to authenticate. */
    @JvmField
    val NONE: Authenticator = AuthenticatorNone()
    private class AuthenticatorNone : Authenticator {
      override fun authenticate(route: Route?, response: Response): Request? = null
    }
  }
}

因此通过这种方式需要自己实现 Authenticator 接口:

kotlin 复制代码
val client = OkHttpClient.Builder()
    .authenticator(object : Authenticator {
        override fun authenticate(route: Route?, response: Response): Request? {
            // 添加 Authorization 与 Proxy-Authorization 请求头
            return response.request.newBuilder()
            .header("Authorization", "Bearer xxxxx....")
            .header("Proxy-Authorization", "xxxxx....")
            .build()
        }
    })

然后说一下 HTTP_CLIENT_TIMEOUT(408),是服务器等待客户端发送超时。这个实际上跟重定向没啥关系,属于超时重试的逻辑,在刨除不满足重试的条件后,无需修改请求进行重试即可。我们来看看它这种情况下用到的 retryAfter():

kotlin 复制代码
  private fun retryAfter(userResponse: Response, defaultDelay: Int): Int {
    val header = userResponse.header("Retry-After") ?: return defaultDelay

    if (header.matches("\\d+".toRegex())) {
      return Integer.valueOf(header)
    }
    return Integer.MAX_VALUE
  }

根据响应头中的 Retry-After 字段,返回当前距离下一次重试需要的时间。当前会忽略 HTTP 的日期,并且假设任何 int 型的非 0 值都是延迟。

实际上就是返回一个延迟的值,表示过多长时间之后再重试。

5xx

在 followUpRequest() 中只有 HTTP_UNAVAILABLE(503 服务不可用)一个 5 开头的状态码,它是指由于超载或系统维护,服务器暂时的无法处理客户端的请求。需要在上一次响应不是 HTTP_UNAVAILABLE 的情况下通过 retryAfter() 判断是否可以立即进行重试,如果服务器返回的 Retry-After 响应头的值不为 0,那么就放弃重试。

相关推荐
黄林晴1 天前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我1 天前
flutter 之真手势冲突处理
android·flutter
法的空间1 天前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止1 天前
深入解析安卓 Handle 机制
android
恋猫de小郭1 天前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
白帽黑客沐瑶1 天前
【网络安全就业】信息安全专业的就业前景(非常详细)零基础入门到精通,收藏这篇就够了
网络·安全·web安全·计算机·程序员·编程·网络安全就业
jctech1 天前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户2018792831671 天前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥1 天前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin
Cui晨1 天前
Android RecyclerView展示List<View> Adapter的数据源使用View
android