在Go語言中處理任何stream數據時,我已經深陷io.Reader和io.Writer的靈活性中不能自拔。同時我在有一點上又或多或少的受了些折磨,挑戰我的reader interface在你看來可能會覺得很簡單:那就是怎麼樣拆分讀操作。
我甚至不知道使用“拆分(split)”這個詞是否正確,我就是想通過io.Reader多次讀取接收到的東西,有時候可能還需要並行操作。但是由於readers不一定會暴露Seek方法重置讀取位置,我需要一個方法來複制它。或者可以算是clone或fork麼?
現狀
假設你有一個web服務允許用戶上傳一個文件。這個服務將會把文件存儲在雲端。但是在存儲前需要對這個文件進行一些簡單的處理。對於接下來的所有請求,你都不得不使用io.Reader去處理。
解決方案
當然,有不止一種方法可以處理這種情況。根據文件的類型,服務的吞吐量,以及文件需要的處理方式的不同有些方式可能比其他的更合適。下面,我給出了5中不同複雜度和靈活性的方法。可以想象還會有更多的方法,但是這幾個會是一個不錯的起點。
Solution #1:簡單的bytes.Reader
如果源reader沒有Seek
方法,爲什麼不自己實現一個呢?你可以把所有的內容都讀取到一個bytes.Reader
中,然後你想分多少次讀取都可以,只要你開心:
func handleUpload(u io.Reader)(err error) {
//capture all bytes from upload
b, err := ioutil.ReadAll(u)
if err != nil {
return err
}
//wrap the bytes in a ReaderSeeker
r := bytes.NewReader(b)
//process the metadata
err = processMetaData(r)
if err != nil {
return err
}
r.Seek(0, 0)
//upload the data
err = uploadFile(r)
if err != nil {
return err
}
return nil
}
如果數據足夠小,這可能是最方便的選擇;你可以完全忘掉bytes.Reader
並使用*byte slice的方式代替工作。但是假如是大文件,如視頻文件或RAW格式的照片等。這些龐然大物將吞噬你的內存,特別是如果服務還具有高流量特徵時。更何況(not to mention)你不能並行執行這些操作。
- 優點:最簡單的方案
- 缺點:同步,無法適應你期望的很多、很大的文件。
Solution #2:可靠的文件系統
OK,那麼將數據放到磁盤中的文件如何(藉助ioutil.TempFile
),並且可以避免將數據存儲在內存中帶來的隱患。
func handleUpload(u io.Reader)(err error) {
//create a temporary file for the upload
f, err := ioutil.TempFile("", "upload")
if err != nil {
return err
}
//destroy the file once done
defer func() {
n := f.name()
f.Close()
os.Remove(n)
}()
//transfer the bytes to the file
_, err := io.Copy(f, u)
if err != nil {
return err
}
//rewind the file
f.Seek(0.0)
//upload the file
err = uploadFile(f)
if err != nil{
return err
}
return nil
}
如果最終是要將文件存儲在service運行的文件系統中,這種方法可能是最好的選擇(儘管會產生一個真實的臨時文件),但是我們假設它最終將落在雲上。繼續,如果這個文件同樣很大,則將產生顯著的,但是不必要的IO。同時,你還將面臨機器上單個文件錯誤或宕機的風險,所以如果你的數據比較敏感,我也不推薦這種方式。
- 優點:避免大量內存佔用保存整個文件
- 缺點:同步,潛在的佔用大量IO、磁盤空間以及數據單點故障
Solution #3:The Duct-Tape io.MultiReader
有些情況下,你需要的metadata存在於文件最開始的幾個字節。例如,識別一個JPEG格式的文件只需要檢查文件的前兩個字節是否是0xFF 0xD8
。這個可以通過使用io.MultiReader
同步處理。io.MultiReader
將一組readers組織起來使他們看起來像一個一樣。如下是我們的JPEG示例:
func handleUpload(u io.Reader)(err error) {
//read in the first 2 bytes
b := make([]byte, 2)
_, err := u.Read(b)
if err != nil {
return err
}
//check that they match the JPEG header
jpg := []byte{0xFF, 0xD8}
if !bytes.Equal(b, jpg) {
return errors.New("not a JPEG")
}
//glue those bytes back onto the reader
r := io.MultiReader(bytes.NewReader(b), u)
//upload the file
err = uploadFile(r)
if err != nil {
return err
}
return nil
}
如果你只打算上傳JPEG文件,這是一個很好的技術。只需要兩個字節,你就可以停止傳輸(注:此處的傳輸不是文件上傳的傳輸,而是將文件拷貝到內存或磁盤進行處理的傳輸過程),而不必將整個文件拷貝到內存或存放到磁盤上。你應該也會發現,有些場景這個方法也並不適用。比如你需要讀取更多的文件內容去收集數據,如通過計算統計單詞個數等。這個過程會阻塞文件上傳,對任務密集型可能也不是理想的處理方式。最後,大多數第三方包(和大部分標準庫)將完整的消耗掉一個reader,以防止你以這種方式使用io.MultiReader
.
另一種方案是使用bufio.Reader.Peek
。本質上它執行相同的操作,但是你可以避開MultiReader
。也就是說,它還可以讓你訪問Reader上的其他的有用的方法。
- 優點:快速且是對文件頭的髒讀,可以作爲文件上傳的門檻。
- 缺點:不適用於不定長讀取,處理整個文件,密集任務,或和很多第三方包一同使用。
Solution #4:The Single-Split io.TeeReader
and io.Pipe
回到前面討論的大視頻文件的情況,我們稍微修改一下故事情節。你的用戶只會上傳單一格式的視頻文件,但是你希望這些視頻文件能夠被你的服務以不同格式播放。比如說,你有一個第三方轉碼器可以將io.Reader
讀取的MP4格式數據轉換成WebM格式的數據輸出。你的服務將會把原始的MP4和轉碼的WebM文件都上傳到雲端。前面的方案必須同步的執行這些操作,現在你想要並行的完成這件事情。
看看io.TeeReader
,它的函數簽名是這樣的:func TeeReader(r Reader, w Writer) Reader
。文檔中是這樣描述的:TeeReader將從Reader r讀取的數據返回一個寫到Writer w的Reader。這個正是你所需要的!現在你怎麼確保寫到w的數據可讀?這個是通過io.Pipe
實現的,它在io.PipeWriter
和io.PipeReader
之間建立了一個連接(即棧,後入先出)。看看代碼是怎麼實現的:
func HandleUpload(u io.Reader) (err error) {
//create the pipe and tee reader
pr, pw := io.Pipe()
tr := io.TeeReader(u, pw)
//Create channels to synchronize
done := make(chan bool)
errs := make(chan error)
defer close(done)
defer close(errs)
go func() {
//close the PipeWriter after the
//TeeReader completes to trigger EOF
defer pw.Close()
//upload the original MP4 data
err := uploadFile(tr)
if err != nil {
errs <- err
return
}
done <- true
}()
go func() {
//transcode to WebM
webmr, err := transcode(pr)
if err != nil {
errs <- err
return
}
//upload to storage
err = uploadFile(webmr)
if err != nil {
errs <- err
return
}
done <- true
}()
//wait until both are done
//or an error occurs
for c := 0; c < 2; {
select {
case err := <-errs:
return err
case <- done:
c++
}
}
return nil
}
因爲uploader將要消費tr
,transcoder在將數據存儲前接收並處理相同的數據。所有的操作不需要額外的buffer,並且並行的執行。注意這裏使用goroutine來執行這兩天路徑。io.Pipe
處於阻塞狀態直到有程序向它寫或從它讀取數據。如果嘗試在同一個線程中執行相同的io.Pipe
,將會得到一個致命錯誤:fatal error;all goroutines are asleep - deadlock
。panic。另一個需要注意的點是:使用Pipe時,你需要在一個合適的時間顯示的觸發一個EOF來關閉io.PipeWriter
。在這個實力中,需要在TeeReader
結束後關閉它。
這個示例同樣採用了channel來進行goroutines之間的“doneness”和error的同步。如果你期望在執行過程中有一些更具體的值返回,你可以使用更合適的類型替換chan bool。
- 優點:完全獨立的,並行的處理相同的數據流
- 缺點,使用goroutines和channel增加了複雜度
Solution #5:The Multi-Split io.MultiWriter
and io.Copy
io.TeeReader
在只有一個其他的流消費者時,能夠非常好的解決問題。由於service可能還需要並行的處理更多的任務(如,轉換成更多的格式),使用tee的疊加將使代碼變得臃腫。看看io.MultiWriter
的解釋:“一個將writes複製並提供給多個writers的writer”。它也像前面的方法一樣使用pipes來傳播數據,不同的是,不是使用io.TeeReader
,而是使用io.Copy
將數據分發到所有的Pipes。示例代碼如下:
func handleUpload(u io.Reader)(err error) {
//create the pipes
mp4R, mp4W := io.Pipe()
webmR, webmW := io.Pipe()
oggR, oggW := io.Pipe()
wavR, wavW := io.Pipe()
//create channels to syschronize
done := make(chan bool)
errs := make(chan error)
defer close(done)
defer close(err)
//spawn all the task goroutines. these looks identical to
//the TeeReader example, but pulled out into separate
//methods for clarity
go uploadMP4(mp4R, done, errs)
go transcodeAndUploadWebM(webmR, done, errs)
go transcodeAndUploadOgg(webmR, done, errs)
go transcodeAndUploadWav(webmR, done, errs)
go func() {
// after completing the copy, we need to close
// the PipeWriters to propagate the EOF to all
// PipeReaders to avoid deadlock
defer mp4W.Close()
defer webmW.Close()
defer oggW.Close()
defer wavW.Close()
//build the multiwriter for all the pipes
mw := io.MultiWriter(mp4W, webmW, oggW, wavW)
//copy the data into the multiwriter
_, err := io.Copy(mw, u)
if err != nil {
errs <- err
}
}()
// wait until all are done
// or an error occurs
for c := 0; c < 4; c++ {
select {
case err := <-errs:
return err
case <-done:
}
}
return nil
}
這個方法和前面的方法有點類似,但是當數據需要被克隆多次時,這種方法明顯的更加簡潔。因爲使用了PIPEs,同樣需要使用goroutines和同步channel,以防止死鎖。我們在copy完成了關閉了所有的pipes。
- 優點:可以根據需要fork多份原始數據
- 缺點:過多的依賴goroutines和channel進行協調。
關於channels?
Channels是Go提供的獨特的,強大的併發工具之一。它是goroutines之間的橋樑,同時兼顧了通信和同步。你可以創建帶buffer和不帶buffer的channel,來實現數據共享。那麼,爲什麼我不提供一個充分利用Channels的解決方案,而不僅僅是用作同步呢?
查閱了一些標準庫的top-level包,發現channels很少出現在函數簽名中:
time
: 用於select timeout- reflect: …cause reflection
- fmt: for formatting it as a pointer
- builtin: expose the
close
function
io.Pipe
的實現中放棄了channel,而使用sync.Mutex
來安全的在reader和writer之間移動數據。我懷疑這是因爲Channel的性能並不好,所以在這裏才被Mutex替代。
當開發一個可重複利用的包的時候,我會像標準庫一樣在我公開的API中避免使用Channels,但是會在內部使用它們用作同步。如果複雜度足夠的低,使用mutex替代channel也許更加理想。這也就是說,在程序開發中,channel是更完美的抽象,比lock更好使用,更加靈活。
拋磚迎玉
我在這裏只是拋出了屈指可數的幾種方法處理從io.Reader
獲取的數據,毫無疑問,肯定還有更多的方法。Go的隱式接口模型(implicit interface model)+ 標準庫的大量使用允許創造性的將不同組件組合而不用擔心數據。我希望我在這裏的一些探討對你有所幫助,正如它們對我有用一樣.