OkHttp離線緩存實現

場景應用:

OkHttp內部已經支持了標準的Http協議緩存策略,如Last-Modified, Etag, Cache-Control等方式,看起來已是很是夠用了,可是咱們還想讓緩存在這樣的場景下獲得使用: 當客戶端設備網絡中斷或者服務端出現了錯誤,可是本地存在緩存副本的時候,咱們依然想取用這部分緩存。html

OkHttp保存緩存的時機:

okhttp3.internal.http.HttpEngine:
private void maybeCache() throws IOException {
    InternalCache responseCache = Internal.instance.internalCache(client);
    if (responseCache == null) return;

    // Should we cache this response for this request?
    if (!CacheStrategy.isCacheable(userResponse, networkRequest)) {
      if (HttpMethod.invalidatesCache(networkRequest.method())) {
        try {
          responseCache.remove(networkRequest);
        } catch (IOException ignored) {
          // The cache cannot be written.
        }
      }
      return;
    }

    // Offer this request to the cache.
    storeRequest = responseCache.put(stripBody(userResponse));
}

這裏咱們能夠看到OkHttp是否緩存請求結果其中的一個條件:CacheStrategy.isCacheable(), 看看這裏面的代碼:java

okhttp3.internal.http.CacheStrategy:
public static boolean isCacheable(Response response, Request request) {
    // Always go to network for uncacheable response codes (RFC 7231 section 6.1),
    // This implementation doesn't support caching partial content.
    switch (response.code()) {
      case HTTP_OK:
      case HTTP_NOT_AUTHORITATIVE:
      case HTTP_NO_CONTENT:
      case HTTP_MULT_CHOICE:
      case HTTP_MOVED_PERM:
      case HTTP_NOT_FOUND:
      case HTTP_BAD_METHOD:
      case HTTP_GONE:
      case HTTP_REQ_TOO_LONG:
      case HTTP_NOT_IMPLEMENTED:
      case StatusLine.HTTP_PERM_REDIRECT:
        // These codes can be cached unless headers forbid it.
        break;

      case HTTP_MOVED_TEMP:
      case StatusLine.HTTP_TEMP_REDIRECT:
        // These codes can only be cached with the right response headers.
        // http://tools.ietf.org/html/rfc7234#section-3
        // s-maxage is not checked because OkHttp is a private cache that should ignore s-maxage.
        if (response.header("Expires") != null
            || response.cacheControl().maxAgeSeconds() != -1
            || response.cacheControl().isPublic()
            || response.cacheControl().isPrivate()) {
          break;
        }
        // Fall-through.

      default:
        // All other codes cannot be cached.
        return false;
    }

    // A 'no-store' directive on request or response prevents the response from being cached.
    return !response.cacheControl().noStore() && !request.cacheControl().noStore();
}

咱們接着回到maybeCache()方法往下看,最後一句responseCache.put(stripBody(userResponse))將會調用下面的方法:git

okhttp3.Cache:
private CacheRequest put(Response response) throws IOException {
    String requestMethod = response.request().method();

    if (HttpMethod.invalidatesCache(response.request().method())) {
      try {
        remove(response.request());
      } catch (IOException ignored) {
        // The cache cannot be written.
      }
      return null;
    }
    if (!requestMethod.equals("GET")) {
      // Don't cache non-GET responses. We're technically allowed to cache
      // HEAD requests and some POST requests, but the complexity of doing
      // so is high and the benefit is low.
      return null;
    }

    if (OkHeaders.hasVaryAll(response)) {
      return null;
    }

    Entry entry = new Entry(response);
    DiskLruCache.Editor editor = null;
    try {
      editor = cache.edit(urlToKey(response.request()));
      if (editor == null) {
        return null;
      }
      //緩存寫入文件:
      entry.writeTo(editor);
      return new CacheRequestImpl(editor);
    } catch (IOException e) {
      abortQuietly(editor);
      return null;
    }
}

總的來講就是隻要是Get請求,同時請求和響應頭部CacheControl沒有設置no store就會把請求結果緩存了。github

OkHttp保存緩存的格式:

url所對應的緩存文件名規則,不難看出是url的md5值。web

Util.md5Hex(request.url().toString())

保存的代碼很明顯就在entry.writeTo裏面啦。緩存

okhttp3.Cache.Entry:
public void writeTo(DiskLruCache.Editor editor) throws IOException {
  //.0結尾的文件
  BufferedSink sink = Okio.buffer(editor.newSink(ENTRY_METADATA));

  sink.writeUtf8(url);
  sink.writeByte('\n');
  sink.writeUtf8(requestMethod);
  sink.writeByte('\n');
  sink.writeDecimalLong(varyHeaders.size());
  sink.writeByte('\n');
  for (int i = 0, size = varyHeaders.size(); i < size; i++) {
	sink.writeUtf8(varyHeaders.name(i));
	sink.writeUtf8(": ");
	sink.writeUtf8(varyHeaders.value(i));
	sink.writeByte('\n');
  }

  sink.writeUtf8(new StatusLine(protocol, code, message).toString());
  sink.writeByte('\n');
  sink.writeDecimalLong(responseHeaders.size());
  sink.writeByte('\n');
  for (int i = 0, size = responseHeaders.size(); i < size; i++) {
	sink.writeUtf8(responseHeaders.name(i));
	sink.writeUtf8(": ");
	sink.writeUtf8(responseHeaders.value(i));
	sink.writeByte('\n');
  }

  if (isHttps()) {
	sink.writeByte('\n');
	sink.writeUtf8(handshake.cipherSuite().javaName());
	sink.writeByte('\n');
	writeCertList(sink, handshake.peerCertificates());
	writeCertList(sink, handshake.localCertificates());
	// The handshake’s TLS version is null on HttpsURLConnection and on older cached responses.
	if (handshake.tlsVersion() != null) {
	  sink.writeUtf8(handshake.tlsVersion().javaName());
	  sink.writeByte('\n');
	}
  }
  sink.close();
}

咱們打開相應的緩存目錄看一下,果真其中包含了以url的md5值做爲文件名的一系列文件,還有一個journal文件,這個文件和DiskLruCache緩存有關,咱們這裏不展開分析。咱們看一下.0和.1文件的內容吧,其實.0裏面的東西從上面的代碼已經能夠知道就是和請求和響應頭部的一些信息。tomcat

http://www.infoq.com/cn/articles/etags
GET
2
Accept-Encoding: gzip
User-Agent: okhttp/3.2.0
HTTP/1.1 200 OK
15
Date: Sun, 11 Dec 2016 14:50:36 GMT
Server: Apache
Sniply-Options: BLOCK
Set-Cookie: JSESSIONID=7FFBDE344A66525EE343DCCC6DD23692; Path=/
Last-Modified: Sun, 11 Dec 2016 14:50:36 GMT
Vary: Accept-Encoding,User-Agent
Access-Control-Allow-Credentials: true
Accept-Ranges: none
Access-Control-Allow-Origin: http://www.infoq.com
Content-Encoding: gzip
Connection: close
Transfer-Encoding: chunked
Content-Type: text/html;charset=utf-8
OkHttp-Sent-Millis: 1481467841870
OkHttp-Received-Millis: 1481467844412

md5(url).1文件很容易猜到就是response自己的內容了。讀者能夠自行驗證,另外若是response是gzip的格式,那麼這裏緩存的直接就是gzip後的內容了。網絡

OkHttp離線緩存實現:

經過以上的分析,咱們的思路就很清晰了,其實就是一開始就判斷是否鏈接網絡,若是是否就直接返回緩存的內容,根據response的頭部是不是Content-Encoding: gzip來肯定是否須要解壓後返回;若是請求服務端失敗,那麼也返回緩存的內容。app

經過分析OkHttp的InterceptorChain實現:less

okhttp3.RealCall.ApplicationInterceptorChain:

public Response proceed(Request request) throws IOException {
      // If there's another interceptor in the chain, call that.
      if (index < client.interceptors().size()) {
        Interceptor.Chain chain = new ApplicationInterceptorChain(index + 1, request, forWebSocket);
        Interceptor interceptor = client.interceptors().get(index);
        Response interceptedResponse = interceptor.intercept(chain);

        if (interceptedResponse == null) {
          throw new NullPointerException("application interceptor " + interceptor
              + " returned null");
        }

        return interceptedResponse;
      }

      // No more interceptors. Do HTTP.
      return getResponse(request, forWebSocket);
}

不難發現只要經過一個Interceptor返回response便可中斷http後續的請求動做,若是服務端錯誤,response將會返回相應的錯誤碼,總結最後的實現代碼以下:

final class CacheControlInterceptor implements Interceptor {

    private final String mCacheDir;

    private final ILog mLog;

    CacheControlInterceptor(ILog log, String cacheDir){
        this.mCacheDir = cacheDir;
        this.mLog = log;
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request();
        boolean networkAvail = NetUtils.isNetworkAvailable();
        if (!networkAvail){
            Response cacheResponse = getCacheResponse(request);
            if(cacheResponse != null){
                return cacheResponse;
            }
        }
        Response networkResponse = chain.proceed(request);
        if(networkAvail && !networkResponse.isSuccessful()){
            Response cacheResponse = getCacheResponse(request);
            if(cacheResponse != null){
                return cacheResponse;
            }
        }
        return networkResponse;
    }

    private Response getCacheResponse(Request request) throws FileNotFoundException{

        if(!"get".equalsIgnoreCase(request.method())|| mCacheDir == null){
            return null;
        }

        String urlMd5 = Util.md5Hex(request.url().url().toString());
        File headerCacheFile = new File(new File(mCacheDir), urlMd5 + ".0");
        Response.Builder cacheResponseBuilder = null;
        if (headerCacheFile.exists()) {
            cacheResponseBuilder = new Response.Builder();
            cacheResponseBuilder.request(request);
            readCacheResponseHeaders(headerCacheFile,cacheResponseBuilder);
        }

        if(cacheResponseBuilder != null){
            Response cacheResponse = cacheResponseBuilder.build();
            File bodyCacheFile = new File(new File(mCacheDir), urlMd5 + ".1");
            Source cacheSource;
            if(!"gzip".equalsIgnoreCase(cacheResponse.header("Content-Encoding"))){
                cacheSource = Okio.source(bodyCacheFile);
            }else{
                cacheSource = new GzipSource(Okio.source(bodyCacheFile));
            }
            RealResponseBody responseBody = new RealResponseBody(cacheResponse.headers(),Okio.buffer(cacheSource));
            return cacheResponse.newBuilder().body(responseBody).build();
        }

        return null;
    }

    private void readCacheResponseHeaders(File headerCacheFile,Response.Builder responseBuilder) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new InputStreamReader(new FileInputStream(headerCacheFile)));
            String lineText;
            int code = 0;
            boolean isHeaderBegin = false;
            while((lineText = reader.readLine()) != null){

                if(isHeaderBegin){
                    int sepIndex = lineText.indexOf(":");
                    String headerName = lineText.substring(0,sepIndex);
                    String headerVal = lineText.substring(sepIndex + 1).trim();
                    responseBuilder.addHeader(headerName,headerVal);
                }

                if(code != 0 && NumberUtils.parseInt(lineText,0) != 0){
                    isHeaderBegin = true;
                    continue;
                }

                if(lineText.startsWith("HTTP/1.1")){
                    code = Integer.valueOf(lineText.substring(lineText.indexOf(" ")).trim());
                    responseBuilder.protocol(Protocol.HTTP_1_1);
                    responseBuilder.code(code);
                }
            }
        } catch (IOException ex) {
            ex.printStackTrace();
        } finally {
            IOUtils.closeSilently(reader);
        }
    }
}

參考資料

  1. http緩存: https://developers.google.cn/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=zh-cn
  2. tomcat中對客戶端的緩存機制: http://blog.csdn.net/liweisnake/article/details/8524179
  3. Okhttp Interceptors: https://github.com/square/okhttp/wiki/Interceptors
相關文章
相關標籤/搜索