當我開始學習 Go 時,我很難掌握各種用於讀取檔案的 API 和技術。我嘗試編寫支援多核的單詞計數程式([KGRZ/KWC](https://github.com/kgrz/kwc)),通過在一個程式中使用多種讀取檔案方式來展示我初始的困惑。在今年的 [Advent of Code](http://adventofcode.com/2017) 中,有些問題需要採用不同的方式來讀取輸入。我最終每種技術都至少使用過一次,現在我將對這些技術的理解寫在本文中。我列出的方法是按照我使用的順序,並不一定按照難度遞減的順序。## 一些基本的假設* 所有的程式碼範例都被封裝在一個 `main()` 函數中* 大多數情況下,我會經常會交替使用“數組 `array`”和“切片 `slice`”來指代切片,但它們是不一樣的。這些[部落格](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)中一樣,讀取檔案時返回一個位元組流。不自動將讀取內容轉換為字串有一個好處,可以避免因為昂貴的字串分配給 GC 帶來的壓力。為了讓這篇文章有一個簡單的概念性模型,我會使用 `string(arrayOfBytes)` 將位元組數群組轉換成字串。不過一般來說不建議在生產環境使用這種方式。## 按位元組讀取### 讀取整個檔案到記憶體首先,標準庫提供多個函數和工具來讀取檔案資料。我們從 `os` 包中提供的一個基本用法開始。這意味著兩個先決條件:1. 檔案大小不超過記憶體。2. 我們能夠提前知道檔案的大小,以便執行個體化一個足夠大的緩衝區來儲存資料。獲得一個 `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))```[basic.go](https://github.com/kgrz/reading-files-in-go/blob/master/basic.go) on Github### 以塊讀取檔案在大多數情況下,一次性讀取整個一個檔案是沒有問題的。有時我們希望使用更節省記憶體的方法。比如說,按照一定大小來讀取一個檔案塊,並處理這個檔案塊,然後重複直到讀取完整個檔案。下面的樣本使用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]))}```[reading-chunkwise.go](https://github.com/kgrz/reading-files-in-go/blob/master/reading-chunkwise.go) on Github與讀取整個檔案內容相比,主要不同之處在於:1. 我們持續進行讀取,直到讀到 `EOF` 標記,所以我們添加了一個特定的檢查 `err==io.EOF`。如果你是 Go 的新手,並且對錯誤處理的方法感到困惑,請查看這篇由 Rob Pike 寫的文章:[Errors are values](https://blog.golang.org/errors-are-values)2. 我們定義了緩衝區大小,這樣我們就可以控制我們想要的“塊”大小。如果使用得當,這可以提高效能,因為作業系統的工作方式是緩衝正在讀取的檔案。3. 如果檔案大小不是緩衝區大小的整數倍,則最後一次迭代只向緩衝中添加餘下的位元組,因此需要切片操作 `buffer[:bytesread]`。在正常情況下,`bytesread` 和緩衝大小相同。這和下面這段 Ruby 代碼類似:```bufsize = 100f = File.new "_config.yml", "r"while readstring = f.read(bufsize) break if readstring.nil? puts readstringend```在每個迴圈中,都對內部檔案指標位置進行更新。當下一次讀取時,資料從檔案指標位移開始,讀取並返回緩衝區大小的資料。這個指標不是由程式設計語言建立的,而是作業系統建立的。在 Linux 上,這個指標是作業系統建立的檔案描述符。所有 `read/Read` 調用(分別在 Ruby/Go 中)被內部翻譯成系統調用,並發送到核心,由核心管理這個指標。### 並發讀取檔案塊如果我們想加快上面提到的對資料區塊的處理速度呢?一個方法就是使用多個 `goroutine`!相對於順序讀取資料區塊,我們需要一個額外的操作就是要知道每個 `routine` 讀取資料的位移量。注意,`ReadAt` 函數和 `Read` 函數在緩衝容量大於剩餘需要讀取位元組的時,處理方式略有不同。還要注意的是,我並沒有限制 `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())// Number of go routines we need to spawn.concurrency := filesize / BufferSize// check for any left over bytes. Add one more go routine if required.if 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) // As noted above, ReadAt differs slighly compared to Read when the // output buffer provided is larger than the data that's available // for reading. So, let's return early only if the error is // something other than an EOF. Returning early will run the // deferred function above 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()```[reading-chunkwise-multiple.go](https://github.com/kgrz/reading-files-in-go/blob/master/reading-chunkwise-multiple.go) on Github這比以前任何方法都要複雜:1. 我嘗試建立一個特定的 `goroutine`,這取決於檔案大小和緩衝區大小(在我們的例子中是100)。2. 我們需要一種方法來確保我們“等待”所有的 `goroutine` 運行完成。在這個例子中,我是使用 `WaitGroup`。3. 我們在 `goroutine` 運行完成時發送一個結束訊號,而不是使用無限迴圈等待運行結束。我們使用 `defer` 調用 `wg.Done()`,當 `goroutine` 運行到 `return` 時,`wg.Done` 會被調用。注意:總是要檢查返回的位元組數,並對輸出緩衝區重新切片。## 掃描檔案你可以一直使用 `Read()` 來讀取檔案,但有時你需要更方便的方法。在 Ruby 中有一些經常用到的 IO 函數,比如 `each_line`,`each_char`,`each_codepoint` 等。我們可以使用 `Scanner` 類型和 `bufio` 包中提供的相關函數來實作類別似的功能。`bufio.Scanner` 類型實現了一個參數為“分割”函數的函數,並基於此函數推進指標。例如,內建的 `bufio.ScanLines` 分割函數,在每次迭代中都會推進指標,直到指標推進到下一個分行符號。在每個步驟中,`bufio.Scanner` 類型提供了擷取在起始位置和結束位置之間的位元組數組/字串的函數。例如:```gofile, err := os.Open("filetoread.txt")if err != nil { fmt.Println(err) return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanLines)// Returns a boolean based on whether there's a next instance of `\n`// character in the IO stream. This step also advances the internal pointer// to the next position (after '\n') if it did find that token.read := scanner.Scan()if read { fmt.Println("read byte array: ", scanner.Bytes()) fmt.Println("read string: ", scanner.Text())}// goto Scan() line, and repeat```[scanner-example.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner-example.go) on Github因此,使用這種逐行的方式讀取整個檔案,可以使用以下代碼:```gofile, err := os.Open("filetoread.txt")if err != nil { fmt.Println(err) return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanLines)// This is our buffer nowvar lines []stringfor scanner.Scan() { lines = append(lines, scanner.Text())}fmt.Println("read lines:")for _, line := range lines { fmt.Println(line)}```[scanner.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner.go) on Github### 按照單詞掃描`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()` 樣本中相同的輸出。兩者之間的一個主要區別是每次我們都需要動態將資料追加到 byte/string 數組。這可以通過使用預先初始化緩衝的技術來規避,只在資料長度超出緩衝區時才增加緩衝區大小。使用上面的相同的例子:```gofile, err := os.Open("filetoread.txt")if err != nil { fmt.Println(err) return}defer file.Close()scanner := bufio.NewScanner(file)scanner.Split(bufio.ScanWords)// initial size of our wordlistbufferSize := 50words := make([]string, bufferSize)pos := 0for scanner.Scan() { if err := scanner.Err(); err != nil { // This error is a non-EOF error. End the iteration if we encounter // an error fmt.Println(err) break } words[pos] = scanner.Text() pos++ if pos >= len(words) { // expand the buffer by 100 again newbuf := make([]string, bufferSize) words = append(words, newbuf...) }}fmt.Println("word list:")// we are iterating only until the value of "pos" because our buffer size// might be more than the number of words because we increase the length by// a constant value. Or the scanner loop might've terminated due to an// error prematurely. In this case the "pos" contains the index of the last// successful update.for _, word := range words[:pos] {fmt.Println(word)}```[scanner-word-list-grow.go](https://github.com/kgrz/reading-files-in-go/blob/master/scanner-word-list-grow.go) on Github所以我們顯著減少了切片“增長”操作,但是根據緩衝大小和檔案大小,我們可能會在緩衝末尾有空缺,這是一個折衷的方案。### 將長字串分割成單詞`bufio.NewScanner` 需要滿足 `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)}```### 掃描逗號分隔字串用基本的 `Read()` 函數或 `Scanner` 類型手動解析 CSV 檔案/字串是比較繁瑣的,因為上述分割函數 `bufio.ScanWords` 將一個“單詞”定義為一組由空格分割的字元。讀取單個字元並記錄緩衝區大小和位置(像詞法分析/解析工作)需要太多的工作和操作。我們可以通過定義新的分割函數來省去這些繁瑣的操作。分割函數順序讀取每個字元直到遇到逗號,然後在 `Text()` 或 `Bytes()` 函數被調用時返回檢測到的單詞。`bufio.SplitFunc` 函數簽名應該是這樣的:```(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"// An anonymous function declaration to avoid repeating main()ScanCSV := func(data []byte, atEOF bool) (advance int, token []byte, err error) { commaidx := bytes.IndexByte(data, ',') if commaidx > 0 { // we need to return the next position buffer := data[:commaidx] return commaidx + 1, bytes.TrimSpace(buffer), nil } // if we are at the end of the string, just return the entire buffer if atEOF { // but only do that when there is some data. If not, this might mean // that we've reached the end of our input CSV string if len(data) > 0 { return len(data), bytes.TrimSpace(data), nil } } // when 0, nil, nil is returned, this is a signal to the interface to read // more data in from the input reader. In this case, this input is our // string reader and this pretty much will never occur. return 0, nil, nil}scanner := bufio.NewScanner(strings.NewReader(csvstring))scanner.Split(ScanCSV)for scanner.Scan() { fmt.Println(scanner.Text())}```## 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)) }}```### 其他有用的函數在標準庫中有更多的函數來讀取檔案(或者更準確的說是一個 `Reader`)。為了避免這篇文章過長,我列出了我發現的一些函數:1. `ioutil.ReadAll()` 輸入一個類似 `io` 對象,將整個資料作為位元組數組返回2. `io.ReadFull()`3. `io.ReadAtLeast()`4. `io.MultiReader` 組合多個類似 `io` 對象時非常有用。如果你有一個需要讀取的檔案清單,可以將它們視為單個連續的資料區塊,而無需管理複雜的前後檔案之間的切換。### 更新為了反白 “read” 函數,我選擇了使用錯誤處理函數來列印錯誤並關閉檔案:```gofunc handleFn(file *os.File) func(error) { return func(err error) { if err != nil { file.Close() log.Fatal(err) } }}// inside the main function:file, err := os.Open("filetoread.txt")handle := handleFn(file)handle(err)```這樣做,我錯過了一個關鍵的細節:當沒有發生錯誤並且程式運行完成時,我沒有關閉檔案控制代碼。如果程式運行多次而沒有發生任何錯誤,則會導致檔案描述符泄漏。這是由[u/shovelpost](https://www.reddit.com/r/golang/comments/7n2bee/various_ways_to_read_a_file_in_go/drzg32k/)在reddit上指出的。我本意是避免使用 `defer`,因為 `log.Fatal` 在內部調用了不運行延遲函數的 `os.Exit`,所以我選擇顯式關閉檔案,但忽略了成功啟動並執行情況。我已經更新了樣本使用 `defer` 和 `return` 來代替對 `os.Exit` 的依賴。
via: https://kgrz.io/reading-files-in-go-an-overview.html
作者:Kashyap Kondamudi 譯者:althen 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
350 次點擊