# 8. GO语言错误处理与单元测试
# 8.1 错误处理
Go语言的错误处理思想及设计包含以下特征:
- 一个可能造成错误的函数,需要返回值中返回一个错误接口(error),如果调用是成功的,错误接口将返回 nil,否则返回错误。
- 在函数调用后需要检查错误,如果发生错误,则进行必要的错误处理。
Go语言没有类似 Java 或 .NET 中的异常处理机制,虽然可以使用 defer、panic、recover 模拟,但官方并不主张这样做,Go语言的设计者认为其他语言的异常机制已被过度使用,上层逻辑需要为函数发生的异常付出太多的资源,同时,如果函数使用者觉得错误处理很麻烦而忽略错误,那么程序将在不可预知的时刻崩溃。
Go语言希望开发者将错误处理视为正常开发必须实现的环节,正确地处理每一个可能发生错误的函数,同时,Go语言使用返回值返回错误的机制,也能大幅降低编译器、运行时处理错误的复杂度,让开发者真正地掌握错误的处理。
error 是 Go 系统声明的接口类型,代码如下:
type error interface {
Error() string
}
所有符合 Error() string
格式的方法,都能实现错误接口,Error() 方法返回错误的具体描述,使用者可以通过这个字符串知道发生了什么错误。
任何时候当你需要一个新的错误类型,都可以用 errors
(必须先 import)包的 errors.New
函数接收合适的错误信息来创建,像下面这样:
error := errors.New("division by zero")
错误字符串由于相对固定,一般在包作用域声明,应尽量减少在使用时直接使用 errors.New 返回。
在代码中使用错误定义示例:
import (
"errors"
"fmt"
)
//定义一个错误信息
var myError = errors.New("division by zero")
//定义一个除法函数,返回值带 error 值
func div(x, y int) (int, error) {
if y == 0 {
return 0, myError
}
return x / y, nil
}
func main() {
fmt.Println(div(12, 0))
}
代码运行结果:
0 division by zero
使用 errors.New 定义的错误字符串的错误类型是无法提供丰富的错误信息的,那么,如果需要携带错误信息返回,就需要借助自定义结构体实现错误接口。下面代码将实现一个解析错误(ParseError),这种错误包含两个内容,分别是文件名和行号,解析错误的结构还实现了 error 接口的 Error() 方法,返回错误描述时,就需要将文件名和行号返回,代码示例:
import (
"fmt"
)
// 声明一个解析错误
type ParseError struct {
Filename string // 文件名
Line int // 行号
}
// 实现error接口,返回错误描述
func (e *ParseError) Error() string {
return fmt.Sprintf("%s:%d", e.Filename, e.Line)
}
// 创建一些解析错误
func newParseError(filename string, line int) error {
return &ParseError{filename, line}
}
func main() {
var e error
// 创建一个错误实例,包含文件名和行号
e = newParseError("main.go", 1)
// 通过error接口查看错误描述
fmt.Println(e.Error())
// 根据错误接口具体的类型,获取详细错误信息
switch detail := e.(type) {
case *ParseError: // 这是一个解析错误
fmt.Printf("Filename: %s Line: %d\n", detail.Filename, detail.Line)
default: // 其他类型的错误
fmt.Println("other error")
}
}
编译运行结果:
main.go:1
Filename: main.go Line: 1
错误对象都要实现 error 接口的 Error() 方法,这样,所有的错误都可以获得字符串的描述,如果想进一步知道错误的详细信息,可以通过类型断言,将错误对象转为具体的错误类型进行错误详细信息的获取。
# 8.2 panic与recover
Go没有像Java那样的异常机制,它不能抛出异常,而是使用了panic
和recover
机制。一定要记住,你应当把它作为最后的手段来使用,也就是说,你的代码中应当没有,或者很少有panic
的东西。这是个强大的工具,请明智地使用它。那么,我们应该如何使用它呢?
Panic
是一个内建函数,可以中断原有的控制流程,进入一个令人恐慌的流程中。当 panic() 触发的宕机发生时,panic() 后面的代码将不会被运行,但是在 panic() 函数前面已经运行过的 defer 语句依然会在宕机发生时发生作用,参考下面代码:
import "fmt"
func div(x, y int) int {
if y == 0 {
panic("division by zero")
}
return x / y
}
func main() {
defer fmt.Println("宕机后要做的事情")
i := div(4, 0)
fmt.Println(i)
}
代码输出如下:
宕机后要做的事情
panic: division by zero
goroutine 1 [running]:
main.div(...)
D:/wamp64/www/golangStudyDiary/00-Go基础知识/codes/package.go:14
main.main()
D:/wamp64/www/golangStudyDiary/00-Go基础知识/codes/package.go:21 +0x9d
exit status 2
宕机前,defer 语句会被优先执行,由于第 7 行的 defer 后执行,因此会在宕机前,这个 defer 会优先处理,随后才是第 6 行的 defer 对应的语句,这个特性可以用来在宕机发生前进行宕机信息处理。
Recover
是一个Go语言的内建函数,可以让进入宕机流程中的 goroutine 恢复过来,recover 仅在延迟函数 defer 中有效,在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果,如果当前的 goroutine 陷入恐慌,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行。
代码示例:
import "fmt"
func test() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover")
}
}()
panic("panic")
fmt.Println("After panic")
}
func main() {
test()
fmt.Println("main end")
}
代码输出如下:
recover
main end
panic 和 recover 的组合有如下特性:
- 有 panic 没 recover,程序宕机。
- 有 panic 也有 recover,程序不会宕机,执行完对应的 defer 后,从宕机点退出当前函数后继续执行。
虽然 panic/recover 能模拟其他语言的异常机制,但并不建议在编写普通函数时也经常性使用这种特性。在 panic 触发的 defer 函数内,可以继续调用 panic,进一步将错误外抛,直到程序整体崩溃。如果想在捕获错误时设置当前函数的返回值,可以对返回值使用命名返回值方式直接进行设置。
# 8.3 代码测试
Go语言中的测试依赖go test命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。
go 默认以 _test.go
后缀代表测试文件,比如源文件 upload.go
, 测试文件为 upload_test.go
。必须导入 testing
包,不需要 main
函数。
测试文件有三种类型的函数,单元测试函数、基准测试函数和示例函数:
类型 | 格式 | 作用 |
---|---|---|
测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
示例函数 | 函数名前缀为Example | 为文档提供示例文档 |
单元测试
在同一文件夹下创建两个Go语言文件,分别命名为 demo.go 和 demt_test.go,目录结构如下:
unit demo.go demo_test.go
具体代码如下所示:
demo.go:
package unit func GetTest1(r float64) float64 { return r } func GetTest2(r float64) float64 { return r } func GetTest3(r float64) float64 { return r }
demo_test.go:
package unit import ( "fmt" "testing" ) func TestGetTest1(t *testing.T) { area := GetTest1(3.2) fmt.Println(area) } func TestGetTest2(t *testing.T) { area := GetTest2(1.1) fmt.Println(area) } func TestGetTest3(t *testing.T) { area := GetTest3(4) fmt.Println(area) }
执行测试命令,运行结果如下所示:
D:\wamp64\www\golangStudyDiary\00-Go基础知识\codes\unit>go test -v === RUN TestGetTest1 3.2 --- PASS: TestGetTest1 (0.00s) === RUN TestGetTest2 1.1 --- PASS: TestGetTest2 (0.00s) === RUN TestGetTest3 4 --- PASS: TestGetTest3 (0.00s) PASS ok github.com/xjx1234/golangStudyDiary/00-Go基础知识/codes/unit 0.241s
在本例子中,使用
go test
完成了测试,go test 命令,会自动读取源码目录下面名为 *_test.go 的文件,生成并运行测试用的可执行文件。关于
go test
还有以下命令:go test
运行所有单元测试,不含基准测试go test -v
测试并输出详细信息go test -v -run TestMethod filename
指定文件和指定方法测试, 例如:go test -v -run TestGetTest2 demo_test.go
go test -v -run TestMethodFirst filename
指定文件和匹配 开头方法测试,例如:go test -v -run TestG demo_test.go
在指定文件执行时候,例如执行
go test -v -run TestG demo_test.go
时候,运行输出:D:\wamp64\www\golangStudyDiary\00-Go基础知识\codes\unit>go test -v -run TestG demo_test.go # command-line-arguments [command-line-arguments.test] .\demo_test.go:16:10: undefined: GetTest1 .\demo_test.go:21:10: undefined: GetTest2 .\demo_test.go:26:10: undefined: GetTest3 FAIL command-line-arguments [build failed]
报错根本原因是因为构建失败,我们应该就能看出一下信息。go test与其他的指定源码文件进行编译或运行的命令程序一样(参考:go run和go build),会为指定的源码文件生成一个虚拟代码包——“command-line-arguments”,对于运行这次测试的命令程序来说,测试源码文件getinfo_test.go是属于代码包“command-line-arguments”的,可是它引用了其他包中的数据并不属于代码包“command-line-arguments”,编译不通过,错误自然发生了。
知道了原因之后,解决的方法就出来了,执行命令时加入这个测试文件需要引用的源码文件,在命令行后方的文件都会被加载到command-line-arguments中进行编译。正确示例如下:
go test -v -run TestG demo_test.go demo.go
每个测试用例可能并发执行,使用 testing.T 提供的日志输出可以保证日志跟随这个测试上下文一起打印输出。testing.T 提供了几种日志输出方法,详见下表所示:
方 法 备 注 Log 打印日志,同时结束测试 Logf 格式化打印日志,同时结束测试 Error 打印错误日志,同时结束测试 Errorf 格式化打印错误日志,同时结束测试 Fatal 打印致命日志,同时结束测试 Fatalf 格式化打印致命日志,同时结束测试 示例:
func TestGetTest1(t *testing.T) { area := GetTest1(3.2) if area < 0 { t.Fail() } }
基准测试
基准测试可以测试一段程序的运行性能及耗费 CPU 的程度。Go语言中提供了基准测试框架,使用方法类似于单元测试,使用者无须准备高精度的计时器和各种分析工具,基准测试本身即可以打印出非常标准的测试报告。
示例:
package unit import ( "fmt" "strconv" "testing" ) //fmt.Sprintf函数基准测试 func BenchmarkSprintf(b *testing.B) { num := 10 b.ResetTimer() for i := 0; i < b.N; i++ { fmt.Sprintf("%d", num) } } //strconv.FormatInt函数基准测试 func BenchmarkFormat(b *testing.B) { num := int64(10) b.ResetTimer() for i := 0; i < b.N; i++ { strconv.FormatInt(num, 10) } } //strconv.Itoa函数基准测试 func BenchmarkItoa(b *testing.B) { num := 10 b.ResetTimer() for i := 0; i < b.N; i++ { strconv.Itoa(num) } }
使用如下命令行开启基准测试:
D:\wamp64\www\golangStudyDiary\00-Go基础知识\codes\unit>go test -bench=. benchmark_test.go -v -run=none goos: windows goarch: amd64 BenchmarkSprintf-4 20000000 101 ns/op BenchmarkFormat-4 500000000 3.16 ns/op BenchmarkItoa-4 500000000 3.28 ns/op PASS ok command-line-arguments 6.259s
其中结果中
20000000
500000000
之类的数据代表测试次数,101 ns/op
类似数据 代表 每一个操作耗费多少时间(纳秒)。相关 go test -bench 命令如下:
go test -bench=. Filename -v -run=none
运行所有基准测试,不运行单元测试,其中Filename 可以为空go test -bench=. Filename -benchtime=3s -v
指定运行时间为 3 秒,其中Filename 可以为空go test -bench=BenchMethod
指定运行某个测试