今天來扒一扒Square
公司的IO
流的庫Okio
,現在越來越多Android
項目都在使用Square
公司的網絡開源全家桶,即 Okio + OkHttp + Retrofit
。這三個庫的層級是從下網上來看,Okio
用來處理IO
流,OkHttp
用來實現Http
協議,Retrofit
用來做Android
端的網絡使用接口,關於Retrofit
,之前寫過源碼分析。但是相對於Retrofit
和OkHttp
,Okio
就比較低調,因爲它偏底層,大部分同學對它可能不太熟悉,我們今天就來看看這個幕後英雄吧。
Square
功德無量,著名的JakeWharton
大神之前就一直在這家公司(據說今年7月份離職了)。
一 爲什麼需要Okio
首先我們需要強調Okio
是一個Java
庫,所以它的底層流肯定都是JavaIO
中定義的基礎流。基礎流指的是Java
中對於從不同數據來源抽象的流,比如是FileInputStream ByteInputStream PipedInputStream
等,Okio
只是優化了Java IO
庫中對於基礎流包裝的API
。
1.1 更加簡潔
我們經常說Java
的IO
庫是JDK
設計的比較精妙的一個API
,由於使用了裝飾者模式,大大減少了類的數目。然而即便如此,大家使用時仍然感覺比較麻煩,比如我們想要從一個文件中讀取出來一個int數據,我們至少要創建如下幾個對象:
FileInputStream //用於打開文件流
BufferedInputStream //對FileInputStream做裝飾,添加buffer功能,避免頻繁IO
DataInputStream //用於將字節流轉化成Java基本類型
此時,通過DataInputStream.readInt()
就可以讀出來一個int了。但是機智的你已經發現了,我們需要和至少三個跟輸入相關的類打交道,而Okio把這些都做了集中處理,你可能只需要一個類就可以很方便的進行以上的各種操作了。
1.2 提高性能
首先okio
內部用一個Segment
對象來描述內存數據,Segment
對象中就有byte[]
作爲數據的載體。對於Segment
來說,Okio
不是每次都去創建,而且通過一個對象池來做複用,這樣就可以減少對象創建,銷燬代價,實際上也可以減少byte[]
數組zero-fill
的代價。關於Segment
,我們會在第四章專門進行介紹。
其次,Okio
中多個流之間的數據是可以共享的,而不需要進行內存拷貝。我們舉個栗子,如果我使用Java IO
從一個文件中讀取數據,寫入另外一個文件中,代碼大致如下:
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream(new File("in.file")));
BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(new File("out.file")));
byte[] bytes = new byte[2048];
int count;
while ((count = bufferedInputStream.read(bytes)) != -1) {
bufferedOutputStream.write(bytes,0,count);
}
bufferedOutputStream.flush();
BufferedInputStream
內部有個緩存,每次read
的時候,先看看自己緩存中是否滿足讀取方的需要,如果滿足,直接從內部緩存中做內存copy
到讀取方傳入的byte
數組(步驟一);如果不滿足,調用fill
方法從原始流中讀取數據到內部緩存,重複步驟一。同時,BufferedOutPutStream中
也有個內部緩存,寫入方先把數據寫入到內部緩存,然後再由內部緩存統一寫入到原始輸出IO
,如果使用okio
,應該可以減少兩份數據copy
,第二節我們對兩種方式從使用和性能進行了對比。
1.3 超時看門狗
Java IO
對於數據流的處理是沒有超時概念的,比如從某個流中讀取數據如果輸入流一直沒有數據,那麼當前工作線程就會一直阻塞(當然NIO裏面是異步的,我們這裏不討論)。Okio
中所有關於流的操作都可以設置超時器,用來做超時處理。比如,從一個InputStream
讀取數據,如果3S
沒有響應數據,應用就可以考慮這個數據源頭可能已經發生錯誤了,可以嘗試過段時間再嘗試。
二 流程總述
2.1 來個栗子
爲了更加深入瞭解Okio
的原理,我們把第一節中的栗子用Okio
重新實現一遍。
BufferedSource bufferedSource = Okio.buffer(Okio.source(new File("in.file")));
BufferedSink bufferedSink = Okio.buffer(Okio.sink(new File("out.file")));
bufferedSink.writeAll(bufferedSource);
bufferedSink.flush();
這裏功能還是一樣的,從一個文件讀取數據,寫入另外一個文件。你可以看到,這裏代碼比之前使用JavaIO
庫的代碼簡潔了不少,連我們經常寫的while讀取都省下來了。我能告訴你這段代碼的性能也比上面使用Java IO
的好嗎?我做了一個簡單的測試,讀取一個20M左右的文件,對比JavaIO
和Okio
分別進行2000
次重複操作,打印一下大概的耗時:
COOL!
2.2 流程分析
既然Okio
看起來這麼叼叼的,我們這一小節就來看看它大概流程是怎麼走的。
2.2.1 輸入輸出
對於IO來說,就是兩件事情,輸入和輸出,所謂輸入就是從IO設備(硬盤、網絡、外設等)中讀取數據到內存,輸出就是把內存中的數據輸出到IO設備。
Java中對於IO流的定義有內存流的概念,比如將一個字符串或者一個byte數組作爲輸入數據源,上面定義並沒有涵蓋這個例外。
不管是在JavaIO或者是Okio中輸入輸出都是對稱的,JavaIO中有你熟悉的InputStream和OutputStream,在Okio中對應爲 Source 和 Sink,由於輸入輸出是對稱的,下面我們只聊輸入流就好了。
首先我們看一下InputStream的接口定義:
public abstract int read() throws IOException;
public int read(byte b[]) throws IOException;
我們看到InputStream的接口定義很清晰,read方法要不通過返回值返回數據,要不通過外面傳入的byte數組來帶回數據,數據的來源就是原始流。
相對於InputStream,Source的定義稍微有點繞,
public interface Source extends Closeable {
long read(Buffer sink, long byteCount) throws IOException;
Timeout timeout();
}
你會看到這個地方的read方法的入參是一個Buffer參數,表示從當前Source流中讀出byteCount個字節放到Buffer中(參數名爲sink,代表輸出),那Buffer是什麼?不要着急,我們第四節將會單獨來說,這裏我們先結合一個最簡單的栗子一步步看調用流程吧。
從文件test.file中讀出來內容當成UTF-8編碼的字符串。
BufferedSource bufferedSource = Okio.buffer(Okio.source(new File("test.file")));
String s = bufferedSource.readUtf8();
2.2.2 適配InputStream
Okio.source(new File("test.file")),就可以創建一個Source了,當然這個Source的基礎流肯定是個FileInputStream:
public static Source source(File file) throws FileNotFoundException {
return source(new FileInputStream(file));
}
看起來,所有的邏輯應該是在Okio.source(InputStream in)這個方法中,它就是把一個普通的JavaIO的InputStream適配成Okio的Source的過程(這就是一個典型的適配器模式的應用)。
public static Source source(InputStream in) {
return source(in, new Timeout());
}
我們可以看到這裏出現了Timeout類,這就是我們所說的超時器,我們在第五章會重點介紹,這裏先忽略。
private static Source source(final InputStream in, final Timeout timeout) {
return new Source() {
public long read(Buffer sink, long byteCount) throws IOException {
//將數據讀入Buffer
return bytesRead;
}
@Override public void close() throws IOException {in.close();}
@Override public Timeout timeout() {return timeout;}
@Override public String toString() {return "source(" + in + ")";}
};
}
我們看到source方法的邏輯很簡單,生成一個Source類的內部類,主要邏輯就是代理基礎流的各種方法,read方法就是從基礎流中讀出數據加入到Buffer中。
到此,我們就通過Okio這個適配器,將一個普通的Java InputStream適配成了一個Source類。
裝飾Source
和JavaIO一樣,產生了Source之後,我們可以將它裝飾成BufferedSource。什麼是BufferedSource?就是爲Source提供了一個內部的Buffer作爲數據存儲的地方,你還記得Source接口的read方法嗎?它的入參需要一個Buffer,對於BufferedSource來說,傳入的就是內部自己的Buffer。此外,BufferedSource提供了一堆快捷API,比如readString等。Okio提供了BufferedSource的具體實現RealBufferedSource。
readUtf8()
到此,我們已經得到了一個RealBufferedSource,通過調RealBufferedSource的readUtf8()我們就得到了文件二進制內容,並以UTF-8進行編碼,我們看看readUtf8的實現吧:
public String readUtf8() throws IOException {
buffer.writeAll(source);
return buffer.readUtf8();
}
這裏我們看到,首先從Source讀取數據到Buffer中,然後調用Buffer的readUtf8()。
至此,我們已經分析了上面使用Okio讀取一個文件內容,並進行的整個流程。使用Okio適配InputStream,使用BufferedSource進行裝飾和接口加強,RealBufferedSource真正實現了BufferedSource,內部持有一個Buffer類,讀取數據之前,通過將Source中數據寫入Buffer,然後從Buffer中讀出來數據。
四 Buffer詳解
前面很多地方已經使用了Buffer這個類,但是我們並沒有細說,現在來重點分析一下。這個類是整個Okio中最最核心的結構之一,也是設計的非常巧妙的一個類,所有關於Okio性能提升的地方都是通過這個類實現的。首先我們來看一下這個類的繼承結構:
是不是毀三觀?是不是很驚訝?Buffer居然同時實現了BufferedSink和BufferedSource,即你既可以對它進行數據寫入,也可以從它做數據讀出。仔細想想前面說的,Source是Okio對InputStream的抽象,從Source讀取的數據被放到那裏去了?入參的Buffer!那我要使用這些數據怎麼辦?肯定從Buffer中取啊!所以Buffer設計成可寫可讀理解起來就沒有什麼問題了吧!
4.1 數據隊列
那Buffer是怎麼實現的呢?很簡單,Buffer本質上就是一個雙向列表,每次做數據寫入時都從表尾追加,讀取的時候從表頭進行讀取。表的每一個節點就是一個Segment。
如果你仔細想想就會發現這個設計比原生的JavaIO裏面的BufferedInputStream好在哪裏了,官方的BufferedInputStream內部的緩存對外是不可見的,每次都是先讀取到自己的緩存,然後從緩存中往外copy內存;Okio是先讀取到Buffer中,外面使用的時候,直接把Buffer中的數據共享出來。
我們還以前面的栗子來分析吧,通過Okio.source做InputStream到Source代理的時候,Source的read方法的實現展開如下:
public long read(Buffer sink, long byteCount) throws IOException {
Segment tail = sink.writableSegment(1);
int maxToCopy = (int) Math.min(byteCount, Segment.SIZE - tail.limit);
int bytesRead = in.read(tail.data, tail.limit, maxToCopy);
if (bytesRead == -1) return -1;
tail.limit += bytesRead;
sink.size += bytesRead;
return bytesRead;
}
獲取Buffer寫入位置
Segment tail = sink.writableSegment(1);
獲取可寫入的尾節點,我們看看Buffer是怎麼做的:
Segment writableSegment(int minimumCapacity) {
//如果Buffer還沒有數據,那麼就初始化一個頭
if (head == null) {
head = SegmentPool.take(); // Acquire a first segment.
return head.next = head.prev = head;
}
//拿到最後一個Segment
Segment tail = head.prev;
//如果不夠放,或者不是owner,那麼新加一個Segment放到隊尾
if (tail.limit + minimumCapacity > Segment.SIZE || !tail.owner) {
tail = tail.push(SegmentPool.take());
}
return tail;
}
寫入數據到Buffer
寫入Buffer中,其實是寫入到第一步返回的Segment的byte[]數組中,這裏其實就是使用了JavaIO中InputStream的read(byte[])方法,可以一次讀入多個字節,減少多次IO。
buffer.readUtf8()
從文件讀取的內容已經被放到了Buffer的Segement單鏈表中,我們調用readUtf8()就是簡單的把從表頭開始遍歷Segment,把數據解碼成UTF-8編碼的數據。
五 超時器
Okio提供了超時器的功能,之前我們通過Okio適配JavaIO時,默認都會給我們提供一個Timeout實例,現在我們來重點聊聊。
在Okio中對於超時的定義有兩種:
1、執行時間限制,這個操作需要在指定的時間段內完成,通過timeout方法指定;
2、截至時間限制,這個操作需要在某個時間點之前完成,通過deadLine方法指定。
Okio爲我們提供了兩個默認的超時器實現 Timeout 和 它的子類AsyncTimeout。其中Timeout是同步超時器,也是大部分Okio默認會給JavaIO添加的超時器,比如:
private static Source source(final InputStream in, final Timeout timeout) {
return new Source() {
public long read(Buffer sink, long byteCount) throws IOException {
timeout.throwIfReached(); //判斷是否已經超時
//do some thing
}
};
}
所謂同步,就是在執行操作的線程裏面去判斷當前是否已經超時,其實同步判斷超時是不準的,我們知道使用JavaIO(非NIO)其實大部分操作都是阻塞的,那如果操作在阻塞階段發生了超時,同步肯定就發現不了。這個時候我們就需要異步的超時器AsyncTimeout,針對socket,Okio默認添加了異步超時器(因爲Okio的主要側重點還是在網絡IO方面,所以Socket有福利也很正常)。看看Okio中關於Socket的代碼:
public static Source source(Socket socket) throws IOException {
AsyncTimeout timeout = timeout(socket); //(1)
Source source = source(socket.getInputStream(), timeout); //(2)
return timeout.source(source);//(3)
}
(1) 生成AsyncTimeout
private static AsyncTimeout timeout(final Socket socket) {
return new AsyncTimeout() {
protected IOException newTimeoutException(IOException cause) {
InterruptedIOException ioe = new SocketTimeoutException("timeout");
if (cause != null) {
ioe.initCause(cause);
}
return ioe;
}
@Override protected void timedOut() {
//超時器超時時需要進行的操作
socket.close();
}
};
}
我們看到生成的AsyncTimeout主要邏輯就是處理超時時應該怎麼處理,這裏是直接關閉socket。
(2) 生成Source,這個代碼前面已經分析過了,不再贅述
(3)用AsyncTimeout裝飾一下Source,不僅僅是在JavaIO中大量使用裝飾者,在Okio中同樣大量使用了。
public final Source source(final Source source) {
return new Source() {
@Override public long read(Buffer sink, long byteCount) throws IOException {
//進入工作
enter();
try {
//do some thing
} finally {
//退出工作
exit();
}
}
}
首先在每一次read之前,我們調用enter()方法,將前這個AsyncTimeout加入到一個加入單鏈表中,單鏈表中的AsyncTimeout按照最近到期的順序插入。
後臺有一個看門狗線程,WatchDog,它不斷的從這個單鏈表中讀取首個超時器,取出超時時間,並直接wait阻塞;如果一個超時器時間到了,就會直接調用它的timeout方法,就完成了超時的邏輯了。關於超時邏輯判斷的這塊邏輯有興趣的自己可以查看,還挺有意思的。
需要第一步(1)中複寫的timedOut,這是超時器能夠工作的關鍵。你想想如果工作線程正和socket進行數據交換,此時是block的,異步的看門狗發現這個數據過程超時了,它怎麼通知工作線程呢?這就是爲什麼AsyncTimeout需要持有socket的原因,看門狗發現超時了,它就會直接調用socket.close,(3)中的dosomething就必然會拋出一個異常,因爲底下的流都被關閉了。
總結
Okio作爲Square公司專門爲網絡IO設計的一個流加強庫,極大簡化了各種IO操作的使用。除了上面分析的,Okio還提供了ByteString作爲Byte數組數據向各種格式做轉化的Utils類,同時,作爲網絡中常用的Gzip壓縮、Hash摘要,Okio也方便的提供了流操作。這些都比較簡單,各位看官可以自己去看源碼分析吧。