這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文地址
在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)+ 標準庫的大量使用允許創造性的將不同組件組合而不用擔心資料。我希望我在這裡的一些探討對你有所協助,正如它們對我有用一樣.