原始碼分析三:OkHttp—RetryAndFollowUpInterceptor

楊昆發表於2018-03-09

引言

上一篇部落格籠統的講了各個攔截器:

從這邊部落格開始單獨分析每個攔截器的原始碼。首先是RetryAndFollowUpInterceptor。

這個攔截器它的作用主要是負責請求的重定向操作,用於處理網路請求中,請求失敗後的重試機制。

核心功能

  • 連線失敗重試(Retry)

    在發生 RouteException 或者 IOException 後,會捕獲建聯或者讀取的一些異常,根據一定的策略判斷是否是可恢復的,如果可恢復會重新建立 StreamAllocation 開始新的一輪請求

  • 繼續發起請求(Follow up)

    主要有這幾種型別

    • 3xx 重定向
    • 401,407 未授權,呼叫 Authenticator 進行授權後繼續發起新的請求
    • 408 客戶端請求超時,如果 Request 的請求體沒有被 UnrepeatableRequestBody 標記,會繼續發起新的請求

其中 Follow up 的次數受到MAX_FOLLOW_UP 約束,在 OkHttp 中為 20 次,這樣可以防止重定向死迴圈。

原始碼分析

這裡詳細分析重定向機制,關於RealCall的構造方法,原始碼如下所示

RealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket) {
  final EventListener.Factory eventListenerFactory = client.eventListenerFactory();

  this.client = client;
  this.originalRequest = originalRequest;
  this.forWebSocket = forWebSocket;
  this.retryAndFollowUpInterceptor = new RetryAndFollowUpInterceptor(client, forWebSocket);

  // TODO(jwilson): this is unsafe publication and not threadsafe.
  this.eventListener = eventListenerFactory.create(this);
}複製程式碼

根據之前攔截器的介紹,會執行RetryAndFollowUpInterceptor的intercept方法。

@Override public Response intercept(Chain chain) throws IOException {
  Request request = chain.request();

  streamAllocation = new StreamAllocation(
      client.connectionPool(), createAddress(request.url()), callStackTrace);
   // (1)“建立StreamAllocation”
  int followUpCount = 0;
  Response priorResponse = null;
  while (true) {
    if (canceled) {
      streamAllocation.release();
      throw new IOException("Canceled");
    }
    //(2)進入迴圈,檢查請求是否已被取消


    Response response = null;
    boolean releaseConnection = true;
    try {
      response = ((RealInterceptorChain) chain).proceed(request, streamAllocation, null, null);
      releaseConnection = false;
    } catch (RouteException e) {
      // The attempt to connect via a route failed. The request will not have been sent.
      if (!recover(e.getLastConnectException(), false, request)) {
        throw e.getLastConnectException();
      }
      releaseConnection = false;
      continue;
    } catch (IOException e) {
      // An attempt to communicate with a server failed. The request may have been sent.
      boolean requestSendStarted = !(e instanceof ConnectionShutdownException);
      if (!recover(e, requestSendStarted, request)) throw e;
      releaseConnection = false;
      continue;
    } finally {
      
      if (releaseConnection) {
        streamAllocation.streamFailed(null);
        streamAllocation.release();
      }
    }
    //(3)“執行RealInterceptorChain.procced,並捕獲異常來判斷是否可以重定向”

    // Attach the prior response if it exists. Such responses never have a body.
    if (priorResponse != null) {
      response = response.newBuilder()
          .priorResponse(priorResponse.newBuilder()
                  .body(null)
                  .build())
          .build();
    }
    
    Request followUp = followUpRequest(response);
    //(4)“是否需要進行重定向”

    if (followUp == null) {
      if (!forWebSocket) {
        streamAllocation.release();
      }
      return response;
    }
    //(5)“不需要重定向,直接返回response,結束。否則到6”
    closeQuietly(response.body());

    if (++followUpCount > MAX_FOLLOW_UPS) {
      streamAllocation.release();
      throw new ProtocolException("Too many follow-up requests: " + followUpCount);
    }
    
    if (followUp.body() instanceof UnrepeatableRequestBody) {
      streamAllocation.release();
      throw new HttpRetryException("Cannot retry streamed HTTP body", response.code());
    }
    // (6)“重定向安全檢查”

    if (!sameConnection(response, followUp.url())) {
      streamAllocation.release();
      streamAllocation = new StreamAllocation(
          client.connectionPool(), createAddress(followUp.url()), callStackTrace);
    //(7)“根據新的請求建立連線”
    } else if (streamAllocation.codec() != null) {
      throw new IllegalStateException("Closing the body of " + response
          + " didn't close its backing stream. Bad interceptor?");
    }

    request = followUp;
    priorResponse = response;
  }
}複製程式碼

可以看出主要攔截處理過程是個while迴圈,如果沒有重定向則返回response,有則按迴圈內處理。

主要有以下5步:

  1. 建立StreamAllocation
  2. 進入迴圈,檢查請求是否已被取消
  3. 執行RealInterceptorChain.procced,並捕獲異常來判斷是否可以重定向
  4. 是否需要進行重定向
  5. 不需要重定向,直接返回response,結束。否則到6
  6. 重定向安全檢查
  7. 根據新的請求建立連線

下面逐個分析:

1.建立StreamAllocation

Request request = chain.request();

streamAllocation = new StreamAllocation(
    client.connectionPool(), createAddress(request.url()), callStackTrace);複製程式碼

它主要用於管理客戶端與伺服器之間的連線,同時管理連線池,以及請求成功後的連線釋放等操作。

在執行StreamAllocation建立時,可以看到根據客戶端請求的地址url,還呼叫了createAddress方法。進入該方法可以看出,這裡返回了一個建立成功的Address,實際上Address就是將客戶端請求的網路地址,以及伺服器的相關資訊,進行了統一的包裝,也就是將客戶端請求的資料,轉換為OkHttp框架中所定義的伺服器規範,這樣一來,OkHttp框架就可以根據這個規範來與伺服器之間進行請求分發了。

private Address createAddress(HttpUrl url) {
  SSLSocketFactory sslSocketFactory = null;
  HostnameVerifier hostnameVerifier = null;
  CertificatePinner certificatePinner = null;
  if (url.isHttps()) {
    sslSocketFactory = client.sslSocketFactory();
    hostnameVerifier = client.hostnameVerifier();
    certificatePinner = client.certificatePinner();
  }

  return new Address(url.host(), url.port(), client.dns(), client.socketFactory(),
      sslSocketFactory, hostnameVerifier, certificatePinner, client.proxyAuthenticator(),
      client.proxy(), client.protocols(), client.connectionSpecs(), client.proxySelector());
}複製程式碼

2.檢查該請求是否已被取消

這裡會進入迴圈,首先會進行一個安全檢查操作,檢查當前請求是否被取消,如果這時請求被取消了,則會通過StreamAllocation釋放連線,並丟擲異常,原始碼如下所示

if (canceled) {
  streamAllocation.release();
  throw new IOException("Canceled");
}複製程式碼

3.捕獲異常來判斷是否可以重定向

3.1 捕獲異常

如果這時沒有發生2,接下來會通過RealInterceptorChain的proceed方法處理請求,在請求過程中,只要發生異常,releaseConnection就會為true,一旦變為true,就會將StreamAllocation釋放掉。 

根據之前的博文,RealInterceptorChain的proceed會呼叫後面的攔截器的攔截方法,如果有問題,會拋2種異常:

  • RouteException

    這個異常發生在 Request 請求還沒有發出去前,就是開啟 Socket 連線失敗。這個異常是 OkHttp 自定義的異常,是一個包裹類,包裹住了建聯失敗中發生的各種 Exception

    主要發生 ConnectInterceptor 建立連線環節

    比如連線超時丟擲的 SocketTimeoutException,包裹在 RouteException 中

  • IOException

    這個異常發生在 Request 請求發出並且讀取 Response 響應的過程中,TCP 已經連線,或者 TLS 已經成功握手後,連線資源準備完畢

    主要發生在 CallServerInterceptor 中,通過建立好的通道,傳送請求並且讀取響應的環節

    比如讀取超時丟擲的 SocketTimeoutException

3.2 重試判斷

在捕獲到這兩種異常後,OkHttp 會使用 recover 方法來判斷是否是不可以重試的。然後有兩種處理方式:

  • 不可重試的

    會把繼續把異常丟擲,呼叫 StreamAllocation 的 streamFailedrelease 方法釋放資源,結束請求。OkHttp 有個黑名單機制,用來記錄發起失敗的 Route,從而在連線發起前將之前失敗的 Route 延遲到最後再使用,streamFailed 方法可以將這個出問題的 route 記錄下來,放到黑名單(RouteDatabase)。所以下一次發起新請求的時候,上次失敗的 Route 會延遲到最後再使用,提高了響應成功率

  • 可以重試的

    則繼續使用 StreamAllocation 開始新的 proceed 。是不是可以無限重試下去?並不是,每一次重試,都會呼叫 RouteSelector 的 next 方法獲取新的 Route,當沒有可用的 Route 後就不會再重試了

什麼情況下的失敗是不可以恢復的呢?

繼續看recover方法:

private boolean recover(IOException e, boolean requestSendStarted, Request userRequest) {
  streamAllocation.streamFailed(e);

  // 需要OkHttpClient配置可以重試
  if (!client.retryOnConnectionFailure()) return false;

  // 使用 isRecoverable 方法過濾掉不可恢復異常
  if (requestSendStarted && userRequest.body() instanceof UnrepeatableRequestBody) return false;

  // 使用 isRecoverable 方法過濾掉不可恢復異常
  if (!isRecoverable(e, requestSendStarted)) return false;

  // 已經沒有其他的路由可以使用
  if (!streamAllocation.hasMoreRoutes()) return false;

  // For failure recovery, use the same route selector with a new connection.
  return true;
}複製程式碼

簡單分析 recover 的程式碼,首先會呼叫 StreamAllocation 的 streamFailed 方法釋放資源。然後通過以下4種策略來判斷這些型別是否可以重試:

   (1)OkHttpClient是否配置可以重試

OkHttpClient okHttpClient = new OkHttpClient.Builder()
   ...
   .retryOnConnectionFailure(true)
   ...
   .build();複製程式碼

    (2)不是被 RouteException 包裹的異常,並且請求的內容被 UnrepeatableRequestBody 標記       

       也就是不是建立連線階段發生的異常,比如發起請求和獲取響應的時候發生的IOException,同時請求的內容不可重複發起,就不能重試

    (3) 使用 isRecoverable 方法過濾掉不可恢復異常

    通過的話會繼續進入第 4 步。

    不可重試的異常有:

  • ProtocolException

    協議異常,主要發生在 RealConnection 中建立 HTTPS 通過 HTTP 代理進行連線重試超過 21 次。不可以重試

    private void buildTunneledConnection(int connectTimeout, int readTimeout, int writeTimeout,
    ConnectionSpecSelector connectionSpecSelector) throws IOException {
         ...
         int attemptedConnections = 0;
         int maxAttempts = 21;
         while (true) {
           if (++attemptedConnections > maxAttempts) {
             throw new ProtocolException("Too many tunnel connections attempted: " + maxAttempts);
           }
          ...
          }
      ...
    }12345678910111213複製程式碼
  • InterruptedIOException

    如果是建立連線時 SocketTimeoutException,即建立 TCP 連線超時,會走到第4步

    如果是連線已經建立,在讀取響應的超時的 SocketTimeoutException,不可恢復

  • CertificateException 引起的 SSLHandshakeException

    證照錯誤導致的異常,比如證照製作錯誤

  • SSLPeerUnverifiedException

    訪問網站的證照不在你可以信任的證照列表中

    (4) 已經沒有其他的路由可以使用

    前面三步條件都通過的,還需要最後一步檢驗,就是獲取可用的 Route。

   public boolean hasMoreRoutes() {
       return route != null || routeSelector.hasNext();
   }  123複製程式碼

所以滿足下面兩個條件就可以結束請求,釋放 StreamAllocation 的資源了

  • StreamAllocation 的 route 為空
  • RouteSelector 沒有可選擇的路由了
    • 沒有下一個 IP
    • 沒有下一個代理
    • 沒有下一個延遲使用的 Route(之前有失敗過的路由,會在這個列表中延遲使用)

RouteSelector 封裝了選擇可用路由進行連線的策略。重試一個重要作用,就是這個請求存在多個代理,多個 IP 情況下,OkHttp 幫我們在連線失敗後換了個代理和 IP,而不是同一個代理和 IP 反覆重試。所以,理所當然的,如果沒有其他 IP 了,那麼就會停止。比如 DNS 對域名解析後會返回多個 IP。比如有三個 IP,IP1,IP2 和 IP3,第一個連線超時了,會換成第二個;第二個又超時了,換成第三個;第三個還是不給力,那麼請求就結束了

但是 OkHttp 在執行以上策略前,也就是 RouteSelector 內部的策略前,還有一個判斷,就是該 StreamAllocation 的當前 route 是否為空,如果不為空話會繼續使用該 route 而沒有走入到 RouteSelector 的策略中。

4.是否需要進行重定向

我們知道,是否需要進行請求重定向,是根據http請求的響應碼來決定的,因此,在followUpRequest方法中,將會根據響應userResponse,獲取到響應碼,並從連線池StreamAllocation中獲取連線,然後根據當前連線,得到路由配置引數Route。觀察以下程式碼可以看出,這裡通過userResponse得到了響應碼responseCode。 

private Request followUpRequest(Response userResponse) throws IOException {
  if (userResponse == null) throw new IllegalStateException();
  Connection connection = streamAllocation.connection();
  Route route = connection != null
      ? connection.route()
      : null;
  int responseCode = userResponse.code();複製程式碼

接下來將會根據響應碼responseCode來檢查當前是否需要進行請求重定向,我們知道,在Http響應碼中,處於3XX的,都需要進行請求重定向處理。因此,接下來該方法會通過switch…case…來進行不同的響應碼處理操作。
從這段程式碼開始,都是3xx的響應碼處理,這裡就開始進行請求重定向的處理操作。 

case HTTP_PERM_REDIRECT:
case HTTP_TEMP_REDIRECT:
  // "If the 307 or 308 status code is received in response to a request other than GET
  // or HEAD, the user agent MUST NOT automatically redirect the request"
  if (!method.equals("GET") && !method.equals("HEAD")) {
    return null;
  }
  // fall-through
case HTTP_MULT_CHOICE:
case HTTP_MOVED_PERM:
case HTTP_MOVED_TEMP:
case HTTP_SEE_OTHER:複製程式碼

執行重定向請求構建前,首先根據OkHttpClient,執行了followRedirects方法,檢查客戶端是否允許進行重定向請求。如果這時客戶端未允許重定向,則會返回null。
接下來根據響應獲取到位置location,然後根據location,得到重定向的url,程式碼如下所示。 

if (!client.followRedirects()) return null;

String location = userResponse.header("Location");
if (location == null) return null;
HttpUrl url = userResponse.request().url().resolve(location);複製程式碼

接下來這一大段程式碼就是重新構建Request的過程,根據重定向得到的url,重新構建Request請求,並對請求中的header和body分別進行處理,最後可以看到,通過build模式,將新構建的Request請求進行返回。

// Don't follow redirects to unsupported protocols.
if (url == null) return null;

// If configured, don't follow redirects between SSL and non-SSL.
boolean sameScheme = url.scheme().equals(userResponse.request().url().scheme());
if (!sameScheme && !client.followSslRedirects()) return null;

// Most redirects don't include a request body.
Request.Builder requestBuilder = userResponse.request().newBuilder();
if (HttpMethod.permitsRequestBody(method)) {
  final boolean maintainBody = HttpMethod.redirectsWithBody(method);
  if (HttpMethod.redirectsToGet(method)) {
    requestBuilder.method("GET", null);
  } else {
    RequestBody requestBody = maintainBody ? userResponse.request().body() : null;
    requestBuilder.method(method, requestBody);
  }
  if (!maintainBody) {
    requestBuilder.removeHeader("Transfer-Encoding");
    requestBuilder.removeHeader("Content-Length");
    requestBuilder.removeHeader("Content-Type");
  }
}

// When redirecting across hosts, drop all authentication headers. This
// is potentially annoying to the application layer since they have no
// way to retain them.
if (!sameConnection(userResponse, url)) {
  requestBuilder.removeHeader("Authorization");
}

return requestBuilder.url(url).build();複製程式碼

5.不需要重定向,直接返回response,結束。否則到6

執行followUpRequest方法,來檢查是否需要進行重定向操作。當不需要進行重新定向操作時,就會直接返回Response,如下所示

if (followUp == null) {
  if (!forWebSocket) {
    streamAllocation.release();
  }
  return response;
}複製程式碼

6.重定向安全檢查

1.重定向次數<20

if (++followUpCount > MAX_FOLLOW_UPS) {
  streamAllocation.release();
  throw new ProtocolException("Too many follow-up requests: " + followUpCount);
}複製程式碼

2.然後根據重定向請求followUp,與當前的響應進行對比,檢查是否同一個連線。

通常,當發生請求重定向時,url地址將會有所不同,也就是說,請求的資源在這時已經被分配了新的url。因此,接下來的!sameConnection這個判斷將會符合,該這個方法就是用來檢查重定向請求,和當前的請求,是否為同一個連線。一般來說,客戶端進行重定向請求時,需要與新的url建立連線,而原先的連線,則需要進行銷燬。

判斷是否為同一個連線:

private boolean sameConnection(Response response, HttpUrl followUp) {
  HttpUrl url = response.request().url();
  return url.host().equals(followUp.host())
      && url.port() == followUp.port()
      && url.scheme().equals(followUp.scheme());
}複製程式碼

不是同一個連線,則重新建立新連線,並銷燬原來的連線:

if (!sameConnection(response, followUp.url())) {
  streamAllocation.release();
  streamAllocation = new StreamAllocation(
      client.connectionPool(), createAddress(followUp.url()), callStackTrace);
} else if (streamAllocation.codec() != null) {
  throw new IllegalStateException("Closing the body of " + response
      + " didn't close its backing stream. Bad interceptor?");
}複製程式碼


至此,RetryAndFollowUpInterceptor就分析完畢了。


相關文章