Okhttp缓存浅析

拦截器Interceptors

先来看看Interceptor本身的文档解释:观察,修改以及可能短路的请求输出和响应请求的回来。通常情况下拦截器用来添加,移除或者转换请求或者回应的头部信息。 拦截器接口中有intercept(Chain chain)方法,同时返回Response。这里有一个简单的拦截弹,它记录了即将到来的请求和输入的响应。

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

    long t1 = System.nanoTime();
    logger.info(String.format("Sending request %s on %s%n%s",
        request.url(), chain.connection(), request.headers()));

    Response response = chain.proceed(request);

    long t2 = System.nanoTime();
    logger.info(String.format("Received response for %s in %.1fms%n%s",
        response.request().url(), (t2 - t1) / 1e6d, response.headers()));

    return response;
  }
}

chain.proceed(request)是拦截器的关键部分。这个看似简单的方法是所有的HTTP工作发生的地方,产生满足要求的反应。 拦截器可以链接。假设你有一个压缩的拦截和校验拦截器:你需要决定数据是否被压缩,或者校验或校验然后压缩。okhttp使用列表来跟踪和拦截,拦截器会按顺序调用。

Okhttp缓存浅析

Application Interceptors

拦截器可以注册为应用程序或网络拦截。我们将使用上面定义的logginginterceptor说明差异。 可以在OkHttpClient.interceptors()返回的list中调用add(),来注册一个application interceptor 。

OkHttpClient client = new OkHttpClient();
client.interceptors().add(new LoggingInterceptor());

Request request = new Request.Builder()
    .url("http://www.publicobject.com/helloworld.txt")
    .header("User-Agent", "OkHttp Example")
    .build();

Response response = client.newCall(request).execute();
response.body().close();

URL http://www.publicobject.com/helloworld.txt会重定向到https://publicobject.com/helloworld .txt,OkHttp会自动执行这些重定向。application interceptor执行之后,chain.proceed()返回的response会重定向到 下面的response:

INFO: Sending request http://www.publicobject.com/helloworld.txt on null
    User-Agent: OkHttp Example

    INFO: Received response for https://publicobject.com/helloworld.txt in 1179.7ms
    Server: nginx/1.4.6 (Ubuntu)
    Content-Type: text/plain
    Content-Length: 1759
    Connection: keep-alive

可以看到,url重定向来,因为response.request().url()和request.url()不同,两个不同的日志可以看出这一点。

Network Interceptors

注册一个网络拦截器是很相似的。添加到networkinterceptors()的list来代替interceptors()的list:

OkHttpClient client = new OkHttpClient();
client.networkInterceptors().add(new LoggingInterceptor());

Request request = new Request.Builder()
    .url("http://www.publicobject.com/helloworld.txt")
    .header("User-Agent", "OkHttp Example")
    .build();

Response response = client.newCall(request).execute();
response.body().close();

当我们运行这个代码的时候,拦截程序运行了两次。一次为http://www.publicobject.com/helloworld.txt初始请求, 和另一个重定向到https://publicobject.com/helloworld.txt。

INFO: Sending request http://www.publicobject.com/helloworld.txt on Connection{www.publicobject.com:80, proxy=DIRECT hostAddress=54.187.32.157 cipherSuite=none protocol=http/1.1}
User-Agent: OkHttp Example
Host: www.publicobject.com
Connection: Keep-Alive
Accept-Encoding: gzip

INFO: Received response for http://www.publicobject.com/helloworld.txt in 115.6ms
Server: nginx/1.4.6 (Ubuntu)
Content-Type: text/html
Content-Length: 193
Connection: keep-alive
Location: https://publicobject.com/helloworld.txt

INFO: Sending request https://publicobject.com/helloworld.txt on Connection{publicobject.com:443, proxy=DIRECT hostAddress=54.187.32.157 cipherSuite=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA protocol=http/1.1}
User-Agent: OkHttp Example
Host: publicobject.com
Connection: Keep-Alive
Accept-Encoding: gzip

INFO: Received response for https://publicobject.com/helloworld.txt in 80.9ms
Server: nginx/1.4.6 (Ubuntu)
Content-Type: text/plain
Content-Length: 1759
Connection: keep-alive

网络要求还包含更多的数据,如Accept-Encoding: gzip头来支持压缩。网络拦截的Chain有一个非空Connection可以用来询问IP地址和用于连接到Web服务器的TLS配置。

application和network interceptors的选择

每一个拦截链都有相对的优点。

  • Application interceptors

1.不必担心中间的responses,例如重定向和重连。2.总是调用一次,即使是从缓存HTTP响应。3.观察应用程序的原始意图。不关心OkHttp的注入headers,例如If-None-Match 4.允许短路和不执行Chain.proceed(). 5.允许重连,多次调用proceed()。

  • Network Interceptors 1.能够操作中间反应,例如重定向和重连。2.不能被缓存响应,例如短路网络调用。3.观察数据,正如它将在网络上传输。4.有权使用携带request的Connection

重写Requests

拦截器可以添加,删除,或替换请求报头。他们还可以将这些请求的body转换。例如,你可以使用一个应用程序拦截来增加request body压缩, 如果你连接的服务器支持这种操作的话。

/** This interceptor compresses the HTTP request body. Many webservers can't handle this! */
final class GzipRequestInterceptor implements Interceptor {
  @Override public Response intercept(Chain chain) throws IOException {
    Request originalRequest = chain.request();
    if (originalRequest.body() == null || originalRequest.header("Content-Encoding") != null) {
      return chain.proceed(originalRequest);
    }

    Request compressedRequest = originalRequest.newBuilder()
        .header("Content-Encoding", "gzip")
        .method(originalRequest.method(), gzip(originalRequest.body()))
        .build();
    return chain.proceed(compressedRequest);
  }

  private RequestBody gzip(final RequestBody body) {
    return new RequestBody() {
      @Override public MediaType contentType() {
        return body.contentType();
      }

      @Override public long contentLength() {
        return -1; // We don't know the compressed length in advance!
      }

      @Override public void writeTo(BufferedSink sink) throws IOException {
        BufferedSink gzipSink = Okio.buffer(new GzipSink(sink));
        body.writeTo(gzipSink);
        gzipSink.close();
      }
    };
  }
}

重写Responses

对应的,拦截器可以重写response headers和转换response body。这是一般比重写请求标头更危险因为它可能违反了服务器的期望! 如果你是在一个棘手的情况,并准备处理的后果,重写response headers是一个强大的方式来解决问题。例如,你可以将服务器的错误配置的缓存控制 响应头修改以便更好地响应缓存:

/** Dangerous interceptor that rewrites the server's cache-control header. */
    private static final Interceptor REWRITE_CACHE_CONTROL_INTERCEPTOR = new Interceptor() {
      @Override public Response intercept(Chain chain) throws IOException {
        Response originalResponse = chain.proceed(chain.request());
        return originalResponse.newBuilder()
            .header("Cache-Control", "max-age=60")
            .build();
      }
    };

作为补充一个网络服务器上的相应的修复,通常这种方法效果最好。

  • 在某些情况下,如用户单击“刷新”按钮,就可能有必要跳过缓存,并直接从服务器获取数据。要强制刷新,添加无缓存指令:"Cache-Control": "no-cache"。
  • 如果缓存只是用来和服务器做验证,可是设置更有效的"Cache-Control":"max-age=0"。
  • 有时你会想显示可以立即显示的资源。这是可以使用的,这样你的应用程序可以在等待最新的数据下载的时候显示一些东西, 重定向request到本地缓存资源,添加"Cache-Control":"only-if-cached"。
  • 有时候过期的response比没有response更好,设置最长过期时间来允许过期的response响应:int maxStale = 60 * 60 * 24 * 28; // tolerate 4-weeks stale

"Cache-Control":"max-stale=" + maxStale。

可用性

okhttp拦截器需要okhttp 2.2或更高。不幸的是,拦截器在OkUrlFactory和基于OkUrlFactory的库上不工作,或图书馆的基础上的,包括 Retrofit ≤1.8和Picasso≤2.4。

缓存配置

开启缓存可以通过如下代码

OkHttpClient okHttpClient = new OkHttpClient();
    if(mSetCache)
                setCache(okHttpClient);
    .......

    private static void setCache(OkHttpClient okHttpClient) {
            File cacheDirectory = new File(LSApp.getApplication().getExternalCacheDir(), "HttpCache");

            okHttpClient.networkInterceptors().add(REWRITE_CACHE_CONTROL_INTERCEPTOR);
            Cache cache = new Cache(cacheDirectory, SIZE_OF_CACHE);

            try {
                okHttpClient.setCache(cache);
            } catch (Exception e) {

            }
        }

上面设置了缓存路径,在项目包的目录下面的HttpCache文件夹中,然后设置了名为REWRITE_CACHE_CONTROL_INTERCEPTOR的拦截器。 然后实例化Cache,最后调用okHttpClient.setCache(cache);进入setCache一探究竟。

public OkHttpClient setCache(Cache cache) {
        this.cache = cache;
        this.internalCache = null;
        return this;
      }

可以看到讲我们的配置的cache保存在了OkHttpClient对象中.

下面去看我们要分析的核心类Cache:

public final class Cache {
  private static final int VERSION = 201105;
  private static final int ENTRY_METADATA = 0;
  private static final int ENTRY_BODY = 1;
  private static final int ENTRY_COUNT = 2;
  ....
  }

首先Cache里面有四个常量,第一个可以看出是版本号,后三个的作用后面会介绍。

接下来定义了一个对象private final DiskLruCache cache;这个是装饰模式里面的被装饰的对象,也是Cache里面的核心对象。Cache的作用只是对DiskLruCache的装饰,而DiskLruCache里面有最核心、最原始、最基本的接口或抽象类的实现。

接下来重点分析最核心的一个方法:get方法。

Response get(Request request) {
    String key = urlToKey(request);
    DiskLruCache.Snapshot snapshot;
    Entry entry;
    try {
      snapshot = cache.get(key);
      if (snapshot == null) {
        return null;
      }
    } catch (IOException e) {
      // Give up because the cache cannot be read.
      return null;
    }

    try {
      entry = new Entry(snapshot.getSource(ENTRY_METADATA));
    } catch (IOException e) {
      Util.closeQuietly(snapshot);
      return null;
    }

    Response response = entry.response(request, snapshot);

    if (!entry.matches(request, response)) {
      Util.closeQuietly(response.body());
      return null;
    }

    return response;
  }

  private static String urlToKey(Request request) {
      return Util.md5Hex(request.urlString());
    }

可以看出,这个方法传入的是request,返回了Response。说明这个就是取出缓存的核心方法。

第一步调用了urlToKey取得request的URL进行MD5加密,然后作为request的唯一标示key。 接着声明snapshot和entry,它们都是用来保存Response的。然后 snapshot = cache.get(key);取出返回内容。 进入DiskLruCache的get看源码:

public synchronized Snapshot get(String key) throws IOException {
    initialize();

    checkNotClosed();
    validateKey(key);
    Entry entry = lruEntries.get(key);
    if (entry == null || !entry.readable) return null;

    Snapshot snapshot = entry.snapshot();
    if (snapshot == null) return null;

    redundantOpCount++;
    journalWriter.writeUtf8(READ).writeByte(' ').writeUtf8(key).writeByte('\n');
    if (journalRebuildRequired()) {
      executor.execute(cleanupRunnable);
    }

    return snapshot;
  }

首先调用初始化方法 initialize();

void initialize() throws IOException {
    assert Thread.holdsLock(this);

    if (initialized) {
      return; // Already initialized.
    }

    // If a bkp file exists, use it instead.
    if (fileSystem.exists(journalFileBackup)) {
      // If journal file also exists just delete backup file.
      if (fileSystem.exists(journalFile)) {
        fileSystem.delete(journalFileBackup);
      } else {
        fileSystem.rename(journalFileBackup, journalFile);
      }
    }

    // Prefer to pick up where we left off.
    if (fileSystem.exists(journalFile)) {
      try {
        readJournal();
        processJournal();
        initialized = true;
        return;
      } catch (IOException journalIsCorrupt) {
        Platform.get().logW("DiskLruCache " + directory + " is corrupt: "
            + journalIsCorrupt.getMessage() + ", removing");
        delete();
        closed = false;
      }
    }

    rebuildJournal();

    initialized = true;
  }

可以看到里面主要在处理journalFile这个文件,这个journalFile是什么呢。如果我们进入缓存目录就能发现问题。

Okhttp缓存浅析

发现缓存文件全是以url的md5加密字段为文件名,每一个response分两个文件保存,以.0和.1结尾的文件区分。 进去看里面的内容如下:.0的文件里面是header:

http://58.210.161.178:8088/lngiot-api/v1/mobile/device/9B80F22B51C64E488C09D37990063D06/module/1727/history?start=1441443443645&end=1441529843645&expectPoint=200%20
GET
0
HTTP/1.1 200 OK
8
Server: Apache-Coyote/1.1
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Sun, 06 Sep 2015 08:56:44 GMT
OkHttp-Selected-Protocol: http/1.1
OkHttp-Sent-Millis: 1441529843654
OkHttp-Received-Millis: 1441529844035
Cache-Control: max-age=864000000, only-if-cached, max-stale=0

而.1文件里面是返回的具体内容,即json数据。

而文件夹最后还有一个journal.文件,这个里面是什么呢。

libcore.io.DiskLruCache
1
201105
2

DIRTY 8c1fab929dcb34407d05366415626994
REMOVE 8c1fab929dcb34407d05366415626994
DIRTY e51a1dad8e596e1844109d27b73ff551
CLEAN e51a1dad8e596e1844109d27b73ff551 2327 80
DIRTY c693ba810f44727d37d2edc11eb76e76
CLEAN c693ba810f44727d37d2edc11eb76e76 420 2919
DIRTY c693ba810f44727d37d2edc11eb76e76
CLEAN c693ba810f44727d37d2edc11eb76e76 420 2919
DIRTY a350c5ffc000d8140cdc6e1f6ec88799
CLEAN a350c5ffc000d8140cdc6e1f6ec88799 407 672
DIRTY 9ca0cc1c6e8190f5d2ec6108dd6b3822
CLEAN 9ca0cc1c6e8190f5d2ec6108dd6b3822 409 2356
DIRTY 7bbe640ea6195fe53960b5393d9d88d3
CLEAN 7bbe640ea6195fe53960b5393d9d88d3 492 13439
DIRTY 8c1fab929dcb34407d05366415626994
CLEAN 8c1fab929dcb34407d05366415626994 388 199
READ c693ba810f44727d37d2edc11eb76e76
READ c693ba810f44727d37d2edc11eb76e76
DIRTY e39dad184a3de2a623f6587f6605e754
CLEAN e39dad184a3de2a623f6587f6605e754 407 565

可以看到里面保存的是每一条reponse记录状态。包括读取,删除,写入等动作。

因此刚才initialize方法就是在读取这个文件,截取前面的动作,去掉remove动作的文件,其他的文件名加入到内存中保存。 然后就是根据key取得内容了Entry entry = lruEntries.get(key);而lruEntries是一个map对象,以url的md5形式作为key: private final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<>(0, 0.75f, true);

再回到Cache中的snapshot = cache.get(key);这行,现在已经取得了snapshot,接着entry = new Entry(snapshot.getSource(ENTRY_METADATA));然后Response response = entry.response(request, snapshot);这样就取得了response。

注意snapshot.getSource(ENTRY_METADATA),还记得最前面声明的三个变量吗?ENTRY_METADATA的值是0,对应保存header的.0文件, 而ENTRY_BODY的值为1,对应.1文件。snapshot.getSource()返回的是source对象,Source继承了Closeable,是一个自定义输出流。

这样就简单分析了取出缓存的过程。

手动取缓存

上面分析了源码中怎么取缓存的方法。默认会通过拦截器设置来自动取缓存,但是如果我们想自己取出缓存,可以通过下面的方法。

public static FilterInputStream getFromCache(String url) throws Exception {
        File cacheDirectory = new File("/storage/emulated/0/Android/data/com.name.demo
        .dev/cache/HttpCache");
        DiskLruCache cache = DiskLruCache.create(FileSystem.SYSTEM, cacheDirectory,
                201105, 2, SIZE_OF_CACHE);
        cache.flush();
        String key = Util.md5Hex(url);
        final DiskLruCache.Snapshot snapshot;
        try {
            snapshot = cache.get(key);
            if (snapshot == null) {
                return null;
            }
        } catch (IOException e) {
            return null;
        }
        okio.Source source = snapshot.getSource(1) ;
        BufferedSource metadata = Okio.buffer(source);
        FilterInputStream bodyIn = new FilterInputStream(metadata.inputStream()) {
            @Override
            public void close() throws IOException {
                snapshot.close();
                super.close();
            }
        };
        return bodyIn ;
    }

注意这里要实例化的是DiskLruCache,而不是Cache,因为Cache本身是没有开放的接口操作缓存的。DiskLruCache的参数和配置缓存时的参数必须相同。前面是通过 Cache cache = new Cache(cacheDirectory, SIZE_OF_CACHE);配置的缓存。

看看Cache源码中的构造就能明白为什么这里要这么写:

public Cache(File directory, long maxSize) {
    cache = DiskLruCache.create(FileSystem.SYSTEM, directory, VERSION, ENTRY_COUNT, maxSize);
  }

Cache在构造中创建了DiskLruCache实例,这里照着这个写就行了。 后面也是参照源码中的get方法取出内容:先取得snapshot,然后 snapshot.getSource(1) ;源码中是snapshot.getSource(0)。 因为我们要取得的是.1文件中的内容,所以是getSource(1)。之后就是流的转换问题了。

调用方法:

String path = "http://XX.XXXX.XXX.XX:8088/XXX-api" + request.getPath() ;
                Scanner sc = null;
                try {
                    sc = new Scanner(getFromCache(path));
                } catch (Exception e) {
                    e.printStackTrace();
                }
                StringBuilder str= new StringBuilder();
                String s;
                while(sc.hasNext() && (s=sc.nextLine())!=null) {
                    str.append(s);
                }

这样就能手动取出缓存中的内容了。