十四、错误处理与测试
Go 没有像 Java 和 .NET 那样的 try/catch 异常机制:不能执行抛异常操作。但是有一套 defer-panic-and-recover 机制
Go 的设计者觉得 try/catch 机制的使用太泛滥了,而且从底层向更高的层级抛异常太耗费资源。他们给 Go 设计的机制也可以“捕捉”异常,但是更轻量,并且只应该作为(处理错误的)最后的手段。
Go 是怎么处理普通错误的呢?通过在函数和方法中返回错误对象作为它们的唯一或最后一个返回值——如果返回 nil,则没有错误发生——并且主调 (calling) 函数总是应该检查收到的错误。
永远不要忽略错误,否则可能会导致程序崩溃!!
panic() 和 recover() 是用来处理真正的异常(无法预测的错误)而不是普通的错误。
库函数通常必须返回某种错误提示给主调函数。
Go 检查和报告错误条件的惯有方式:
产生错误的函数会返回两个变量,一个值和一个错误码;如果后者是
nil就是成功,非nil就是发生了错误。为了防止发生错误时正在执行的函数(如果有必要的话甚至会是整个程序)被中止,在调用函数后必须检查错误。
if value, err := pack1.Func1(param1); err != nil {
fmt.Printf("Error %s in pack1.Func1 with parameter %v", err.Error(), param1)
return // or: return err
} else {
// Process(value)
}
为了更清晰的代码,应该总是使用包含错误值变量的 if 复合语句
如果程序中止也没关系的话甚至可以使用 panic()
13.1 错误处理
Go 有一个预先定义的 error 接口类型
type error interface {
Error() string
}
错误值用来表示异常状态;errors 包中有一个 errorString 结构体实现了 error 接口。当程序处于错误状态时可以用 os.Exit(1) 来中止运行。
13.2 定义错误
任何时候当需要一个新的错误类型,都可以用 errors 包(必须先 import)的 errors.New() 函数接收合适的错误信息来创建,像下面这样:
err := errors.New("math - square root of negative number")
通常(错误信息)都会有像 Error:... 这样的前缀,所以你的错误信息不要以大写字母开头(注:英文只有句首单词首字母大写,这里应当是考虑到这一点)。
在大部分情况下自定义错误结构类型很有意义的,可以包含除了(低层级的)错误信息以外的其它有用信息,例如,正在进行的操作(打开文件等),全路径或名字。看下面例子中 os.Open() 操作触发的 PathError 错误:
// PathError records an error and the operation and file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": "+ e.Err.Error()
}
如果有不同错误条件可能发生,那么对实际的错误使用类型断言或类型判断(type-switch)是很有用的,并且可以根据错误场景做一些补救和恢复操作。
// err != nil
if e, ok := err.(*os.PathError); ok {
// remedy situation
}
或:
switch err := err.(type) {
case ParseError:
PrintParseError(err)
case PathError:
PrintPathError(err)
...
default:
fmt.Printf("Not a special error, just %s\n", err)
}
包也可以用额外的方法 (methods)定义特定的错误,比如 net.Error:
package net
type Error interface {
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}
所有的例子都遵循同一种命名规范:错误类型以 ...Error 结尾,错误变量以 err... 或 Err... 开头或者直接叫 err 或 Err。
syscall 是低阶外部包,用来提供系统基本调用的原始接口。它们返回封装整数类型错误码的 syscall.Errno;类型 syscall.Errno 实现了 Error 接口。
大部分 syscall 函数都返回一个结果和可能的错误,比如:
r, err := syscall.Open(name, mode, perm)
if err != nil {
fmt.Println(err.Error())
}
os 包也提供了一套像 os.EINAL 这样的标准错误,它们基于 syscall 错误:
var (
EPERM Error = Errno(syscall.EPERM)
ENOENT Error = Errno(syscall.ENOENT)
ESRCH Error = Errno(syscall.ESRCH)
EINTR Error = Errno(syscall.EINTR)
EIO Error = Errno(syscall.EIO)
...
)
13.2.1 用 fmt 创建错误对象
通常想要返回包含错误参数的更有信息量的字符串,例如:可以用 fmt.Errorf() 来实现:它和 fmt.Printf() 完全一样,接收一个或多个格式占位符的格式化字符串和相应数量的占位变量。和打印信息不同的是它用信息生成错误对象。
比如在前面的平方根例子中使用:
if f < 0 {
return 0, fmt.Errorf("math: square root of negative number %g", f)
}
第二个例子:从命令行读取输入时,如果加了 --help 或 -h 标志,我们可以用有用的信息产生一个错误:
if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
err = fmt.Errorf("usage: %s infile.txt outfile.txt", filepath.Base(os.Args[0]))
return
}
13.3 运行时异常和 panic
当发生像数组下标越界或类型断言失败这样的运行错误时,Go 运行时会触发运行时 panic,伴随着程序的崩溃抛出一个 runtime.Error 接口类型的值。这个错误值有个 RuntimeError() 方法用于区别普通错误。
panic() 可以直接从代码初始化:当错误条件(我们所测试的代码)很严苛且不可恢复,程序不能继续运行时,可以使用 panic() 函数产生一个中止程序的运行时错误。panic() 接收一个做任意类型的参数,通常是字符串,在程序死亡时被打印出来。Go 运行时负责中止程序并给出调试信息。
一个检查程序是否被已知用户启动的具体例子:
一个检查程序是否被已知用户启动的具体例子:
var user = os.Getenv("USER")
func check() {
if user == "" {
panic("Unknown user: no value for $USER")
}
}
可以在导入包的 init() 函数中检查这些。
当发生错误必须中止程序时,panic() 可以用于错误处理模式:
if err != nil {
panic("ERROR occurred:" + err.Error())
}
在多层嵌套的函数调用中调用 panic(),可以马上中止当前函数的执行,所有的 defer 语句都会保证执行并把控制权交还给接收到 panic 的函数调用者。这样向上冒泡直到最顶层,并执行(每层的) defer,在栈顶处程序崩溃,并在命令行中用传给 panic() 的值报告错误情况:这个终止过程就是 panicking。
标准库中有许多包含 Must 前缀的函数,像 regexp.MustComplie() 和 template.Must();当正则表达式或模板中转入的转换字符串导致错误时,这些函数会 panic()。
不能随意地用 panic() 中止程序,必须尽力补救错误让程序能继续执行。
13.4 从 panic 中恢复 (recover)
recover()内建函数被用于从 panic 或错误场景中恢复:让程序可以从 panicking 重新获得控制权,停止终止过程进而恢复正常执行。
recover 只能在 defer 修饰的函数中使用:用于取得 panic() 调用中传递过来的错误值,如果是正常执行,调用 recover() 会返回 nil,且没有其它效果。
总结:panic() 会导致栈被展开直到 defer 修饰的 recover() 被调用或者程序中止。
// protect() 函数调用函数参数 `g` 来保护调用者防止从 `g` 中
// 抛出的运行时 panic,并展示 panic 中的信息
func protect(g func()) {
defer func() {
log.Println("done")
// Println executes normally even if there is a panic
if err := recover(); err != nil {
log.Printf("run time panic: %v", err)
}
}()
log.Println("start")
g() // possible runtime-error
}
defer-panic()-recover() 在某种意义上也是一种像 if,for 这样的控制流机制。
Go 标准库中许多地方都用了这个机制,例如,json 包中的解码和 regexp 包中的 Complie() 函数。Go 库的原则是即使在包的内部使用了 panic(),在它的对外接口 (API) 中也必须用 recover() 处理成显式返回的错误。
13.5 自定义包中的错误处理和 panicking
这是所有自定义包实现者应该遵守的最佳实践:
1)在包内部,总是应该从 panic 中 recover:不允许显式的超出包范围的 panic()
2)向包的调用者返回错误值(而不是 panic)。
在包内部,特别是在非导出函数中有很深层次的嵌套调用时,将 panic 转换成 error 来告诉调用方为何出错,是很实用的(且提高了代码可读性)。
13.6 一种用闭包处理错误的模式
每当函数返回时,我们应该检查是否有错误发生:但是这会导致重复乏味的代码。结合 defer/panic/recover 机制和闭包可以得到一个我们马上要讨论的更加优雅的模式。**不过这个模式只有当所有的函数都是同一种签名时可用,这样就有相当大的限制。一个很好的使用它的例子是 web 应用,**所有的处理函数都是下面这样:
func handler1(w http.ResponseWriter, r *http.Request) { ... }
假设所有的函数都有这样的签名:
func f(a type1, b type2)
参数的数量和类型是不相关的。
我们给这个类型一个名字:
fType1 = func f(a type1, b type2)
在我们的模式中使用了两个帮助函数:
1)check():这是用来检查是否有错误和 panic 发生的函数:
func check(err error) { if err != nil { panic(err) } }
2)errorhandler():这是一个包装函数。接收一个 fType1 类型的函数 fn 并返回一个调用 fn 的函数。里面就包含有 defer/recover 机制,这在 13.3 节中有相应描述。
func errorHandler(fn fType1) fType1 {
return func(a type1, b type2) {
defer func() {
if err, ok := recover().(error); ok {
log.Printf("run time panic: %v", err)
}
}()
fn(a, b)
}
}
当错误发生时会 recover 并打印在日志中;除了简单的打印,应用也可以用 template 包(参见 15.7 节)为用户生成自定义的输出。check() 函数会在所有的被调函数中调用,像这样:
func f1(a type1, b type2) {
...
f, _, err := // call function/method
check(err)
t, err := // call function/method
check(err)
_, err2 := // call function/method
check(err2)
...
}
通过这种机制,所有的错误都会被 recover,并且调用函数后的错误检查代码也被简化为调用 check(err) 即可。在这种模式下,不同的错误处理必须对应不同的函数类型;它们(错误处理)可能被隐藏在错误处理包内部。可选的更加通用的方式是用一个空接口类型的切片作为参数和返回值。
13.7 启动外部命令和程序
os 包有一个 StartProcess 函数可以调用或启动外部系统命令和二进制可执行文件;它的第一个参数是要运行的进程,第二个参数用来传递选项或参数,第三个参数是含有系统环境基本信息的结构体。
这个函数返回被启动进程的 id (pid),或者启动失败返回错误。
StartProcess是一个低级接口。os/exec包提供更高级的接口。
如果有错误,则错误类型为*PathError。
exec 包中也有同样功能的更简单的结构体和函数;主要是 exec.Command(name string, arg ...string) 和 Run()。首先需要用系统命令或可执行文件的名字创建一个 Command 对象,然后用这个对象作为接收者调用 Run()。
command := exec.Command("date")
command.Stdin = strings.NewReader("some input")
var out bytes.Buffer
command.Stdout = &out
err := command.Run()
if err != nil {
fmt.Printf("Error: %v executing command!", err)
os.Exit(1)
}
fmt.Println(out.String())
13.8 Go 中的单元测试和基准测试
首先所有的包都应该有一定的必要文档,然后同样重要的是对包的测试。
Go 的测试工具 go test
名为 testing 的包被专门用来进行自动化测试,日志和错误报告。并且还包含一些基准测试函数的功能。
备注:gotest 是 Unix bash 脚本,
对一个包做(单元)测试,需要写一些可以频繁(每次更新后)执行的小块测试单元来检查代码的正确性。于是我们必须写一些 Go 源文件来测试代码。测试程序必须属于被测试的包,并且文件名满足这种形式 *_test.go,所以测试代码和包中的业务代码是分开的。
_test 程序不会被普通的 Go 编译器编译,所以当放应用部署到生产环境时它们不会被部署;只有 gotest 会编译所有的程序:普通程序和测试程序。
测试文件中必须导入 "testing" 包,并写一些名字以 TestZzz 打头的全局函数,这里的 Zzz 是被测试函数的字母描述,如 TestFmtInterface(),TestPayEmployees() 等。
测试函数必须有这种形式的头部:
func TestAbcde(t *testing.T)
T 是传给测试函数的结构类型,用来管理测试状态,支持格式化测试日志,如 t.Log,t.Error,t.ErrorF 等。在函数的结尾把输出跟想要的结果对比,如果不等就打印一个错误,成功的测试则直接返回。
用下面这些函数来通知测试失败:
1)func (t *T) Fail()
标记测试函数为失败,然后继续执行(剩下的测试)。
2)func (t *T) FailNow()
标记测试函数为失败并中止执行;文件中别的测试也被略过,继续执行下一个文件。
3)func (t *T) Log(args ...interface{})
args 被用默认的格式格式化并打印到错误日志中。
4)func (t *T) Fatal(args ...interface{})
结合 先执行 3),然后执行 2)的效果。
运行 go test 来编译测试程序,并执行程序中所有的 TestZZZ 函数。如果所有的测试都通过会打印出 PASS。
go test 可以接收一个或多个函数程序作为参数,并指定一些选项。
结合 --chatty 或 -v 选项,每个执行的测试函数以及测试状态会被打印。
testing 包中有一些类型和函数可以用来做简单的基准测试;测试代码中必须包含以 BenchmarkZzz 打头的函数并接收一个 *testing.B 类型的参数,比如:
func BenchmarkReverse(b *testing.B) {
...
}
命令 go test –test.bench=.*会运行所有的基准测试函数;代码中的函数会被调用 N 次(N 是非常大的数,如 N = 1000000),并展示 N 的值和函数执行的平均时间,单位为 ns(纳秒,ns/op)。如果是用 testing.Benchmark() 调用这些函数,直接运行程序即可。
13.8.1 测试的具体例子
示例even_main.go:
package main
import (
"fmt"
"even/even"
)
func main() {
for i:=0; i<=100; i++ {
fmt.Printf("Is the integer %d even? %v\n", i, even.Even(i))
}
}
上面使用了 even.go 中的 even 包:
示例 even/even.go:
package even
func Even(i int) bool { // Exported function
return i%2 == 0
}
func Odd(i int) bool { // Exported function
return i%2 != 0
}
在 even 包的路径下,我们创建一个名为 oddeven_test.go 的测试程序:
package even
import "testing"
func TestEven(t *testing.T) {
if !Even(10) {
t.Log(" 10 must be even!")
t.Fail()
}
if Even(7) {
t.Log(" 7 is not even!")
t.Fail()
}
}
func TestOdd(t *testing.T) {
if !Odd(11) {
t.Log(" 11 must be odd!")
t.Fail()
}
if Odd(10) {
t.Log(" 10 is not odd!")
t.Fail()
}
}
由于测试需要具体的输入用例且不可能测试到所有的用例(非常像一个无穷的数),所以我们必须对要使用的测试用例思考再三。
至少应该包括:
- 正常的用例
- 反面的用例(错误的输入,如用负数或字母代替数字,没有输入等)
- 边界检查用例(如果参数的取值范围是 0 到 1000,检查 0 和 1000 的情况)
13.8.2 用(测试数据)表驱动测试
编写测试代码时,一个较好的办法是把测试的输入数据和期望的结果写在一起组成一个数据表:表中的每条记录都是一个含有输入和期望值的完整测试用例,有时还可以结合像测试名字这样的额外信息来让测试输出更多的信息。
可以抽象为下面的代码段:
var tests = []struct{ // Test table
in string
out string
}{
{"in1", "exp1"},
{"in2", "exp2"},
{"in3", "exp3"},
...
}
func TestFunction(t *testing.T) {
for i, tt := range tests {
s := FuncToBeTested(tt.in)
if s != tt.out {
t.Errorf("%d. %q => %q, wanted: %q", i, tt.in, s, tt.out)
}
}
}
如果大部分函数都可以写成这种形式,那么写一个帮助函数 verify() 对实际测试会很有帮助:
func verify(t *testing.T, testnum int, testcase, input, output, expected string) {
if expected != output {
t.Errorf("%d. %s with input = %s: output %s != %s", testnum, testcase, input, output, expected)
}
}
TestFunction() 则变为:
func TestFunction(t *testing.T) {
for i, tt := range tests {
s := FuncToBeTested(tt.in)
verify(t, i, "FuncToBeTested: ", tt.in, s, tt.out)
}
}
十五、读写数据
除了 fmt 和 os 包,我们还需要用到 bufio 包来处理缓冲的输入和输出。
15.1 读取用户的输入
我们如何读取用户的键盘(控制台)输入呢?从键盘和标准输入 os.Stdin 读取输入,最简单的办法是使用 fmt 包提供的 Scan... 和 Sscan... 开头的函数
Scan
func Scan(a ...any) (n int, err error)
Scan扫描从标准输入读取的文本,将连续的空格分隔值存储到连续的参数中。换行算作空格。它返回成功扫描的个数。如果它小于参数的数量,err将报告原因。
var a, b int
num, err := fmt.Scan(&a, &b)
Scanf
func Scanf(format string, a ...any) (n int, err error)
Scanf扫描从标准输入读取的文本,根据格式将连续的空格分隔值存储到连续的参数中。它返回成功扫描的个数。如果它小于参数的数量,err将报告原因
输入中的换行符必须与格式中的换行符匹配。唯一的例外是:动词%c总是扫描输入中的下一个符文,即使它是一个空格(或制表符等)或换行符。
Scanln
func Scanln(a ...any) (n int, err error)
Scanln类似于Scan,但是在换行符处停止扫描,并且在最后一项之后必须有换行符或EOF。
Scanf()与其类似,除了Scanf()的第一个参数用作格式字符串,用来决定如何读取。
Sscan...和以Sscan...开头的函数则是从字符串读取,除此之外,与Scanf()相同。
func Sscan(str string, a ...any) (n int, err error)
Sscan扫描参数 str ,将连续的用空格分隔的值存储到连续的参数中。换行算作空格。它返回成功扫描的项目数。如果它小于参数的数量,err将报告原因。
func Sscanf(str string, format string, a ...any) (n int, err error)
Sscanf扫描参数字符串,根据格式将连续的用空格分隔的值存储到连续的参数中。它返回成功解析的项的数量。输入中的换行符必须与格式中的换行符匹配。
也可以使用 bufio 包提供的缓冲读取器 (buffered reader) 来读取数据,
func main() {
var inputReader *bufio.Reader
inputReader = bufio.NewReader(os.Stdin)
fmt.Println("Please enter some input: ")
readString, err := inputReader.ReadString('\n')
if err == nil {
fmt.Printf("The input is %s.", readString)
}
switch readString {
case "Philip\r\n":
fmt.Println("Welcome Philip!")
case "Chris\r\n":
fmt.Println("Welcome Chris!")
case "Ivo\r\n":
fmt.Println("Welcome Ivo!")
default:
fmt.Printf("You are not welcome here! Goodbye!")
}
// version 2:
switch readString {
case "Philip\r\n":
fallthrough
case "Ivo\r\n":
fallthrough
case "Chris\r\n":
fmt.Printf("Welcome %s\n", readString)
default:
fmt.Printf("You are not welcome here! Goodbye!\n")
}
// version 3:
switch readString {
case "Philip\r\n", "Ivo\r\n":
fmt.Printf("Welcome %s\n", readString)
default:
fmt.Printf("You are not welcome here! Goodbye!\n")
}
}
屏幕是标准输出 os.Stdout;os.Stderr 用于显示错误信息,大多数情况下等同于 os.Stdout。
Go里面switch默认相当于每个case最后带有break,匹配成功后不会自动向下执行其他case,而是跳出整个switch, 但是可以使用fallthrough强制执行后面的case代码。
15.2 文件读写
在 Go 语言中,文件使用指向 os.File 类型的指针来表示的,也叫做文件句柄。我们在前面章节使用到过标准输入 os.Stdin 和标准输出 os.Stdout,他们的类型都是 *os.File。
func main() {
inputFile, inputError := os.Open("input.dat")
if inputError != nil {
return // exit the function on error
}
defer inputFile.Close()
inputReader := bufio.NewReader(inputFile)
for {
inputString, readerError := inputReader.ReadString('\n')
fmt.Printf("The input was: %s", inputString)
if readerError == io.EOF {
return
}
}
}
1) 将整个文件的内容读到一个字符串里:
可以使用 io/ioutil 包里的 ioutil.ReadFile() 方法(已舍弃)
新的在os 包里
func ReadFile(name string) ([]byte, error)
ReadFile读取指定文件并返回内容。一个成功的调用返回err == nil,而不是err == EOF。因为ReadFile读取整个文件,所以它不会将Read的EOF作为要报告的错误。
file, err := os.ReadFile("./a.txt")
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(file)
2) 带缓冲的读取
在很多情况下,文件的内容是不按行划分的,或者干脆就是一个二进制文件。在这种情况下,ReadString() 就无法使用了,我们可以使用 bufio.Reader 的 Read(),它只接收一个参数:
buf := make([]byte, 1024)
...
// `n` 的值表示读取到的字节数.
n, err := inputReader.Read(buf)
if (n == 0) { break}
3) 按列读取文件中的数据
如果数据是按列排列并用空格分隔的,你可以使用 fmt 包提供的以 FScan... 开头的一系列函数来读取他们
func main() {
file, err := os.Open("products2.txt")
if err != nil {
panic(err)
}
defer file.Close()
var col1, col2, col3 []string
for {
var v1, v2, v3 string
_, err := fmt.Fscanln(file, &v1, &v2, &v3)
// scans until newline
if err != nil {
break
}
col1 = append(col1, v1)
col2 = append(col2, v2)
col3 = append(col3, v3)
}
fmt.Println(col1)
fmt.Println(col2)
fmt.Println(col3)
}
注意: path 包里包含一个子包叫 filepath,这个子包提供了跨平台的函数,用于处理文件名和路径。例如 Base() 函数用于获得路径中的最后一个元素(不包含后面的分隔符)
func Base(path string) string
Base返回path的最后一个元素。在提取最后一个元素之前删除尾随路径分隔符。如果路径为空,Base返回"."。如果路径完全由分隔符组成,Base返回一个分隔符。
关于解析 CSV 文件,
encoding/csv包提供了相应的功能。
15.3 compress 包:读取压缩文件
compress 包提供了读取压缩文件的功能,支持的压缩文件格式为:bzip2、flate、gzip、lzw 和 zlib。
compress/gzip
gzip包实现了对gzip格式压缩文件的读写,如RFC 1952中所规定的。
类型
type Header struct {
Comment string // comment
Extra []byte // "extra data"
ModTime time.Time // modification time
Name string // file name
OS byte // operating system type
}
gzip文件存储关于压缩文件的元数据头。该头作为Writer和Reader结构的字段公开。
由于GZIP文件格式的限制,字符串必须是UTF-8编码的,并且只能包含Unicode编码点U+0001到U+00FF。
Reader
type Reader struct {
Header // valid after NewReader or Reader.Reset
// contains filtered or unexported fields
}
Reader 是io.Reader, 可以从gzip格式的压缩文件中读取未压缩数据的读取器
一般来说,gzip文件可以是gzip文件的串联,每个文件都有自己的头文件。从Reader读取的操作返回每个未压缩数据的串联。Reader字段中只记录第一个报头。
Gzip文件存储未压缩数据的长度和校验和
客户端应将Read返回的数据视为暂定数据,直到读到 io.EOF
func (z *Reader) Close() error
NewReader()
func NewReader(r io.Reader) (*Reader, error)
NewReader创建一个新的Reader,读取给定的Reader。如果r也没有实现io.ByteReader,解压缩器可能从r中读取比需要的更多的数据。
调用者有责任在完成Reader时调用Close。
Reader.Header 字段将在返回的Reader中有效
15.4 写文件
func main () {
// var outputWriter *bufio.Writer
// var outputFile *os.File
// var outputError os.Error
// var outputString string
outputFile, outputError := os.OpenFile("output.dat", os.O_WRONLY|os.O_CREATE, 0666)
if outputError != nil {
fmt.Printf("An error occurred with file opening or creation\n")
return
}
defer outputFile.Close()
outputWriter := bufio.NewWriter(outputFile)
outputString := "hello world!\n"
for i:=0; i<10; i++ {
outputWriter.WriteString(outputString)
}
outputWriter.Flush()
}