本文為本人原創(chuàng),轉(zhuǎn)載請注明作者和出處。
緩存在面試中經(jīng)常被問到,在這一章我會單獨講解有關(guān)于http緩存以及我們okhttp實現(xiàn)緩存的方方面面。
系列文章索引:
OkHttp源碼學習系列一:總流程和Dispatcher分析
OkHttp源碼學習系列二:攔截鏈分析
OkHttp源碼學習系列三:緩存總結(jié)
零、http緩存規(guī)則
試想一下如果我們使用了緩存,那我們應(yīng)該在何時使用緩存呢,或者說我們?nèi)绾尾拍苤牢覀兊木彺鏇]有過期,可以使用。這就需要我們先學習http的緩存規(guī)則。服務(wù)端和客戶端都必須遵守這套規(guī)則才能夠使得我們的緩存可以在合適的時機被使用。其實這套規(guī)則還是稍微有點復雜的,筆者也是花了不少時間才完全明白這一套規(guī)則是怎么回事。為了全面搞清楚這套流程,我畫了如下這張圖,一起來看下吧:
從客戶端發(fā)起請求開始,客戶端先去緩存文件中根據(jù)url查找是否有緩存,取到緩存的http報文后,首先要做的事是判斷是否過期。判斷過期的標志是根據(jù)報文的兩個頭域,Expires和Cache-Control。
Expires規(guī)定了緩存的報文的過期時間,Cache-Control規(guī)定了緩存的有效期。前者是http1.0的標準,基本已被棄用,后者是http1.1的標準,兩者同時存在的話以Cache-Control為準。Cache-Control設(shè)定過期時間一般是Cache-Control: max-age=3600這樣的形式,3600代表3600秒,即一小時后過期。如果判斷后沒有過期的話,則客戶端直接使用緩存,不會向服務(wù)器發(fā)起請求。
看到這里你會不會有疑問?那這樣緩存策略豈不是很簡單,過期就重新請求,沒過期就直接用,怎么那張圖后面還有那么多流程?是的,如果我們要請求的資源對即時性要求不高,我們確實可以這樣,服務(wù)器規(guī)定個過期時間,過期就重新請求,沒過期就直接使用緩存。但是但是,我們大部分請求都不會使用這種過期策略,即使是對即時性要求不高的。為什么?舉個例子,假如運維小哥在首頁中不小心加入了些不和諧內(nèi)容,如果使用過期策略,那即使運維小哥秒刪,用戶也得等緩存過期后才會重現(xiàn)請求,這樣是不是很囧?
那么又有疑問了,既然絕大多數(shù)請求都不會設(shè)定一個緩存有效期,或者說我們的緩存永遠都是過期的,那緩存還有什么用?其實,上圖中后面的流程也很簡單,就是服務(wù)器會把我們將要使用的緩存與服務(wù)器實時數(shù)據(jù)進行對比,如果服務(wù)器的更新則緩存不可用,需要使用服務(wù)器返回的最新數(shù)據(jù)。假如服務(wù)器對比發(fā)現(xiàn)客戶端的緩存已經(jīng)說最新的了,那么就會返回304響應(yīng)碼,不返回資源內(nèi)容,客戶端接受到304響應(yīng)碼后直接使用緩存。
那么服務(wù)器是如何對比呢?有兩種情況,一種是ETag。服務(wù)器會對資源做一個ETag算法,生成一個字符串。這個字符串實際上代表了資源在服務(wù)器的版本。服務(wù)器在響應(yīng)頭中會加入這個ETag給客戶端。客戶端緩存后下次繼續(xù)請求的時候把緩存中的ETag取出來加入到請求頭。服務(wù)器取出請求頭中的ETag,與當前服務(wù)器該資源實時的ETag作對比。如果不一致則表明服務(wù)器有更新資源,則返回200響應(yīng)碼,并返回資源。如果客戶端的ETag和服務(wù)端的相同,代表服務(wù)端沒有更新數(shù)據(jù),則返回304響應(yīng)碼,并不返回資源。這樣就達到了使用緩存去節(jié)省時間和流量的目的。另一種是Last-Modified,它代表的是資源在服務(wù)器更新的時間,和ETag類似客戶端從緩存中取出這個時間加入到請求頭,服務(wù)器根據(jù)最近的一次更新時間與之對比,決定是返回資源還是返回304。
總結(jié)下,如果服務(wù)器通過Cache-Control頭規(guī)定了過期時間,沒過期的話可以直接使用緩存。否則,使用Etag或者Last-Modified加入請求體,服務(wù)器接收后與最新的數(shù)據(jù)進行比對,決定直接返回最新數(shù)據(jù)還是304響應(yīng)碼。是不是其實一點都不復雜呢?
一、Okhttp的緩存策略的實現(xiàn)
http協(xié)議規(guī)定了緩存的使用規(guī)則,那么Okhttp是如何實現(xiàn)這些規(guī)則的呢?
其實我寫到這塊有點無從起筆了,在上一章講解CacheIntercept的時候已經(jīng)將Okhttp使用緩存的過程講的差不多了,沒有看過的同學可以先翻去看上一章。此時我們可以不著急看源碼,在學習完http緩存規(guī)則后,我們可以思考下,我們的客戶端也就是OkHttpClient在緩存這塊要做哪些事呢?首先,需要取緩存,并確定緩存有無過期。如果過期,看看有沒有Etag或Last-Modified可以加到請求體。最后如果服務(wù)器返回304,我們要直接使用緩存,如果返回200,我們需要更新緩存。按照這個順序,我們來在源碼中找一找,這些操作是如何實現(xiàn)的。
在上一章中,CacheIntercept先取緩存,再通過緩存策略類CacheStrategy獲取處理后的緩存response,一起來回顧下這段代碼:
Response cacheCandidate = cache != null
? cache.get(chain.request())
: null;
long now = System.currentTimeMillis();
CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
Request networkRequest = strategy.networkRequest;
Response cacheResponse = strategy.cacheResponse;
首先從cache對象中根據(jù)request取到緩存的response,然后調(diào)用CacheStrategy的構(gòu)建方法并獲取實例對象,從這個CacheStrategy中獲取處理后的response。現(xiàn)在我們就來看下CacheStrategy到底怎么處理的cacheResponse,先從factory方法開始吧:
public Factory(long nowMillis, Request request, Response cacheResponse) {
this.nowMillis = nowMillis;
this.request = request;
this.cacheResponse = cacheResponse;
if (cacheResponse != null) {
this.sentRequestMillis = cacheResponse.sentRequestAtMillis();
this.receivedResponseMillis = cacheResponse.receivedResponseAtMillis();
Headers headers = cacheResponse.headers();
for (int i = 0, size = headers.size(); i < size; i++) {
String fieldName = headers.name(i);
String value = headers.value(i);
if ("Date".equalsIgnoreCase(fieldName)) {
servedDate = HttpDate.parse(value);
servedDateString = value;
} else if ("Expires".equalsIgnoreCase(fieldName)) {
expires = HttpDate.parse(value);
} else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
lastModified = HttpDate.parse(value);
lastModifiedString = value;
} else if ("ETag".equalsIgnoreCase(fieldName)) {
etag = value;
} else if ("Age".equalsIgnoreCase(fieldName)) {
ageSeconds = HttpHeaders.parseSeconds(value, -1);
}
}
}
}
這個構(gòu)造方法做的事情很簡單,就是記錄下這個緩存的response的請求發(fā)送時間,接收response的時間,以及Expires、Last-Modified、ETag等與緩存有關(guān)的響應(yīng)頭,為后續(xù)的判斷做準備。繼續(xù)來看get方法:
public CacheStrategy get() {
CacheStrategy candidate = getCandidate();
if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
// We're forbidden from using the network and the cache is insufficient.
return new CacheStrategy(null, null);
}
return candidate;
}
這里調(diào)用了一個最為重要的getCandidate()方法,返回的仍然是一個CacheStrategy對象,后面的那個判斷是在Cache-Control響應(yīng)頭為only-if-cached且需要進行網(wǎng)絡(luò)請求的情況,遇到這種情況返回的CacheStrategy對象networkRequest和cacheResponse為空,在CacheIntercept中針對這種情況會返回504錯誤碼。這種情況其實就是服務(wù)器要求使用緩存,而我們沒有緩存或緩存過期了,只能作為錯誤情況處理。在CacheStrategy源碼的時候要記得,networkRequest其實就是傳進來的我們的request,只是如果不需要進行網(wǎng)絡(luò)請求的話CacheStrategy為把它置為null;cacheResponse則是我們的緩存response,只是如果緩存不可用會將它置為null。現(xiàn)在我們來重點看下這個getCandidate()方法,該方法非常的長,我們還是一段一段地看:
if (cacheResponse == null) {
return new CacheStrategy(request, null);
}
// Drop the cached response if it's missing a required handshake.
if (request.isHttps() && cacheResponse.handshake() == null) {
return new CacheStrategy(request, null);
}
if (!isCacheable(cacheResponse, request)) {
return new CacheStrategy(request, null);
}
CacheControl requestCaching = request.cacheControl();
if (requestCaching.noCache() || hasConditions(request)) {
return new CacheStrategy(request, null);
}
這四種情況最終的結(jié)果都一樣,networkRequest不為null,cacheResponse為null,代表我們的緩存不可用,需要請求網(wǎng)絡(luò)。第一種是我們的緩存為空,即我們沒取到緩存;第二種是連接上https,而緩存response沒有握手信息,這時候緩存也用不了;第三種調(diào)用了isCacheable方法,這個方法就不貼了,大致就是判斷響應(yīng)碼和Cache-Control是否符合緩存要求,不符合的緩存也不能使用;最后一種是判斷請求頭中的Cache-Control是否允許緩存,不允許的話也不能使用緩存。
在這里額外說一下,一開始我也對請求頭中的Cache-Control很困惑。后來了解到客戶端請求頭中確實是可以有Cache-Control用于讓客戶端來控制緩存策略。但由于這樣對服務(wù)器來說有安全隱患,因此幾乎很少有服務(wù)器會實現(xiàn)客戶端的緩存策略。
long ageMillis = cacheResponseAge();
long freshMillis = computeFreshnessLifetime();
//略過requestCacheing部分
long maxStaleMillis = 0;
CacheControl responseCaching = cacheResponse.cacheControl();
if (!responseCaching.mustRevalidate() && requestCaching.maxStaleSeconds() != -1) {
maxStaleMillis = SECONDS.toMillis(requestCaching.maxStaleSeconds());
}
if (!responseCaching.noCache() && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
Response.Builder builder = cacheResponse.newBuilder();
if (ageMillis + minFreshMillis >= freshMillis) {
builder.addHeader("Warning", "110 HttpURLConnection \"Response is stale\"");
}
long oneDayMillis = 24 * 60 * 60 * 1000L;
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
builder.addHeader("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
}
return new CacheStrategy(null, builder.build());
}
這一段的話就是計算過期時間的代碼了。這里講下ageMillis就是緩存的已存在的時間(當前時間減去緩存當時的時間),freshMillis就是緩存的過期時間。然后下面的一大段都是requestCaching里的緩存策略就不看了,原因我上面說了很少有服務(wù)器會實現(xiàn)客戶端的緩存策略。然后通過response中的Cache-Control獲取緩存的最大允許過期時間maxStaleMillis。接下來有個計算ageMillis + minFreshMillis < freshMillis + maxStaleMillis,也就是緩存在允許過期的時間范圍內(nèi)。在這種情況緩存是可以使用的,因此后續(xù)return的CacheStrategy的networkRequest為null,cacheResponse不為null,代表我們客戶端直接用緩存,不需要進行網(wǎng)絡(luò)請求。但后續(xù)還有兩個判斷,第一個是在緩存過期的情況(雖然過期但是在允許的過期范圍內(nèi)),加入一個warning響應(yīng)頭;第二個是緩存存在超過一天且是否過期存在模糊不清的情況下,也加一個warning響應(yīng)頭作為提醒。
String conditionName;
String conditionValue;
if (etag != null) {
conditionName = "If-None-Match";
conditionValue = etag;
} else if (lastModified != null) {
conditionName = "If-Modified-Since";
conditionValue = lastModifiedString;
} else if (servedDate != null) {
conditionName = "If-Modified-Since";
conditionValue = servedDateString;
} else {
return new CacheStrategy(request, null);
}
Headers.Builder conditionalRequestHeaders = request.headers().newBuilder();
Internal.instance.addLenient(conditionalRequestHeaders, conditionName, conditionValue);
Request conditionalRequest = request.newBuilder()
.headers(conditionalRequestHeaders.build())
.build();
return new CacheStrategy(conditionalRequest, cacheResponse);
剩下的情況不說大家也能猜到了,即緩存可用,但是需要服務(wù)器根據(jù)ETag或lastModified來比對是否過期。可以看到Etag的優(yōu)先級最高,如果這些判定條件都沒有則緩存不可用。之后把Etag或lastModified加入到請求頭中,讓后續(xù)的網(wǎng)絡(luò)請求發(fā)送給服務(wù)器,讓服務(wù)器來比對該緩存是否可用。
好了到這里,Okhttp判斷緩存是否過期的代碼已經(jīng)講完了,下面選擇性的看下如果服務(wù)器返回304,即我們的緩存可用,客戶端怎么處理的:
// If we have a cache response too, then we're doing a conditional get.
if (cacheResponse != null) {
if (networkResponse.code() == HTTP_NOT_MODIFIED) {
Response response = cacheResponse.newBuilder()
.headers(combine(cacheResponse.headers(), networkResponse.headers()))
.sentRequestAtMillis(networkResponse.sentRequestAtMillis())
.receivedResponseAtMillis(networkResponse.receivedResponseAtMillis())
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build();
networkResponse.body().close();
// Update the cache after combining headers but before stripping the
// Content-Encoding header (as performed by initContentStream()).
cache.trackConditionalCacheHit();
cache.update(cacheResponse, response);
return response;
} else {
closeQuietly(cacheResponse.body());
}
}
首先當然是根據(jù)我們的緩存response封裝成一個最終的response,然后調(diào)用cache類的trackConditionalCacheHit()方法。這個方法是干嘛的呢?我們的緩存大小是有限制的,由于我們底層緩存算法用的Lru算法,即最近最少使用原則更新緩存,我們需要記錄緩存的命中次數(shù),在緩存超出限制后就可以根據(jù)算法清除最近最少使用的緩存。另外如果服務(wù)器沒有返回304,我們當然也會刷新緩存,用新的response替換舊的。
二、其它
Cache類
除了緩存策略類CacheStrategy,還有一個重要的類Cache沒講。這里簡單講下這個類就是負責存取緩存的。它里面存儲緩存的對象是DiskLruCache,而DiskLruCache內(nèi)部又是LinkedHashMap<String, Entry>,關(guān)于LinkedHashMap大家有可以查閱一下相關(guān)文章,它底層是一個哈希表和鏈表的組合,并且由于同時維護了一個可以給存儲對象排序的雙向鏈表,因此可以實現(xiàn)Lru算法。關(guān)于DiskLruCache是如何在內(nèi)存和文件之間來回讀寫的,這里就不展開講解了,也是常考的面試題,有興趣的同學可以自己看看。
無網(wǎng)情況下強制使用緩存
當沒有網(wǎng)絡(luò)的時候,我們無法發(fā)送給服務(wù)器進行比對是否過期。在某些場景,可能會要求我們在無網(wǎng)的時候直接使用緩存,這該怎么做呢?很簡單,我們只要自定義一個攔截器,在我們的請求頭中判斷沒有網(wǎng)絡(luò)可用時,緩存策略為強制使用緩存。
Interceptor interceptor = new Interceptor() {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
if (!context.isNetworkReachable()) {
request = request.newBuilder()
.cacheControl(CacheControl.FORCE_CACHE)
.build();
Log.d("OkHttp", "網(wǎng)絡(luò)不可用請求攔截");
}
Response response = chain.proceed(request);
return response;
}
};
httpClient.interceptors().add(interceptor);
httpClient.networkInterceptors().add(interceptor);
實際上就是當沒有網(wǎng)絡(luò)的時候,在請求頭上加入Cache-Control:only-if-cached,同時還會把允許過期時間改為無限大,這樣就可以強制使用緩存。