# 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那样的异常机制,它不能抛出异常,而是使用了panicrecover机制。一定要记住,你应当把它作为最后的手段来使用,也就是说,你的代码中应当没有,或者很少有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 指定运行某个测试
最后更新时间: 3/2/2023, 4:55:04 PM