本文由玉剛說寫作平臺提供寫作贊助
贊助金額:200元
原作者:竹千代
版權聲明:本文版權歸微信公眾號玉剛說所有,未經許可,不得以任何形式轉載
Http是我們經常打交道的網絡應用層協議,它的重要性可能不需要再強調。但是實際上很多人,包括我自己可能對http了解的並不夠深。本文就我自己的學習心得,分享一下我認為需要知道的緩存所涉及到的相關知識點。
Http報文首先我們來點基礎的,看看http報文具體的格式。http報文可以分為請求報文和響應報文,格式大同小異。主要分為三個部分:
起始行
首部
主體
請求報文格式:
<method> <request-url> <version>
<headers>
<entity-body>
響應報文格式
<version> <status> <reason-phrase>
<headers>
<entity-body>
從請求報文格式和響應報文格式可以看出,兩者主要在起始行上有差異。這裡稍微解釋一下各個標籤:
<method> 指請求方法,常用的主要是Get、 Post、Head 還有其他一些我們這裡就不說了,有興趣的可以自己查閱一下
<version> 指協議版本,現在通常都是Http/1.1了
<request-url> 請求地址
<status> 指響應狀態碼, 我們熟悉的200、404等等
<reason-phrase> 原因短語,200 OK 、404 Not Found 這種後面的描述就是原因短語,通常不必太關注。
我們知道請求方法最常用的有Get 和Post兩種,面試時也常常會問到這兩者有什麼區別,通常什麼情況下使用。這裡我們來簡單說一說。
兩個方法之間在傳輸形式上有一些區別,通過Get方法發起請求時,會將請求參數拼接在request-url尾部,格式是url?param1=xxx¶m2=xxx&[…]。
我們需要知道,這樣傳輸參數會使得參數都暴露在地址欄中。並且由於url是ASCII編碼的,所以參數中如果有Unicode編碼的字符,例如漢字,都會編碼之後傳輸。另外值得注意的是,雖然http協議並沒有對url長度做限制,但是一些瀏覽器和伺服器可能會有限制,所以通過GET方法發起的請求參數不能夠太長。而通過POST方法發起的請求是將參數放在請求體中的,所以不會有GET參數的這些問題。
另外一點差別就是方法本身的語義上的。GET方法通常是指從伺服器獲取某個URL資源,其行為可以看作是一個讀操作,對同一個URL進行多次GET並不會對伺服器產生什麼影響。而POST方法通常是對某個URL進行添加、修改,例如一個表單提交,通常會往伺服器插入一條記錄。多次POST請求可能導致伺服器的資料庫中添加了多條記錄。所以從語義上來講,兩者也是不能混為一談的。
狀態碼常見的狀態碼主要有
200 OK 請求成功,實體包含請求的資源
301 Moved Permanent 請求的URL被移除了,通常會在Location首部中包含新的URL用於重定向。
304 Not Modified 條件請求進行再驗證,資源未改變。
404 Not Found 資源不存在
206 Partial Content 成功執行一個部分請求。這個在用於斷點續傳時會涉及到。
在請求報文和響應報文中都可以攜帶一些信息,通過與其他部分配合,能夠實現各種強大的功能。這些信息位於起始行之下與請求實體之間,以鍵值對的形式,稱之為首部。每條首部以回車換行符結尾,最後一個首部額外多一個換行,與實體分隔開。
這裡我們重點關注一下
Date
Cache-Control
Last-Modified
Etag
Expires
If-Modified-Since
If-None-Match
If-Unmodified-Since
If-Range
If-Match
Http的首部還有很多,但限於篇幅我們不一一討論。這些首部都是Http緩存會涉及到的,在下文中我們會來說說各自的作用。
實體請求發送的資源,或是響應返回的資源。
Http緩存當我們發起一個http請求後,伺服器返回所請求的資源,這時我們可以將該資源的副本存儲在本地,這樣當再次對該url資源發起請求時,我們能快速的從本地存儲設備中獲取到該url資源,這就是所謂的緩存。緩存既可以節約不必要的網絡帶寬,又能迅速對http請求做出響應。
先擺出幾個概念:
新鮮度檢測
再驗證
再驗證命中
我們知道,有些url所對應的資源並不是一成不變的,伺服器中該url的資源可能在一定時間之後會被修改。這時本地緩存中的資源將與伺服器一側的資源有差異。
既然在一定時間之後可能資源會改變,那麼在某個時間之前我們可以認為這個資源沒有改變,從而放心大膽的使用緩存資源,當請求時間超過來該時間,我們認為這個緩存資源可能不再與伺服器端一致了。所以當我們發起一個請求時,我們需要先對緩存的資源進行判斷,看看究竟我們是否可以直接使用該緩存資源,這個就叫做新鮮度檢測。即每個資源就像一個食品一樣,擁有一個過期時間,我們吃之前需要先看看有沒有過期。
如果發現該緩存資源已經超過了一定的時間,我們再次發起請求時不會直接將緩存資源返回,而是先去伺服器查看該資源是否已經改變,這個就叫做再驗證。如果伺服器發現對應的url資源並沒有發生變化,則會返回304 Not Modified,並且不再返回對應的實體。這稱之為再驗證命中。相反如果再驗證未命中,則返回200OK,並將改變後的url資源返回,此時緩存可以更新以待之後請求。
我們看看具體的實現方式:
新鮮度檢測
我們需要通過檢測資源是否超過一定的時間,來判斷緩存資源是否新鮮可用。那麼這個一定的時間怎麼決定呢?其實是由伺服器通過在響應報文中增加Cache-Control:max-age,或是Expire這兩個首部來實現的。值得注意的是Cache-Control是http1.1的協議規範,通常是接相對的時間,即多少秒以後,需要結合last-modified這個首部計算出絕對時間。而Expire是http1.0的規範,後面接一個絕對時間。
再驗證
如果通過新鮮度檢測發現需要請求伺服器進行再驗證,那麼我們至少需要告訴伺服器,我們已經緩存了一個什麼樣的資源了,然後伺服器來判斷這個緩存資源到底是不是與當前的資源一致。邏輯是這樣沒錯。那怎麼告訴伺服器我當前已經有一個備用的緩存資源了呢?我們可以採用一種稱之為條件請求的方式實現再驗證。
Http定義了5個首部用於條件請求:
If-Modified-Since
If-None-Match
If-Unmodified-Since
If-Range
If-Match
If-Modified-Since 可以結合Last-Modified這個伺服器返回的響應首部使用,當我們發起條件請求時,將Last-Modified首部的值作為If-Modified-Since首部的值傳遞到伺服器,意思是查詢伺服器的資源自從我們上一次緩存之後是否有修改。
If-None-Match 需要結合另一個Etag的伺服器返回的響應首部使用。Etag首部實際上可以認為是伺服器對文檔資源定義的一個版本號。有時候一個文檔被修改了,可能所做的修改極為微小,並不需要所有的緩存都重新下載數據。或者說某一個文檔的修改周期極為頻繁,以至於以秒為時間粒度的判斷已經無法滿足需求。這個時候可能就需要Etag這個首部來表明這個文檔的版號了。發起條件請求時可將緩存時保存下來的Etag的值作為If-None-Match首部的值發送至伺服器,如果伺服器的資源的Etag與當前條件請求的Etag一致,表明這次再驗證命中。
其他三個與斷點續傳涉及到的相關知識有關,本文暫時不討論。待我之後寫一篇文章來講講斷點續傳。
OkHttp的緩存緩存的Http理論知識大致就是這麼些。我們從OkHttp的源碼來看看,這些知名的開源庫是如何利用Http協議實現緩存的。這裡我們假設讀者對OkHttp的請求執行流程有了大致的了解,並且只討論緩存相關的部分。對於OkHttp代碼不熟悉的同學,建議先看看相關代碼或是其他文章。
我們知道OkHttp的請求在發送到伺服器之前會經過一系列的Interceptor,其中有一個CacheInterceptor即是我們需要分析的代碼。
final InternalCache cache;
@Override public Response intercept(Chain chain) throws IOException {
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;
.
}
方法首先通過InternalCache 獲取到對應請求的緩存。這裡我們不展開討論這個類的具體實現,只需要知道,如果之前緩存了該請求url的資源,那麼通過request對象可以查找到這個緩存響應。
將獲取到的緩存響應,當前時間戳和請求傳入CacheStrategy,然後通過執行get方法執行一些邏輯最終可以獲取到strategy.networkRequest,strategy.cacheResponse。如果通過CacheStrategy的判斷之後,我們發現這次請求無法直接使用緩存數據,需要向伺服器發起請求,那麼我們就通過CacheStrategy為我們構造的networkRequest來發起這次請求。我們先來看看CacheStrategy做了哪些事情。
CacheStrategy.Factory.java
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);
}
}
}
}
CacheStrategy.Factory的構造方法首先保存了傳入的參數,並將緩存響應的相關首部解析保存下來。之後調用的get方法如下
public CacheStrategy get() {
CacheStrategy candidate = getCandidate();
if (candidate.networkRequest != null && request.cacheControl().onlyIfCached()) {
return new CacheStrategy(null, null);
}
return candidate;
}
get方法很簡單,主要邏輯在getCandidate中,這裡的邏輯是如果返回的candidate所持有的networkRequest不為空,表示我們這次請求需要發到伺服器,此時如果請求的cacheControl要求本次請求只使用緩存數據。那麼這次請求恐怕只能以失敗告終了,這點我們等會兒回到CacheInterceptor中可以看到。接著我們看看主要getCandidate的主要邏輯。
private CacheStrategy getCandidate() {
if (cacheResponse == null) {
return new CacheStrategy(request, null);
}
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);
}
.
}
上面這段代碼主要列出四種情況下需要忽略緩存,直接想伺服器發起請求的情況:
緩存本身不存在
請求是採用https 並且緩存沒有進行握手的數據。
緩存本身不應該不保存下來。可能是緩存本身實現有問題,把一些不應該緩存的數據保留了下來。
如果請求本身添加了 Cache-Control: No-Cache,或是一些條件請求首部,說明請求不希望使用緩存數據。
這些情況下直接構造一個包含networkRequest,但是cacheResponse為空的CacheStrategy對象返回。
private CacheStrategy getCandidate() {
.
CacheControl responseCaching = cacheResponse.cacheControl();
if (responseCaching.immutable()) {
return new CacheStrategy(null, cacheResponse);
}
long ageMillis = cacheResponseAge();
long freshMillis = computeFreshnessLifetime();
if (requestCaching.maxAgeSeconds() != -1) {
freshMillis = Math.min(freshMillis, SECONDS.toMillis(requestCaching.maxAgeSeconds()));
}
long minFreshMillis = 0;
if (requestCaching.minFreshSeconds() != -1) {
minFreshMillis = SECONDS.toMillis(requestCaching.minFreshSeconds());
}
long maxStaleMillis = 0;
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());
}
.
}
如果緩存響應的Cache-Control首部包含immutable,那麼說明該資源不會改變。客戶端可以直接使用緩存結果。值得注意的是immutable並不屬於http協議的一部分,而是由facebook提出的擴展屬性。
之後分別計算ageMills、freshMills、minFreshMills、maxStaleMills這四個值。
如果響應緩存沒有通過Cache-Control:No-Cache 來禁止客戶端使用緩存,並且
ageMillis + minFreshMillis < freshMillis + maxStaleMillis
這個不等式成立,那麼我們進入條件代碼塊之後最終會返回networkRequest為空,並且使用當前緩存值構造的CacheStrtegy。
這個不等式究竟是什麼含義呢?我們看看這四個值分別代表什麼。
ageMills 指這個緩存資源自響應報文在源伺服器中產生或者過期驗證的那一刻起,到現在為止所經過的時間。用食品的保質期來比喻的話,好比當前時間距離生產日期已經過去了多久了。
freshMills 表示這個資源在多少時間內是新鮮的。也就是假設保質期18個月,那麼這個18個月就是freshMills。
minFreshMills 表示我希望這個緩存至少在多久之後依然是新鮮的。好比我是一個比較講究的人,如果某個食品只有一個月就過期了,雖然並沒有真的過期,但我依然覺得食品不新鮮從而不想再吃了。
maxStaleMills 好比我是一個不那麼講究的人,即使食品已經過期了,只要不是過期很久了,比如2個月,那我覺得問題不大,還可以吃。
minFreshMills 和maxStatleMills都是由請求首部取出的,請求可以根據自己的需要,通過設置
Cache-Control:min-fresh=xxx、Cache-Control:max-statle=xxx
來控制緩存,以達到對緩存使用嚴格性的收緊與放鬆。
private CacheStrategy getCandidate() {
.
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);
}
如果之前的條件不滿足,說明我們的緩存響應已經過期了,這時我們需要通過一個條件請求對伺服器進行再驗證操作。接下來的代碼比較清晰來,就是通過從緩存響應中取出的Last-Modified,Etag,Date首部構造一個條件請求並返回。
接下來我們返回CacheInterceptor
if (networkRequest == null && cacheResponse == null) {
return new Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(504)
.message("Unsatisfiable Request (only-if-cached)")
.body(Util.EMPTY_RESPONSE)
.sentRequestAtMillis(-1L)
.receivedResponseAtMillis(System.currentTimeMillis())
.build();
}
可以看到,如果我們返回的networkRequest和cacheResponse都為空,說明我們即沒有可用的緩存,同時請求通過Cache-Control:only-if-cached只允許我們使用當前的緩存數據。這個時候我們只能返回一個504的響應。接著往下看,
if (networkRequest == null) {
return cacheResponse.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.build();
}
如果networkRequest為空,說明我們不需要進行再驗證了,直接將cacheResponse作為請求結果返回。
Response networkResponse = null;
try {
networkResponse = chain.proceed(networkRequest);
} finally {
if (networkResponse == null && cacheCandidate != null) {
closeQuietly(cacheCandidate.body());
}
}
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();
cache.trackConditionalCacheHit();
cache.update(cacheResponse, response);
return response;
} else {
closeQuietly(cacheResponse.body());
}
}
Response response = networkResponse.newBuilder()
.cacheResponse(stripBody(cacheResponse))
.networkResponse(stripBody(networkResponse))
.build();
if (cache != null) {
if (HttpHeaders.hasBody(response) && CacheStrategy.isCacheable(response, networkRequest)) {
CacheRequest cacheRequest = cache.put(response);
return cacheWritingResponse(cacheRequest, response);
}
if (HttpMethod.invalidatesCache(networkRequest.method())) {
try {
cache.remove(networkRequest);
} catch (IOException ignored) {
}
}
}
return response;
如果networkRequest存在不為空,說明這次請求是需要發到伺服器的。此時有兩種情況,一種cacheResponse不存在,說明我們沒有一個可用的緩存,這次請求只是一個普通的請求。如果cacheResponse存在,說明我們有一個可能過期了的緩存,此時networkRequest是一個用來進行再驗證的條件請求。
不管哪種情況,我們都需要通過networkResponse=chain.proceed(networkRequest)獲取到伺服器的一個響應。不同的只是如果有緩存數據,那麼在獲取到再驗證的響應之後,需要cache.update(cacheResponse, response)去更新當前緩存中的數據。如果沒有緩存數據,那麼判斷此次請求是否可以被緩存。在滿足緩存的條件下,將響應緩存下來,並返回。
OkHttp緩存大致的流程就是這樣,我們從中看出,整個流程是遵循了Http的緩存流程的。最後我們總結一下緩存的流程:
從接收到的請求中,解析出Url和各個首部。
查詢本地是否有緩存副本可以使用。
如果有緩存,則進行新鮮度檢測,如果緩存足夠新鮮,則使用緩存作為響應返回,如果不夠新鮮了,則構造條件請求,發往伺服器再驗證。如果沒有緩存,就直接將請求發往伺服器。
把從伺服器返回的響應,更新或是新增到緩存中。
OAuthOAuth是一個用於授權第三方獲取相應資源的協議。與以往的授權方式不同的是,OAuth的授權能避免用戶暴露自己的用戶密碼給第三方,從而更加的安全。OAuth協議通過設置一個授權層,以區分用戶和第三方應用。用戶本身可以通過用戶密碼登陸服務提供商,獲取到帳戶所有的資源。而第三方應用只能通過向用戶請求授權,獲取到一個Access Token,用以登陸授權層,從而在指定時間內獲取到用戶授權訪問的部分資源。
OAuth定義的幾個角色:
RoleDescriptionResource Owner可以授權訪問某些受保護資源的實體,通常就是指用戶Client可以通過用戶的授權訪問受保護資源的應用,也就是第三方應用Authorization server在認證用戶之後給第三方下發Access Token的伺服器Resource Server擁有受保護資源的伺服器,可以通過Access Token響應資源請求 +---+ ++
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | ++
| |
| | ++
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D) Access Token --| |
| | ++
| |
| | ++
| |--(E) Access Token ->| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+---+ ++
從上圖可以看出,一個OAuth授權的流程主要可以分為6步:
客戶端向用戶申請授權。
用戶同意授權。
客戶端通過獲取的授權,向認證伺服器申請Access Token。
認證伺服器通過授權認證後,下發Access Token。
客戶端通過獲取的到Access Token向資源伺服器發起請求。
資源伺服器核對Access Token後下發請求資源。
Https簡單的說 Http + 加密 + 認證 + 完整性保護 = Https
傳統的Http協議是一種應用層的傳輸協議,Http直接與TCP協議通信。其本身存在一些缺點:
Http協議使用明文傳輸,容易遭到竊聽。
Http對於通信雙方都沒有進行身份驗證,通信的雙方無法確認對方是否是偽裝的客戶端或者服務端。
Http對於傳輸內容的完整性沒有確認的辦法,往往容易在傳輸過程中被劫持篡改。
因此,在一些需要保證安全性的場景下,比如涉及到銀行帳戶的請求時,Http無法抵禦這些攻擊。
Https則可以通過增加的SSL\TLS,支持對於通信內容的加密,以及對通信雙方的身份進行驗證。
近代密碼學中加密的方式主要有兩類:
對稱秘鑰加密
非對稱秘鑰加密
對稱秘鑰加密是指加密與解密過程使用同一把秘鑰。這種方式的優點是處理速度快,但是如何安全的從一方將秘鑰傳遞到通信的另一方是一個問題。
非對稱秘鑰加密是指加密與解密使用兩把不同的秘鑰。這兩把秘鑰,一把叫公開秘鑰,可以隨意對外公開。一把叫私有秘鑰,只用於本身持有。得到公開秘鑰的客戶端可以使用公開秘鑰對傳輸內容進行加密,而只有私有秘鑰持有者本身可以對公開秘鑰加密的內容進行解密。這種方式克服了秘鑰交換的問題,但是相對於對稱秘鑰加密的方式,處理速度較慢。
SSL\TLS的加密方式則是結合了兩種加密方式的優點。首先採用非對稱秘鑰加密,將一個對稱秘鑰使用公開秘鑰加密後傳輸到對方。對方使用私有秘鑰解密,得到傳輸的對稱秘鑰。之後雙方再使用對稱秘鑰進行通信。這樣即解決了對稱秘鑰加密的秘鑰傳輸問題,又利用了對稱秘鑰的高效率來進行通信內容的加密與解密。
Https的認證SSL\TLS採用的混合加密的方式還是存在一個問題,即怎麼樣確保用於加密的公開秘鑰確實是所期望的伺服器所分發的呢?也許在收到公開秘鑰時,這個公開秘鑰已經被別人篡改了。因此,我們還需要對這個秘鑰進行認證的能力,以確保我們通信的對方是我們所期望的對象。
目前的做法是使用由數字證書認證機構頒發的公開秘鑰證書。伺服器的運營人員可以向認證機構提出公開秘鑰申請。認證機構在審核之後,會將公開秘鑰與共鑰證書綁定。伺服器就可以將這個共鑰證書下發給客戶端,客戶端在收到證書後,使用認證機構的公開秘鑰進行驗證。一旦驗證成功,即可知道這個秘鑰是可以信任的秘鑰。
總結
Https的通信流程:
Client發起請求
Server端響應請求,並在之後將證書發送至Client
Client使用認證機構的共鑰認證證書,並從證書中取出Server端共鑰。
Client使用共鑰加密一個隨機秘鑰,並傳到Server
Server使用私鑰解密出隨機秘鑰
通信雙方使用隨機秘鑰最為對稱秘鑰進行加密解密。