跟着go标准库学习如何按行读取文件

1,135 阅读4分钟

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

pexels-dominika-roseclay-2691779

导语

在任何一种编程语言中,对文件操作都是必不可少的一部分,甚至在广义上,Unix系统提出了一切设备皆文件,几乎涵盖了操作系统级别的所有IO操作。在学习go语言时同样需要了解go语言是如何处理普通文件操作的。幸运的是go标准库提供了足够丰富的文件操作工具,而且通过这些工具的实现代码,也可以学习到一些文件操作的高质量代码的写法。值得指出的是go标准库代码质量非常高,不仅仅文件操作,还有非常多的内容值得深入挖掘,感兴趣的小伙伴已经可以自己前往挖宝了~

有一个重要的文件读取场景是按照行逐行读取并处理文件,这样的文件一般是特别大的文件,不能一次性都读到内存中,只能分块或分段的处理,下边我们看一下应该如何写出正确的高质量的逐行读取文件的代码。

按行读取文件

一般网文代码#1如下:

func ReadFile(filePath string) error {
    f, err := os.Open(filePath)
    defer f.Close()
    if err != nil {
        return err
    }
    buf := bufio.NewReader(f)

    for {
        line, err := buf.ReadLine("\n")
        if err != nil {
            if err == io.EOF{
                return nil
            }
            return err
        }
        // handle here 
        return nil
    }
}

注意:该代码片段中有两处问题,此时你可以停下来看看自己是否可以找得到问题所在。

更严格的说,还可能有另外一处问题存在,我们在下边统一分析。

以下代码#2来自csv标准库。简单说明一下csv文件格式的要领,csv文件按行存储,每一行代表一组数据,该组数据包含多个字段,不同字段默认用逗号分割。我们来分析一下csv标准库是如何按行读取数据的。

// readLine reads the next line (with the trailing endline).
// If EOF is hit without a trailing endline, it will be omitted.
// If some bytes were read, then the error is never io.EOF.
// The result is only valid until the next call to readLine.
func (r *Reader) readLine() ([]byte, error) {
	line, err := r.r.ReadSlice('\n')
	if err == bufio.ErrBufferFull {
		r.rawBuffer = append(r.rawBuffer[:0], line...)
		for err == bufio.ErrBufferFull {
			line, err = r.r.ReadSlice('\n')
			r.rawBuffer = append(r.rawBuffer, line...)
		}
		line = r.rawBuffer
	}
  if len(line) > 0 && err == io.EOF {
		err = nil
		// For backwards compatibility, drop trailing \r before EOF.
		if line[len(line)-1] == '\r' {
			line = line[:len(line)-1]
		}
	}
	// 其他代码略
}

// NewReader returns a new Reader that reads from r.
func NewReader(r io.Reader) *Reader {
	return &Reader{
		Comma: ',',
		r:     bufio.NewReader(r),
	}
}

这两个代码片段都通过bufio标准库实现了按行读取文件,代码#1中第10行错误的使用了bufio底层函数ReadLine,该函数虽然是导出函数,但是并不适合上层应用直接调用,从其注释说明中也可见一斑。

// ReadLine is a low-level line-reading primitive. Most callers should use
// ReadBytes('\n') or ReadString('\n') instead or use a Scanner.
// ReadLine是一个底层按行读取原语。大部分调用者应该使用ReadBytes('\n') 或 ReadString('\n'),或者扫描器

注意:网上有大量代码示例是无视该说明文档的,潜在的风险则是代码panic后可能会造成数据的损失。

代码#1中另一个问题是对EOF处理不当,虽然判断了EOF错误,但是却没有正确处理读到EOF时的数据,代码片段#2正确处理了。这种场景会发生在文件的最后一行而该行并没有换行符,如果用代码#1就会漏掉该行数据。

上边我们提到代码#1严格的说还有另外一个问题就是对缓存满的错误没有处理,直接就报错退出了,这样的错误处理是无效的,将该错误返回给上层甚至使用者都无助于解决这个错误,这是一个不能靠重试就可以解决的错误。总不能要求他们改一下待处理的数据,我们的程序缓存不够处理不了,这就贻笑大方了。

从两段代码对比我们可以看出,高质量的代码是高度关注细节,高度准确的,这些细节往往体现在对边界场景和错误的处理上。

按照csv标准库修改后端代码#1如下:

func ReadFile(filePath string) error {
    f, err := os.Open(filePath)
    defer f.Close()
    if err != nil {
        return err
    }
    buf := bufio.NewReader(f)

    for {
        line, err := buf.ReadString('\n')
        if err != nil && err != io.EOF{
            return err
        }
        if len(line) > 0 {
        }
        // handle here 
        return nil
    }
}

注意:bufio的ReadString或ReadBytes函数内部已经处理了缓冲区的错误,故只需要处理EOF错误即可。

参考文献

  1. colobu.com/2016/10/12/…
  2. pkg.go.dev/encoding/cs…
  3. pkg.go.dev/bufio