下篇預告:使用80%的代碼來完成剩下的20%的緩存需求。
目錄
1.當我們在談論緩存的時候,我們在談論什麼?
2.GET網絡請求緩存
i.80%的緩存需求:兩行代碼就可滿足
ii.控制緩存的有效性
iii.文件緩存:借助ETag或Last-Modified判斷文件緩存是否有效
a.Last-Modified
b.ETag
c.總結
iv.一般數據類型借助 Last-Modified 與 ETag 進行緩存
3.剩下20%的網絡緩存需求--真的有NSURLCache 不能滿足的需求?
由於微信、QQ、微博、這類的應用使用緩存很“重”,使一般的用戶也對緩存也非常習慣。緩存已然成為必備。
“緩存的目的的以空間換時間”
這句話在動辄就是 300M、600M 的大應用上,得到了很好的诠釋。但能有緩存意識的公司,還在少數。
“只有你真正感受到痛的時候,你才會考慮使用緩存。”
這個痛可能是:
服務器壓力、客戶端網絡優化、用戶體驗等等。
當我們在談論緩存的時候,我們在談論什麼?
我們今天將站在小白用戶的角度,給緩存這個概念進行重新的定義。
緩存有不同的分類方法:
這裡所指的緩存,是一個寬泛的概念。
我們這裡主要按照功能進行劃分:
GET網絡請求緩存
概述
首先要知道,POST請求不能被緩存,只有 GET 請求能被緩存。因為從數學的角度來講,GET 的結果是 冪等 的,就好像字典裡的 key 與 value 就是冪等的,而 POST 不 冪等 。緩存的思路就是將查詢的參數組成的值作為 key ,對應結果作為value。從這個意義上說,一個文件的資源鏈接,也叫 GET 請求,下文也會這樣看待。
80%的緩存需求:兩行代碼就可滿足
設置緩存只需要三個步驟:
第一個步驟:請使用 GET 請求。
第二個步驟:
如果你已經使用 了 GET 請求,iOS 系統 SDK 已經幫你做好了緩存。你需要的僅僅是設置下內存緩存大小、磁盤緩存大小、以及緩存路徑。甚至這兩行代碼不設置也是可以的,會有一個默認值。代碼如下:
NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:4 * 1024 * 1024 diskCapacity:20 * 1024 * 1024 diskPath:nil]; [NSURLCache setSharedURLCache:urlCache];
第三個步驟:沒有第三步!
你只要設置了這兩行代碼,基本就可滿足80%的緩存需求。AFNetworking 的作者 Mattt曾經說過:
“無數開發者嘗試自己做一個簡陋而脆弱的系統來實現網絡緩存的功能,殊不知 NSURLCache 只要兩行代碼就能搞定且好上 100 倍。”
(AFN 是不是在暗諷 SDWebImage 復雜又蹩腳的緩存機制??)
要注意
iOS 5.0開始,支持磁盤緩存,但僅支持 HTTP。
iOS 6.0開始,支持 HTTPS 緩存。
控制緩存的有效性
我們知道:
只要是緩存,總會過期。
那麼緩存的過期時間如何控制?
上文中的兩行代碼,已經給出了一個方法,指定超時時間。但這並也許不能滿足我們的需求,如果我們對數據的一致性,時效性要求很高,即使1秒鐘後數據更改了,客戶端也必須展示更改後的數據。這種情況如何處理?
下面我們將對這種需求,進行解決方案的介紹。順序是這樣的:先從文件類型的緩存入手,引入兩個概念。然後再談下,一般數據類型比如 JSON 返回值的緩存處理。
文件緩存:借助ETag或Last-Modified判斷文件緩存是否有效。
Last-Modified
服務器的文件存貯,大多采用資源變動後就重新生成一個鏈接的做法。而且如果你的文件存儲采用的是第三方的服務,比如七牛、青雲等服務,則一定是如此。
這種做法雖然是推薦做法,但同時也不排除不同文件使用同一個鏈接。那麼如果服務端的file更改了,本地已經有了緩存。如何更新緩存?
這種情況下需要借助 ETag 或 Last-Modified 判斷圖片緩存是否有效。
Last-Modified 顧名思義,是資源最後修改的時間戳,往往與緩存時間進行對比來判斷緩存是否過期。
在浏覽器第一次請求某一個URL時,服務器端的返回狀態會是200,內容是你請求的資源,同時有一個Last-Modified的屬性標記此文件在服務期端最後被修改的時間,格式類似這樣:
Last-Modified: Fri, 12 May 2006 18:53:33 GMT
客戶端第二次請求此URL時,根據 HTTP 協議的規定,浏覽器會向服務器傳送 If-Modified-Since 報頭,詢問該時間之後文件是否有被修改過:
If-Modified-Since: Fri, 12 May 2006 18:53:33 GMT
總結下來它的結構如下:
如果服務器端的資源沒有變化,則自動返回 HTTP 304 (Not Changed.)狀態碼,內容為空,這樣就節省了傳輸數據量。當服務器端代碼發生改變或者重啟服務器時,則重新發出資源,返回和第一次請求時類似。從而保證不向客戶端重復發出資源,也保證當服務器有變化時,客戶端能夠得到最新的資源。
判斷方法用偽代碼表示:
if ETagFromServer != ETagOnClient || LastModifiedFromServer != LastModifiedOnClient GetFromServer else GetFromCache
之所以使用
LastModifiedFromServer != LastModifiedOnClient
而非使用:
LastModifiedFromServer > LastModifiedOnClient
原因是考慮到可能出現類似下面的情況:服務端可能對資源文件,廢除其新版,回滾啟用舊版本,此時的情況是:
LastModifiedFromServer <= LastModifiedOnClient
但我們依然要更新本地緩存。
參考鏈接:What takes precedence: the ETag or Last-Modified HTTP header?
Demo10和 Demo11 給出了一個完整的校驗步驟:
並給出了 NSURLConnection 和 NSURLSession 兩個版本:
/*! @brief 如果本地緩存資源為最新,則使用使用本地緩存。如果服務器已經更新或本地無緩存則從服務器請求資源。 @details 步驟: 1. 請求是可變的,緩存策略要每次都從服務器加載 2. 每次得到響應後,需要記錄住 LastModified 3. 下次發送請求的同時,將LastModified一起發送給服務器(由服務器比較內容是否發生變化) @return 圖片資源 */ - (void)getData:(GetDataCompletion)completion { NSURL *url = [NSURL URLWithString:kLastModifiedImageURL]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:15.0]; // // 發送 etag // if (self.etag.length > 0) { // [request setValue:self.etag forHTTPHeaderField:@"If-None-Match"]; // } // 發送 LastModified if (self.localLastModified.length > 0) { [request setValue:self.localLastModified forHTTPHeaderField:@"If-Modified-Since"]; } [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // NSLog(@"%@ %tu", response, data.length); // 類型轉換(如果將父類設置給子類,需要強制轉換) NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; NSLog(@"statusCode == %@", @(httpResponse.statusCode)); // 判斷響應的狀態碼是否是 304 Not Modified (更多狀態碼含義解釋: https://github.com/ChenYilong/iOSDevelopmentTips) if (httpResponse.statusCode == 304) { NSLog(@"加載本地緩存圖片"); // 如果是,使用本地緩存 // 根據請求獲取到`被緩存的響應`! NSCachedURLResponse *cacheResponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request]; // 拿到緩存的數據 data = cacheResponse.data; } // 獲取並且紀錄 etag,區分大小寫 // self.etag = httpResponse.allHeaderFields[@"Etag"]; // 獲取並且紀錄 LastModified self.localLastModified = httpResponse.allHeaderFields[@"Last-Modified"]; // NSLog(@"%@", self.etag); NSLog(@"%@", self.localLastModified); dispatch_async(dispatch_get_main_queue(), ^{ !completion ?: completion(data); }); }] resume]; }
ETag
ETag 是什麼?
HTTP 協議規格說明定義ETag為“被請求變量的實體值” (參見 —— 章節 14.19)。 另一種說法是,ETag是一個可以與Web資源關聯的記號(token)。它是一個 hash 值,用作 Request 緩存請求頭,每一個資源文件都對應一個唯一的 ETag 值, 服務器單獨負責判斷記號是什麼及其含義,並在HTTP響應頭中將其傳送到客戶端,以下是服務器端返回的格式:
ETag: "50b1c1d4f775c61:df3"
客戶端的查詢更新格式是這樣的:
If-None-Match: W/"50b1c1d4f775c61:df3"
其中:
If-None-Match - 與響應頭的 Etag 相對應,可以判斷本地緩存數據是否發生變化
如果ETag沒改變,則返回狀態304然後不返回,這也和Last-Modified一樣。
總結下來它的結構如下:
ETag 是的功能與 Last-Modified 類似:服務端不會每次都會返回文件資源。客戶端每次向服務端發送上次服務器返回的ETag 值,服務器會根據客戶端與服務端的 ETag 值是否相等,來決定是否返回 data,同時總是返回對應的 HTTP 狀態碼。客戶端通過 HTTP 狀態碼來決定是否使用緩存。比如:服務端與客戶端的 ETag 值相等,則 HTTP 狀態碼為 304,不返回 data。服務端文件一旦修改,服務端與客戶端的 ETag 值不等,並且狀態值會變為200,同時返回 data。
因為修改資源文件後該值會立即變更。這也決定了 ETag 在斷點下載時非常有用。 比如 AFNetworking 在進行斷點下載時,就是借助它來檢驗數據的。詳見在 AFHTTPRequestOperation 類中的用法:
//下載暫停時提供斷點續傳功能,修改請求的HTTP頭,記錄當前下載的文件位置,下次可以從這個位置開始下載。 - (void)pause { unsigned long long offset = 0; if ([self.outputStream propertyForKey:NSStreamFileCurrentOffsetKey]) { offset = [[self.outputStream propertyForKey:NSStreamFileCurrentOffsetKey] unsignedLongLongValue]; } else { offset = [[self.outputStream propertyForKey:NSStreamDataWrittenToMemoryStreamKey] length]; } NSMutableURLRequest *mutableURLRequest = [self.request mutableCopy]; if ([self.response respondsToSelector:@selector(allHeaderFields)] && [[self.response allHeaderFields] valueForKey:@"ETag"]) { //若請求返回的頭部有ETag,則續傳時要帶上這個ETag, //ETag用於放置文件的唯一標識,比如文件MD5值 //續傳時帶上ETag服務端可以校驗相對上次請求,文件有沒有變化, //若有變化則返回200,回應新文件的全數據,若無變化則返回206續傳。 [mutableURLRequest setValue:[[self.response allHeaderFields] valueForKey:@"ETag"] forHTTPHeaderField:@"If-Range"]; } //給當前request加Range頭部,下次請求帶上頭部,可以從offset位置繼續下載 [mutableURLRequest setValue:[NSString stringWithFormat:@"bytes=%llu-", offset] forHTTPHeaderField:@"Range"]; self.request = mutableURLRequest; [super pause]; }
七牛等第三方文件存儲商現在都已經支持ETag,Demo8和9 中給出的演示圖片就是使用的七牛的服務,見:
static NSString *const kETagImageURL = @"http://ac-g3rossf7.clouddn.com/xc8hxXBbXexA8LpZEHbPQVB.jpg";
下面使用一個 Demo 來進行演示用法,
以 NSURLConnection 搭配 ETag 為例,步驟如下:
請求的緩存策略使用 NSURLRequestReloadIgnoringCacheData,忽略本地緩存
服務器響應結束後,要記錄 Etag,服務器內容和本地緩存對比是否變化的重要依據
在發送請求時,設置 If-None-Match,並且傳入 Etag
連接結束後,要判斷響應頭的狀態碼,如果是 304,說明本地緩存內容沒有發生變化
以下代碼詳見 Demo08 :
/*! @brief 如果本地緩存資源為最新,則使用使用本地緩存。如果服務器已經更新或本地無緩存則從服務器請求資源。 @details 步驟: 1. 請求是可變的,緩存策略要每次都從服務器加載 2. 每次得到響應後,需要記錄住 etag 3. 下次發送請求的同時,將etag一起發送給服務器(由服務器比較內容是否發生變化) @return 圖片資源 */ - (void)getData:(GetDataCompletion)completion { NSURL *url = [NSURL URLWithString:kETagImageURL]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:15.0]; // 發送 etag if (self.etag.length > 0) { [request setValue:self.etag forHTTPHeaderField:@"If-None-Match"]; } [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { // NSLog(@"%@ %tu", response, data.length);dd // 類型轉換(如果將父類設置給子類,需要強制轉換) NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; NSLog(@"statusCode == %@", @(httpResponse.statusCode)); // 判斷響應的狀態碼是否是 304 Not Modified (更多狀態碼含義解釋: https://github.com/ChenYilong/iOSDevelopmentTips) if (httpResponse.statusCode == 304) { NSLog(@"加載本地緩存圖片"); // 如果是,使用本地緩存 // 根據請求獲取到`被緩存的響應`! NSCachedURLResponse *cacheResponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request]; // 拿到緩存的數據 data = cacheResponse.data; } // 獲取並且紀錄 etag,區分大小寫 self.etag = httpResponse.allHeaderFields[@"Etag"]; NSLog(@"etag值%@", self.etag); !completion ?: completion(data); }]; }
相應的 NSURLSession 搭配 ETag 的版本見 Demo09:
/*! @brief 如果本地緩存資源為最新,則使用使用本地緩存。如果服務器已經更新或本地無緩存則從服務器請求資源。 @details 步驟: 1. 請求是可變的,緩存策略要每次都從服務器加載 2. 每次得到響應後,需要記錄住 etag 3. 下次發送請求的同時,將etag一起發送給服務器(由服務器比較內容是否發生變化) @return 圖片資源 */ - (void)getData:(GetDataCompletion)completion { NSURL *url = [NSURL URLWithString:kETagImageURL]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:15.0]; // 發送 etag if (self.etag.length > 0) { [request setValue:self.etag forHTTPHeaderField:@"If-None-Match"]; } [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { // NSLog(@"%@ %tu", response, data.length); // 類型轉換(如果將父類設置給子類,需要強制轉換) NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; NSLog(@"statusCode == %@", @(httpResponse.statusCode)); // 判斷響應的狀態碼是否是 304 Not Modified (更多狀態碼含義解釋: https://github.com/ChenYilong/iOSDevelopmentTips) if (httpResponse.statusCode == 304) { NSLog(@"加載本地緩存圖片"); // 如果是,使用本地緩存 // 根據請求獲取到`被緩存的響應`! NSCachedURLResponse *cacheResponse = [[NSURLCache sharedURLCache] cachedResponseForRequest:request]; // 拿到緩存的數據 data = cacheResponse.data; } // 獲取並且紀錄 etag,區分大小寫 self.etag = httpResponse.allHeaderFields[@"Etag"]; NSLog(@"%@", self.etag); dispatch_async(dispatch_get_main_queue(), ^{ !completion ?: completion(data); }); }] resume]; }
運行效果:
總結
在官方給出的文檔中提出 ETag 是首選的方式,優於 Last-Modified 方式。因為 ETag 是基於 hash ,hash 的規則可以自己設置,而且是基於一致性,是“強校驗”。 Last-Modified 是基於時間,是弱校驗,弱在哪裡?比如說:如果服務端的資源回滾客戶端的 Last-Modified 反而會比服務端還要新。
雖然 ETag 優於 Last-Modified,但並非所有服務端都會支持,而 Last-Modified 則一般都會有該字段。 大多數情況下需要與服務端進行協調支持 ETag,如果協商無果就只能退而求其次。
Demo 也給出了一個不支持 ETag 的鏈接,基本隨便找一張圖片都行:
static NSString *const kLastModifiedImageURL = @"http://image17-c.poco.cn/mypoco/myphoto/20151211/16/17338872420151211164742047.png";
作為通用型的網絡請求工具 AFNetworking 對該現狀的處理方式是,判斷服務端是否包含 ETag ,然後再進行相應處理。可見AFHTTPRequestOperation 類中的用法,也就是上文中已經給出的斷點下載的代碼。
在回顧下思路:
為資源分派 hash 值,然後對比服務端與本地緩存是否一致來決定是否需要更新緩存。
這種思路,在開發中經常使用,比如:處於安全考慮,登陸操作一般不會傳輸賬號密碼,而是傳輸對應的 hash 值-- token ,這裡的 token 就可以看做一個 file 資源,如果想讓一個用戶登陸超時時間是三天,只需要在服務端每隔三天更改下 token 值,客戶端與服務端值不一致,然後服務端返回 token 過期的提示。
值得注意的一點是:
如果借助了 Last-Modified 和 ETag,那麼緩存策略則必須使用 NSURLRequestReloadIgnoringCacheData 策略,忽略緩存,每次都要向服務端進行校驗。
如果 GET 中包含有版本號信息
眾多的應用都會在 GET 請求後加上版本號:
http://abc.com?my_current_version=v1.0.0
這種情況下, ?v1.0 和 ?v2.0 兩個不同版本,請求到的 Last-Modified 和 ETag 會如預期嗎?
這完全取決於公司服務端同事的實現, Last-Modified 和 ETag 僅僅是一個協議,並沒有統一的實現方法,而服務端的處理邏輯完全取決於需求。
你完全可以要求服務端同事,僅僅判斷資源的異同,而忽略掉 ?v1.0 和 ?v2.0 兩個版本的區別。
參考鏈接:if-modified-since vs if-none-match
一般數據類型借助 Last-Modified 與 ETag 進行緩存
以上的討論是基於文件資源,那麼對一般的網絡請求是否也能應用?
控制緩存過期時間,無非兩種:設置一個過期時間;校驗緩存與服務端一致性,只在不一致時才更新。
一般情況下是不會對 api 層面做這種校驗,只在有業務需求時才會考慮做,比如:
數據更新頻率較低,“萬不得已不會更新”---只在服務器有更新時才更新,以此來保證2G 等惡略網絡環境下,有較好的體驗。比如網易新聞欄目,但相反微博列表、新聞列表就不適合。
業務數據一致性要求高,數據更新後需要服務端立刻展示給用戶。客戶端顯示的數據必須是服務端最新的數據。
有離線展示需求,必須實現緩存策略,保證弱網情況下的數據展示的速度。但不考慮使用緩存過期時間來控制緩存的有效性。
盡量減少數據傳輸,節省用戶流量。
一些建議:
如果是 file 文件類型,用 Last-Modified 就夠了。即使 ETag 是首選,但此時兩者效果一致。九成以上的需求,效果都一致。
如果是一般的數據類型--基於查詢的 get 請求,比如返回值是 data 或 string 類型的 json 返回值。那麼 Last-Modified 服務端支持起來就會困難一點。因為比如 你做了一個博客浏覽 app ,查詢最近的10條博客, 基於此時的業務考慮 Last-Modified 指的是10條中任意一個博客的更改。那麼服務端需要在你發出請求後,遍歷下10條數據,得到“10條中是否至少一個被修改了”。而且要保證每一條博客表數據都有一個類似於記錄 Last-Modified 的字段,這顯然不太現實。
如果更新頻率較高,比如最近微博列表、最近新聞列表,這些請求就不適合,更多的處理方式是添加一個接口,客戶端將本地緩存的最後一條數據的的時間戳或 id 傳給服務端,然後服務端會將新增的數據條數返回,沒有新增則返回 nil 或 304。
參考鏈接:《(慕課網)imooc iPhone3.3 接口數據緩存》
剩下20%的網絡緩存需求
真的有NSURLCache 不能滿足的需求?
有人可能要問:
NSURLCache 不是幫我們做了硬盤緩存麼?那我們為什麼要自己用數據庫做本地緩存啊。為啥不直接用NSURLCache 不是更方便?
系統幫我們做的緩存,好處是自動,無需我們進行復雜的設置。壞處也恰恰是這個:不夠靈活,不能自定義。只能指定一個緩存的總文件夾,不能分別指定每一個文件緩存的位置,更不能為每個文件創建一個文件夾,也不能指定文件夾的名稱。緩存的對象也是固定的:只能是 GET請求的返回值。
下一篇文章我們將主要圍繞這一問題展開討論下:使用80%的代碼來完成剩下的20%的緩存需求 。