本系列文章:
OkHttp源碼徹底解析(三)OkHttp3.0攔截器原理——責任鏈模式
目錄
失敗重連以及重定向的攔截器:RetryAndFollowUpInterceptor
攔截器
攔截器是OkHttp中提供一種強大機制,它可以實現網絡監聽、請求以及響應重寫、請求失敗重試等
功能。下面舉一個簡單打印日誌的栗子,此攔截器可以打印出網絡請求以及響應的信息。
有關攔截器的邏輯流程及原理,看OkHttp攔截器原理
OkHttp中的攔截器
我們來看看OkHttp中各個攔截器的順序
這些Interceptor中每一個的職責
失敗重連以及重定向的攔截器:RetryAndFollowUpInterceptor
失敗重連攔截器核心源碼:
一個循環來不停的獲取response。每循環一次都會獲取下一個request,如果沒有,則返回response,退出循環。而獲取下一個request的邏輯,是根據上一個response返回的狀態碼,分別作處理。
橋接攔截器BridgeInterceptor
橋接攔截器的主要作用是將:
-
請求從應用層數據類型類型轉化爲網絡調用層的數據類型。
-
將網絡層返回的數據類型 轉化爲 應用層數據類型。
1. 保存最新的cookie(默認沒有cookie,需要應用程序自己創建,詳見 [Cookie的API] (https://square.github.io/okhttp/3.x/okhttp/okhttp3/CookieJar.html) 和 [Cookie的持久化] (https://segmentfault.com/a/1190000004345545)); 2. 如果request中使用了"gzip"壓縮,則進行Gzip解壓。解壓完畢後移除Header中的"Content-Encoding"和"Content-Length"(因爲Header中的長度對應的是壓縮前數據的長度,解壓後長度變了,所以Header中長度信息實效了); 3. 返回response。
簡單看一下一個正真的http請求都包含了什麼:
可以發現除了請求URL之外,瀏覽器還給你拼接了請求頭信息,所以既然上面所說BridgeInterceptor的第一步是將用戶請求創建真正的Network Request
BridgeInterceptor爲Request設置User-Agent、Cookie、Accept-Encoding等相關請求頭信息。到此爲止一個完整的NetWork Request 就構建完畢,是時候發起真正的網絡請求了
補充:Keep-Alive 連接:
HTTP中的keepalive連接在網絡性能優化中,對於延遲降低與速度提升的有非常重要的作用。
通常我們進行http連接時,首先進行tcp握手,然後傳輸數據,最後釋放
http連接
這種方法的確簡單,但是在複雜的網絡內容中就不夠用了,創建socket需要進行3次握手,而釋放socket需要是4次。重複的連接與釋放tcp連接就像每次僅僅擠1mm的牙膏就合上牙膏蓋子接着再打開接着擠一樣。而每次連接大概是TTL一次的時間(也就是ping一次),在TLS環境下消耗的時間就更多了。很明顯,當訪問複雜網絡時,延時(而不是帶寬)將成爲非常重要的因素。
當然,上面的問題早已經解決了,在http中有一種叫做keepalive connections的機制,它可以在傳輸數據後仍然保持連接,當客戶端需要再次獲取數據時,直接使用剛剛空閒下來的連接而不需要再次握手
Keep-Alive連接
在現代瀏覽器中,一般同時開啓6~8個keepalive connections的socket連接,並保持一定的鏈路生命,當不需要時再關閉;而在服務器中,一般是由軟件根據負載情況決定是否主動關閉。
緩存攔截器CacheInterceptor
緩存攔截器的主要作用是將請求 和 返回 關連得保存到緩存中。客戶端與服務端根據一定的機制,在需要的時候使用緩存的數據作爲網絡請求的響應,節省了時間和帶寬。
客戶端與服務端之間的緩存機制:
- 作用:將HTPTP和HTTPS的網絡返回數據緩存到文件系統中,以便在服務端數據沒發生變化的情況下複用,節省時間和帶寬;
-
工作原理:客戶端發器網絡請求,如果緩存文件中有一份與該請求匹配(URL相同)的完整的返回數據(比如上一次請求返回的結果),那麼客戶端就會發起一個帶條件(例子,服務端第一次返回數據時,在response的Header中添加上次修改的時間信息:Last-Modified: Tue, 12 Jan 2016 09:31:27 GMT;客戶端再次請求的時候,在request的Header中添加這個時間信息:If-Modified-Since: Tue, 12 Jan 2016 09:31:27 GMT)的獲取資源的請求。此時服務端根據客戶端請求的條件,來判斷該請求對應的數據是否有更新。如果需要返回的數據沒有變化,那麼服務端直接返回 304 "not modified"。客戶端如果收到響應碼味304 的信息,則直接使用緩存數據。否則,服務端直接返回更新的數據。具體如下圖所示:
帶緩存的網絡請求流程
客戶端緩存的實現:
OKHttp3的緩存類爲Cache類,它實際上是一層緩存邏輯的包裝類。內部有個專門負責緩存文件讀寫的類:DiskLruCache。於此同時,OKHttp3還定義了一個緩存接口:InternalCache。這個緩存接口類作爲Cache的成員變量其所有的實現,都是調用了Cahce類的函數實現的。它們間具體的關係如下:
緩存的工作機制
InternalCache接口:
public interface InternalCache {
Response get(Request request) throws IOException;
CacheRequest put(Response response) throws IOException;
/**
* 移除request相關的緩存數據
*/
void remove(Request request) throws IOException;
/**
* 用網絡返回的數據,更新緩存中數據
*/
void update(Response cached, Response network);
/** 統計網絡請求的數據 */
void trackConditionalCacheHit();
/** 統計網絡返回的數據 */
void trackResponse(CacheStrategy cacheStrategy);
}
Note:setInternalCache這個方法是不對應用程序開放的,應用程序只能使用cache(Cache cache)這個方法來設置緩存。並且,Cache內部的internalCache是final的,不能被修改。總結:internalCache這個接口雖然是public的,但實際上,應用程序是無法創建它,並附值到OkHttpClient中去的。
那麼問題來了,爲什麼不用Cahce實現InternalCache這個接口,而是以組合的方式,在它的內部實現都調用了Cache的方法呢?
因爲:
- 在Cache中,InternalCache接口的兩個統計方法:trackConditionalCacheHit和trackResponse (之所以要統計,是爲了查看緩存的效率。比如總的請求次數與緩存的命中次數。)需要用內置鎖進行同步。
- Cache中,將trackConditionalCacheHit和trackResponse方法 從public變爲爲privite了。不允許子類重寫,也不開放給應用程序。
Cache類:
-
將網絡請求的文件讀寫操作委託給內部的DiskLruCache類來處理。
-
負責將url轉換爲對應的key。
-
負責數據統計:寫成功計數,寫中斷計數,網絡調用計數,請求數量計數,命中數量計數。
-
負責網絡請求的數據對象Response 與 寫入文件系統中的文本數據 之間的轉換。使用內部類Entry來實現。具體如下:
網絡請求的文件流文本格式如下: { http://google.com/foo GET 2 Accept-Language: fr-CA Accept-Charset: UTF-8 HTTP/1.1 200 OK 3 Content-Type: image/png Content-Length: 100 Cache-Control: max-age=600 ... } 內存對象的Entry格式如下. private static final class Entry { private final String url; private final Headers varyHeaders; private final String requestMethod; private final Protocol protocol; private final int code; private final String message; ... }
通過okio的讀寫API,實現它們之間靈活的切換。
DiskLruCache類:
簡介:一個有限空間的文件緩存。
- 每個緩存數據都有一個string類型的key和一些固定數量的值。
- 緩存的數據保存在文件系統的一個目錄下。這個目錄必須是該緩存獨佔的:因爲緩存運行時會刪除和修改該目錄下的文件,因而該緩存目錄不能被其他線程使用。
- 緩存限制了總的文件大小。如果存儲的大小超過了限制,會以LRU算法來移除一些數據。
- 可以通過edit,update方法來修改緩存數據。每次調用edit,都必須以commit或者abort結束。commit是原子操作。
- 客戶端調用get方法來讀取一個緩存文件的快照(存儲了key,快照序列號,數據源和數據長度)。
緩存使用了日誌文件(文件名爲journal)來存儲緩存的數據目錄和操作記錄。一個典型的日誌文件的文本文檔如下:
//第一行爲緩存的名字
libcore.io.DiskLruCache
1 //緩存的版本
100 //應用版本
2 //值的數量
//緩存記錄:操作 key 第一個數據的長度 第二個數據的長度
CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054
DIRTY 335c4c6028171cfddfbaae1a9c313c52
CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342
REMOVE 335c4c6028171cfddfbaae1a9c313c52
DIRTY 1ab96a171faeeee38496d8b330771a7a
CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234
READ 335c4c6028171cfddfbaae1a9c313c52
READ 3400330d1dfc7f3f7f4b8d4d803dfcf6
操作記錄:狀態+key+額外擦數
- CLEAN key param0 param1:該key的對應的數據爲最新的有效數據,後續爲額外的參數
- DIRTY key:該key對應的數據被創建或被修改。
- REMOVE key:該key對應的數據被刪除。
- READ key:改key對應的數據被讀取的記錄。——用於LRU算法來統計哪些數據是最新的數據。
一些冗餘的操作記錄,比如DIRTY,REMOVE...比較多的時候(大於2000個,或者超過總數量),會發器線程對該日誌文件進行壓縮(刪除這些冗餘的日誌記錄)。此時,會創建一個journal.tmp文件作爲臨時的文件,供緩存繼續使用。同時還有個journal.bkp文件,用作journal文件的臨時備份。
換文文件結構如下:
緩存的文件mu lu
工作原理:
-
每個緩存記錄在內存中的對象封裝爲Entry類
private final class Entry { private final String key; //緩存對應的key private final long[] lengths; //文件的長度 private final File[] cleanFiles; //有效的數據文件 private final File[] dirtyFiles; //正在修改的數據文件 private boolean readable;//是否可讀 private Editor currentEditor;//當前的編輯器。一個Entry一個時候只能被一個編輯器修改。 private long sequenceNumber; //唯一序列號,相當於版本號,當內存中緩存數據發生變化時,該序列號會改變。 ... }
-
創建緩存快照對象,作爲每次讀取緩存時的一個內容快照對象:
public final class Snapshot implements Closeable { private final String key; //緩存的key private final long sequenceNumber; //創建快照的時候的緩存序列號 private final Source[] sources;//數據源,okio的API,可以直接讀取 private final long[] lengths;//數據長度。 ... }
內容快照作爲讀去緩存的對象(而不是將Entry直接返回)的作用:
(1). 內容快照的數據結構方式更便於數據的讀取(將file轉換爲source),並且隱藏了Entry的細節;
(2). 內容快照在創建的時候記錄了當時的Entry的序列號。因而可以用快照的序列號與緩存的序列號對比,如果序列號不相同,則說明緩存數據發生了修改,該條數據就是失效的。
- 緩存內容Entry的這種工作機制(單個editor,帶有序列號的內容快照)以最小的代價,實現了單線程修改,多線程讀寫的數據對象(否則則需要使用複雜的鎖機制)既添降低了邏輯的複雜性,又提高了性能(缺點就是高併發情況下,導致數據頻繁失效,導致緩存的命中率降低)。
- 變化的序列號計數在很多涉及併發讀取的機制都有使用。比如:SQlite的連接。
- DiskLruCache緩存文件工作流:
DiskLruCache緩存工作流
其返回的SnapShot數據快照,提供了Source接口(okio),供外部內類Cache直接轉化爲內存對象Cache.Entry。外部類進一步Canche.Entry轉化外OKHttpClient使用的Response。
OkHttpClient從緩存中獲取一個url對應的緩存數據的數據格式變化過程如下:
緩存數據格式變化過程
LRU的算法體現在:DiskLreCache的日誌操作過程中,每一次讀取緩存都產生一個READ的記錄。由於緩存的初始化是按照日誌文件的操作記錄順序來讀取的,所以這相當於把這條緩存數據放置到了緩存隊列的頂端,也就完成了LRU算法:last recent used,最近使用到的數據最新。
連接攔截器 和 最後的請求服務器的攔截器
這兩個連接器基本上完成了最後發起網絡請求的工作。追所以劃分爲兩個攔截器,除了解耦之外,更重要的是在這兩個流程之間還可以插入一個專門爲WebSocket服務的攔截器( WebSocket一種在單個 TCP 連接上進行全雙工通訊的協議,本文不做詳解)。
總結
1.失敗重連攔截器:
一個循環來不停的獲取response。每循環一次都會獲取下一個request,如果沒有,則返回response,退出循環。而獲取下一個request的邏輯,是根據上一個response返回的狀態碼,分別作處理。
2.橋接攔截器:
請求從應用層數據類型類型轉化爲網絡調用層的數據類型。將網絡層返回的數據類型 轉化爲 應用層數據類型。(補足缺失的請求頭等)
3.緩存攔截器:
CacheInterceptor主要作用是將請求 和 返回 關連得保存到緩存中。客戶端與服務端根據一定的機制,在需要的時候使用緩存的數據作爲網絡請求的響應,節省了時間和帶寬。
4.連接攔截器
與請求服務器的攔截器是網絡交互的關鍵。爲請求服務器攔截器建立可用的連接,創建用於網絡IO流 的RealConnection對象
5.請求服務器的攔截器:
完成了最後發起網絡請求的工作。將HTTP請求寫入網絡IO流,從IO流讀取網絡數據。
與連接攔截器要劃分爲兩個攔截器,除了解耦之外,更重要的是在這兩個流程之間還可以插入一個專門爲WebSocket服務的攔截器( WebSocket一種在單個 TCP 連接上進行全雙工通訊的協議,本文不做詳解)。
自定義攔截器
我們可以通過自定義攔截器做什麼
1.重寫請求
攔截器可以添加、移除或者替換請求頭。甚至在有請求主體時候,可以改變請求主體。舉個栗子,你可以使用application interceptor
添加經過壓縮之後的請求主體,當然,這需要你將要連接的服務端支持處理壓縮數據。
/** This interceptor compresses the HTTP request body. Many webservers can't handle this! */
final class GzipRequestInterceptor implements Interceptor {
@Override public Response intercept(Interceptor.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();
}
};
}
}
2.重寫響應
和重寫請求相似,攔截器可以重寫響應頭並且可以改變它的響應主體。相對於重寫請求而言,重寫響應通常是比較危險的一種做法,因爲這種操作可能會改變服務端所要傳遞的響應內容的意圖。
當然,如果你 比較奸詐 在不得已的情況下,比如不處理的話的客戶端程序接受到此響應的話會Crash等,以及你還可以保證解決重寫響應後可能出現的問題時,重新響應頭是一種非常有效的方式去解決這些導致項目Crash的問題。舉個栗子,你可以修改服務器返回的錯誤的響應頭Cache-Control
信息,去更好地自定義配置響應緩存保存時間。
/** Dangerous interceptor that rewrites the server's cache-control header. */
private static final Interceptor REWRITE_CACHE_CONTROL_INTERCEPTOR = new Interceptor() {
@Override public Response intercept(Interceptor.Chain chain) throws IOException {
Response originalResponse = chain.proceed(chain.request());
return originalResponse.newBuilder()
.header("Cache-Control", "max-age=60")
.build();
}
};
OkHttp是由Square發佈的一個HTTP client,它支持高速緩存服務器響應.
緩存:
如果服務器支持緩存,請求返回的Response會帶有這樣的Header:Cache-Control, max-age=xxx,這種情況下我們只需要手動給okhttp設置緩存就可以讓okhttp自動幫你緩存了。這裏的max-age的值代表了緩存在你本地存放的時間。
OkHttpClient okHttpClient = new OkHttpClient();
OkHttpClient newClient = okHttpClient.newBuilder()
.cache(new Cache(mContext.getCacheDir(), 10240*1024))
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build();
如果服務器不支持緩存就可能沒有指定這個頭部,這種情況下我們就需要使用Interceptor來重寫Respose的頭部信息,從而讓okhttp支持緩存。
Interceptor interceptor = new Interceptor() {
@Override
public Response intercept(Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
String cacheControl = request.cacheControl().toString();
if (TextUtils.isEmpty(cacheControl)) {
cacheControl = "public, max-age=60";
}
return response.newBuilder()
.header("Cache-Control", cacheControl)
.removeHeader("Pragma")
.build();
}
};
如何使用自定義攔截器
class LoggingInterceptor implements Interceptor {
@Override public Response intercept(Interceptor.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;
}
}
上面自定義了一個攔截器
那麼如何添加攔截器呢?首先,我們要知道,攔截器分兩種:Application和Network攔截器
攔截器可以以application
或者network
兩種方式註冊,分別調用addInterceptor()
以及addNetworkInterceptor
方法進行註冊。我們使用上文中日誌攔截器的使用來體現出兩種註冊方式的不同點。
Application interceptors
首先通過調用addInterceptor()
在OkHttpClient.Builder
鏈式代碼中註冊一個application
攔截器:
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new LoggingInterceptor())
.build();
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();
請求的URLhttp://www.publicobject.com/helloworld.txt
被重定向成https://publicobject.com/helloworld.txt
,OkHttp支持自動重定向。注意,我們的application攔截器只會被調用一次,並且調用chain.proceed()
之後獲得到的是重定向之後的最終的響應信息,並不會獲得中間過程的響應信息:
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()
是不一樣的。日誌打印出來的信息顯示兩個不同的URL。客戶端第一次請求執行的url爲http://www.publicobject.com/helloworld.txt
,而響應數據的url爲https://publicobject.com/helloworld.txt
。
Network interceptors
註冊一個Network攔截器和註冊Application攔截器方法是非常相似的。註冊Application攔截器調用的是addInterceptor()
,而註冊Network攔截器調用的是addNetworkInterceptor()
。
OkHttpClient client = new OkHttpClient.Builder()
.addNetworkInterceptor(new LoggingInterceptor())
.build();
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
發出請求,另外一次則是URL被重定向之後客戶端再次向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
NetWork請求包含了更多信息,比如OkHttp爲了減少數據的傳輸時間以及傳輸流量而自動添加的請求頭Accept-Encoding: gzip
希望服務器能返回經過壓縮過的響應數據。Network 攔截器調用Chain
方法後會返回一個非空的Connection
對象,它可以用來查詢客戶端所連接的服務器的IP地址以及TLS配置信息。
選擇使用Application或Network攔截器?
每一個攔截器都有它的優點。
Application interceptors
- 無法操作中間的響應結果,比如當URL重定向發生以及請求重試等,只能操作客戶端主動第一次請求以及最終的響應結果。
- 在任何情況下只會調用一次,即使這個響應來自於緩存。
- 可以監聽觀察這個請求的最原始未經改變的意圖(請求頭,請求體等),無法操作OkHttp爲我們自動添加的額外的請求頭,比如
If-None-Match
。 - 允許
short-circuit (短路)
並且允許不去調用Chain.proceed()
。(編者注:這句話的意思是Chain.proceed()
不需要一定要調用去服務器請求,但是必須還是需要返回Respond實例。那麼實例從哪裏來?答案是緩存。如果本地有緩存,可以從本地緩存中獲取響應實例返回給客戶端。這就是short-circuit (短路)
的意思。。囧) - 允許請求失敗重試以及多次調用
Chain.proceed()
。
Network Interceptors
- 允許操作中間響應,比如當請求操作發生重定向或者重試等。
- 不允許調用緩存來
short-circuit (短路)
這個請求。(編者注:意思就是說不能從緩存池中獲取緩存對象返回給客戶端,必須通過請求服務的方式獲取響應,也就是Chain.proceed()
) - 可以監聽數據的傳輸
- 允許
Connection
對象裝載這個請求對象。(編者注:Connection
是通過Chain.proceed()
獲取的非空對象)