在我们需要实现一个函数功能是读取一个文件的时候,将文件名传递给函数不是一种最佳的实践,可能产生一些反作用,比如在单元测试起来困难。下面将深入讨论这个问题并掌握怎么处理它。
我们想实现一个函数用来统计文件中空行数。一种可能的实现如下,接收一个文件名作为入参,使用bufio.NewScanner扫描并检查文件中的每一行。
func countEmptyLinesInFile(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, err
}
// Handle file closure
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// ...
}
}
我们打开了一个文件,然后使用bufio.NewScanner开始扫描每一行,默认情况下,bufio.NewScanner会将按行切分输入的内容。
这个函数正如我们期望的那样工作,只要提供的文件名是有效的,我们就能够从文件中读取到内容并返回文件中空行数,那有什么问题吗?
假设我们要实现单元测试覆盖这三种情况: 1. 正常的文件 2. 空文件 3. 文件只包含空行, 每种情况都需要创建一个文件进行测试。函数越复杂,需要越多的测试案例来覆盖,就会需要创建更多的文件。在某些情况下,我们甚至不得不创建几十个文件,这很快变得难以管理。
更进一步来说,上述代码是不可重用的。例如,如果我们需要实现相同的逻辑但是从HTTP request中统计空行数,我们将不得不重复主逻辑。
func countEmptyLinesInHTTPRequest(request http.Request) (int, error) {
scanner := bufio.NewScanner(request.Body)
// Copy the same logic
}
一种解决办法是将传递给函数的入参调整为*bufio.Scanner
, 这样函数内部的逻辑就可以使用传入的scanner参数,两段代码逻辑是一致的。但是更地道的做法是传入一个reader接口。
下面对countEmptyLines进行重构,传入的参数为io.Reader,具体代码如下:
func countEmptyLines(reader io.Reader) (int, error) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
// ...
}
}
由于bufio.NewScanner可以接收io.Reader,所以可以函数入参直接传给它。通过这里的调整,我们能够得到哪些收益呢?
其一 代码能够得到复用,因为入参是一个接口,所以它可以是一个文件,也可以是HTTP, socket等等,函数内部不需要关心传入的是啥,因为无论是*os.File
还是http.Request
中的Body都实现了io.Reader.
其二 上述代码在测试起来非常方便。现在countEmptyLines接收的是io.Reader,我们可以通过从字符串创建io.Reader来实现单元测试。
func TestCountEmptyLines(t *testing.T) {
emptyLines, err := countEmptyLines(strings.NewReader(
`foo
bar
baz
`))
// Test logic
}
在上面的测试中,我们直接从字符串中使用strings.NewReader创建了一个io.Reader,不必为每个测试用例写一个文件。每个测试用例是独立的,提供了测试的可读性和可维护性。
在大多数情况下,接收文件名作为函数参数,从文件中读取的函数应被视为代码异味。正如上面所见,它使得单元测试更加复杂,因为我们可能需要创建多个文件。此外,它降低了函数的可复用性(尽管并非所有函数都是可以被重用的),使用io.Reader接口抽象数据源,无论输入的是文件、字符串、HTTP还是gRPC请求,都可以重用并轻松对代码进行测试。