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

发布于:2025-05-23 ⋅ 阅读:(13) ⋅ 点赞:(0)

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

1、责任链与内置拦截器

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

  @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():

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() 大致可以分为三部分:

  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() 执行下一个责任链节点。因此可以将这种责任链模式看成如下结构:

2024-12-6.记忆责任链

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

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

2、重试与重定向拦截器

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

  @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 的方法:

  /**
   * 为可能遍历所有网络拦截器的流程做准备。此操作将尝试找到一个 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() 做进一步的细分判断是否能重试:

  /**
   * 报告并尝试从与服务器通信的失败中恢复。若异常 `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 进行重试:

  @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():

  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 是基于本地文件的(比如上传文件):

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

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

是否为可重试的异常类型

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

  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() 内就抛出了这种异常:

  @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() 就是一个很关键的方法:

  /**
   * 根据接收到的 [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() 创建一个重定向请求:

  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:

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 接口:

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():

  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,那么就放弃重试。


网站公告

今日签到

点亮在社区的每一天
去签到