這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。2017 年 12 月 30 日2018 年 1 月 1 日:[更新](http://kgrz.io/reading-files-in-go-an-overview.html#update)(譯註:在文章末尾)---當我開始學習 Go 的時候,我很難熟練得運用各種操作檔案的 API。在我嘗試寫一個多核心的計數器([kgrz/kwc](https://github.com/kgrz/kwc))時讓我感到了困惑 - 操作同一個檔案的不同方法。在今年的 [Advent of Code](http://adventofcode.com/2017/) 中遇到了一些需要多種讀取輸入源的方式的問題。最終我把每種方法都至少使用了一次,因此現在我對這些技術有了一個清晰的認識。我會在這篇文章中將這些記錄下來。我會按照我遇到這些技術的順序列出來,而不是按照從易到難的順序。* 按位元組讀取 * 將整個檔案讀入記憶體中* 分批讀取檔案* 並行分批讀取檔案* 掃描* 按單詞掃描* 將一個長字串分割成多個單詞* 掃描用逗號分割的字串* Ruby 風格* 讀取整個檔案* 讀取目錄下的所有檔案* 更多協助方法* 更新## 一些基本的假設* 所有的代碼都包裹在 `main()` 代碼塊內* 大部分情況下我會使用 "array" 和 "slice" 來指代 slices,但它們的含義是不同的。[這](https://blog.golang.org/go-slices-usage-and-internals)[兩](https://blog.golang.org/slices)篇文章很好得解釋了兩者的不同之處。* 我會把所有的範例程式碼上傳到 [kgrz/reading-files-in-go](https://github.com/kgrz/reading-files-in-go)。在 Go 中 - 對於這個問題,大部分的低級語言和一些類似於 Node 的動態語言 - 會返回位元組流。之所以不自動返回字串是因為可以避免昂貴的會增加記憶體回收行程的壓力的字串分配操作。為了讓這篇文章更加通俗易懂,我會使用 `string(arrayOfBytes)` 來將 `位元組` 數組轉化為字串,但不建議在生產模式中使用這種方式。## 按位元組讀取*將整個檔案讀入記憶體中*標準庫裡提供了眾多的函數和工具來讀取檔案資料。我們先從 `os` 包中提供的基本例子入手。這意味著兩個先決條件:1. 該檔案需要放入記憶體2. 我們需要預Crowdsourced Security Testing道檔案大小以便執行個體化一個足夠裝下該檔案的緩衝區當我們獲得了 `os.File` 對象的控制代碼,我們就可以事先查詢檔案的大小以及執行個體化一個位元組數組。```gofile, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()fileinfo, err := file.Stat()if err != nil {fmt.Println(err)return}filesize := fileinfo.Size()buffer := make([]byte, filesize)bytesread, err := file.Read(buffer)if err != nil {fmt.Println(err)return}fmt.Println("bytes read: ", bytesread)fmt.Println("bytestream to string: ", string(buffer))```在 Github 中查看源檔案 [basic.go](https://github.com/kgrz/reading-files-in-go/blob/master/basic.go)## 分批讀取檔案大部分情況下我們都可以將這個檔案讀入記憶體,但有時候我們希望使用更保守的記憶體使用量策略。比如讀取一定大小的檔案內容,處理它們,然後迴圈這個過程直到結束。在下面這個例子中使用了 100 位元組的緩衝區。```goconst BufferSize = 100file, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()buffer := make([]byte, BufferSize)for {bytesread, err := file.Read(buffer)if err != nil {if err != io.EOF {fmt.Println(err)}break}fmt.Println("bytes read: ", bytesread)fmt.Println("bytestream to string: ", string(buffer[:bytesread]))}```在 Github 中查看源檔案 [reading-chunkwise.go](https://github.com/kgrz/reading-files-in-go/blob/master/reading-chunkwise.go) 與讀取整個檔案的區別在於:1. 當讀取到 EOF 標記時就停止讀取,因此我們增加了一個特殊的斷言 `err == io.EOF`。如果你剛開始接觸 Go,你可能會對 errors 的約定感到困惑,那麼閱讀 Rob Pike 的這篇文章可能會對你有所協助:[Errors are values](https://blog.golang.org/errors-are-values)2. 我們定義了緩衝區的大小,這樣我們可以控制任意的緩衝區大小。由於作業系統的這種工作方式([caching a file that’s being read](http://www.tldp.org/LDP/sag/html/buffer-cache.html)),如果設定得當可以提高效能。3. 如果檔案的大小不是緩衝區大小的整數倍,那麼最後一次迭代只會讀取剩餘的位元組到緩衝區中,因此我們會調用 `buffer[:bytesread]`。在正常情況下,`bytesread` 和緩衝區大小相同。這種情況和以下的 Ruby 代碼非常相似:```cbufsize = 100f = File.new "_config.yml", "r"while readstring = f.read(bufsize)break if readstring.nil?puts readstringend```在迴圈中的每一次迭代,內部的檔案指標都會被更新。當下一次讀取開始時,資料將從檔案指標的位移量處開始,直到讀取了緩衝區大小的內容。這個指標不是程式設計語言中的概念,而是作業系統中的概念。在 linux 中,這個指標是指建立的檔案描述符的屬性。所有的 read/Read 函數調用(在 Ruby/Go 中)都被內部轉化為系統調用並發送給核心,然後由核心管理所有的這些指標。## 並行分批讀取檔案那怎麼樣才能加速分批讀取檔案呢?其中一種方法是用多個 go routine。相對於連續分批讀取檔案,我們需要知道每個 goroutine 的位移量。值得注意的是,當剩餘的資料小於緩衝區時,`ReadAt` 的表現和 `Read` 有[輕微的不同](https://golang.org/pkg/io/#ReaderAt)。另外,我在這裡並沒有設定 goroutine 數量的上限,而是由緩衝區的大小自行決定。但在實際的應用中通常都會設定 goroutine 的數量上限。```goconst BufferSize = 100file, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()fileinfo, err := file.Stat()if err != nil {fmt.Println(err)return}filesize := int(fileinfo.Size())// 我們需要使用的 goroutine 數量concurrency := filesize / BufferSize// 如果有多餘的位元組,增加一個額外的 goroutineif remainder := filesize % BufferSize; remainder != 0 {concurrency++}var wg sync.WaitGroupwg.Add(concurrency)for i := 0; i < concurrency; i++ {go func(chunksizes []chunk, i int) {defer wg.Done()chunk := chunksizes[i]buffer := make([]byte, chunk.bufsize)bytesread, err := file.ReadAt(buffer, chunk.offset)// 如上所述,當輸出緩衝區的容量比要讀取的資料大時,ReadAt 和 Read 方法稍微有些區別。// 因此當遇到非 EOF 類型的錯誤時,我們需要提前從函數返回。這中情況下 deferred 函數會在// 主函數返回前執行if err != nil && err != io.EOF {fmt.Println(err)return}fmt.Println("bytes read, string(bytestream): ", bytesread)fmt.Println("bytestream to string: ", string(buffer[:bytesread]))}(chunksizes, i)}wg.Wait()```在 Github 中查看源檔案 [reading-chunkwise-multiple.go](https://github.com/kgrz/reading-files-in-go/blob/master/reading-chunkwise-multiple.go)這比之前的方法都需要考慮得更多:1. 我嘗試建立特定數量的 Go-routines, 這個數量取決於檔案大小以及緩衝區大小(在我們的例子中是 100k)。2. 我們需要一種方法能確定等所有的 goroutines 都結束。在這個例子中,我們使用 wait group。3. 我們在每個 goroutine 結束時發送訊號,而不是使用 `break` 從 for 迴圈中跳出。由於我們在 `defer` 中調用 `wg.Done()`,每次從 goroutine 中”返回“時都會調用該函數。注意:每次都應該檢查返回的位元組數,並重新整理(reslice)輸出緩衝區。## 掃描你可以在各種情境下使用 `Read()` 方法來讀取檔案,但有時候你需要一些更加方便的方法。就像在 Ruby 中經常使用的類似於 `each_line`,`each_char`,`each_codepoint` 等 IO 函數。我們可以使用 `Scanner` 類型以及 `bufio` 包中的相關函數來達到類似的效果。`buifo.Scanner` 類型實現了具有 “分割” 功能的函數,並基於該函數更新指標位置。比如內建的 `bufio.ScanLines` 分割函數,在每次迭代中將指標指向下一行第一個字元。在每一步中,該類型同時暴露一些方法來獲得從起始位置到結束位置之間的位元組數組/字串。比如:```gofile, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanLines)// 根據 IO 流中的下個字元是否是'\n' 來返回 boolean 值。如果找到該符號,// 該步驟會提前將內部指標移動到下一個位置('\n' 的後面)。read := scanner.Scan()if read {fmt.Println("read byte array: ", scanner.Bytes())fmt.Println("read string: ", scanner.Text())}// 回到 Scan() 那一行, 然後重複執行。```在 Github 中查看源檔案 [scanner-example.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner-example.go)因此,若想按行讀取整個檔案,可以這麼做:```gofile, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanLines)// 這是我們的緩衝區var lines []stringfor scanner.Scan() {lines = append(lines, scanner.Text())}fmt.Println("read lines:")for _, line := range lines {fmt.Println(line)}```在 Github 中查看源檔案 [scanner.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner.go)## 按單詞掃描`bufio` 包包含了一些基本的預定義的分割函數:1. ScanLines(預設)2. ScanWords3. ScanRunes(在遍曆 UTF-8 字串而不是位元組時將會非常有用)4. ScanBytes若想從檔案中得到單詞數組,則可以這麼做:```gofile, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanWords)var words []stringfor scanner.Scan() {words = append(words, scanner.Text())}fmt.Println("word list:")for _, word := range words {fmt.Println(word)}````ScanBytes` 分割函數會返回和之前所說的 `Read()` 樣本一樣的結果。兩者的主要區別在於在掃描器中每次我們需要添加到位元組/字串數組時存在的動態分配問題。我們可以用預先定義緩衝區大小並在達到大小限制後才增加其長度的技術來規避這種問題。樣本如下:```gofile, err := os.Open("filetoread.txt")if err != nil {fmt.Println(err)return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanWords)// 初始化我們的單字清單bufferSize := 50words := make([]string, bufferSize)pos := 0for scanner.Scan() {if err := scanner.Err(); err != nil {// 這是一個非 EOF 錯誤。如果遇到這種錯誤則結束迴圈。fmt.Println(err)break}words[pos] = scanner.Text()pos++if pos >= len(words) {// expand the buffer by 100 againnewbuf := make([]string, bufferSize)words = append(words, newbuf...)}}fmt.Println("word list:")// 由於我們會按固定大小擴充緩衝區,緩衝區容量可能比實際的單詞數量大, 因此我們只有在 "pos" // 有效時才進行迭代。否則掃描器可能會因為遇到錯誤而提前終止。在這個例子中,"pos" 包含了// 最後一次更新的索引。for _, word := range words[:pos] {fmt.Println(word)}```在 Github 中查看源檔案 [scanner-word-list-grow.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner-word-list-grow.go)最終我們可以實現更少的 “擴增” 操作,但同時根據 `bufferSize` 我們可能會在末尾存在一些空的插槽,這算是一種折中的方法。## 將一個長字串分割成多個單詞`bufio.Scanner` 有一個參數,這個參數是實現了 `io.Reader` 介面的類型,這意味著該類型可以是任何擁有 `Read` 方法的類型。在標準庫中 `strings.NewReader` 函數是一個返回 “reader” 類型的字串實用方法。我們可以把兩者結合起來使用:```gofile, err := os.Open("_config.yml")longstring := "This is a very long string. Not."handle(err)var words []stringscanner := bufio.NewScanner(strings.NewReader(longstring))scanner.Split(bufio.ScanWords)for scanner.Scan() {words = append(words, scanner.Text())}fmt.Println("word list:")for _, word := range words {fmt.Println(word)}```## 讀取逗號分隔的字串用基本的 `file.Read()` 或者 `Scanner` 類型去解析 CSV 檔案/字串顯得過於笨重,因為在 `bufio.ScanWords` 函數中“單詞”是指被 unicode 空格分隔的符號(runes)。讀取單個符號(runes),並持續跟蹤緩衝區大小以及位置(就像 lexing/parsing 所做的)需要做太多的工作和操作。當然,這是可以避免的。我們可以定義一個新的分割函數,這個函數讀取字元知道遇到逗號,然後調用 `Text()` 或者 `Bytes()` 返回該資料區塊。`bufio.SplitFunc` 的函數簽名如下所示:```go(data []byte, atEOF bool) -> (advance int, token []byte, err error)```1. `data` 是輸入的位元組字串2. `atEOF` 是傳遞給函數的結束符標誌3. `advance` 使用它,我們可以指定處理當前讀取長度的位置數。此值用於在掃描迴圈完成後更新遊標位置4. `token` 是指掃描操作的實際資料5. `err` 你可能想返回傳現的問題簡單起見,我將示範讀取一個字串而不是一個檔案。一個使用上述簽名的簡單的 CSV 讀取器如下所示:```gocsvstring := "name, age, occupation"// 定義一個匿名函數來避免重複 main() 函數ScanCSV := func(data []byte, atEOF bool) (advance int, token []byte, err error) {commaidx := bytes.IndexByte(data, ',')if commaidx > 0 {// 我們需要返回下一個位置buffer := data[:commaidx]return commaidx + 1, bytes.TrimSpace(buffer), nil}// 如果碰到了字串的末尾,那麼直接返回整個緩衝區if atEOF {// 以下代碼只有在有資料時才執行,否則可能意味著已經到達輸入的 CSV 字串的末尾if len(data) > 0 {return len(data), bytes.TrimSpace(data), nil}}// 返回 0, nil, nil 是讓介面從輸入源讀取更多的資料的訊號。// 在這個例子中,輸入源是字串讀取器,基本上不太可能碰到這種情況。return 0, nil, nil}scanner := bufio.NewScanner(strings.NewReader(csvstring))scanner.Split(ScanCSV)for scanner.Scan() {fmt.Println(scanner.Text())}```在 Github 中查看源檔案 [comma-separated-string.go](https://github.com/kgrz/reading-files-in-go/blob/master/comma-separated-string.go#L10)## Ruby 風格我們已經按照便利程度和功能一次增加的順序列舉了許多讀取檔案的方法。如果我們僅僅是想將一個檔案讀入緩衝區呢?標準庫中的 `ioutil` 包包含了一些更簡便的函數。## 讀取整個檔案```gobytes, err := ioutil.ReadFile("_config.yml")if err != nil {log.Fatal(err)}fmt.Println("Bytes read: ", len(bytes))fmt.Println("String read: ", string(bytes))```這種方式看起來更像一些進階指令碼語言的寫法。## 讀取這個檔案夾下的所有檔案無需多言, 如果你有很大的檔案,*不要*運行這個指令碼 :D```gofilelist, err := ioutil.ReadDir(".")if err != nil {log.Fatal(err)}for _, fileinfo := range filelist {if fileinfo.Mode().IsRegular() {bytes, err := ioutil.ReadFile(fileinfo.Name())if err != nil {log.Fatal(err)}fmt.Println("Bytes read: ", len(bytes))fmt.Println("String read: ", string(bytes))}}```## 更多的協助方法在標準庫中還有很多讀取檔案的函數(確切得說,讀取器)。為了防止這篇本已冗長的文章變得更加冗長,我列舉了一些我發現的函數:1. `ioutil.ReadAll()` -> 使用一個類似 io 的對象,返回位元組數組2. `io.ReadFull()`3. `io.ReadAtLeast()`4. `io.MultiReader` -> 一個非常有用的合并多個類 io 對象的基本工具(primitive)。你可以把多個檔案當成是一個連續的資料區塊來處理,而無需處理在上一個檔案結束後切換至另一個檔案對象的複雜操作。## 更新我嘗試強調 “讀取” 函數,我選擇使用 error 函數來列印以及關閉檔案:```gofunc handleFn(file *os.File) func(error) {return func(err error) {if err != nil {file.Close()log.Fatal(err)}}}// 在 main 函數內:file, err := os.Open("filetoread.txt")handle := handleFn(file)handle(err)```這樣操作,我忽略了一個重要的細節:如果沒有錯誤發生且程式運行結束,那檔案就不會被關閉。如果程式多次運行且沒有發生錯誤,則會導致檔案描述符泄露。這個問題已經在 [on reddit by u/shovelpost](https://www.reddit.com/r/golang/comments/7n2bee/various_ways_to_read_a_file_in_go/drzg32k/) 中指出。我之所以不想用 `defer` 是因為 `log.Fatal` 內部會調用 `os.Exit` 函數,而該函數不會運行 deferred 函數,所以我選擇了手動關閉檔案,然而卻忽略了正常啟動並執行情況。我已經在更新了的例子中使用了 `defer`,並用 `return` 取代了 `os.Exit()`。
via: http://kgrz.io/reading-files-in-go-an-overview.html
作者:Kashyap Kondamudi 譯者:killernova 校對:Unknwon
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
652 次點擊