错误处理与测试

Go没有try/catch异常机制:不能执行抛异常操作。但是有一套defer-panic-and-recover机制。

通过在函数和方法中返回错误对象作为它们的唯一或最后一个返回值,如果返回nil,则没有错误发生,并且主调(calling)函数总是应该检查收到的错误。

处理错误并且在函数发生错误的地方给用户返回错误信息:照这样处理就算真的出了问题,程序也能继续运行并且通知给用户。panic and recover是用来处理真正的异常(无法预测的错误)而不是普通的错误。

Go检查和报告错误条件的惯有方式:

  • 产生错误的函数会返回两个变量,一个值和一个错误码;如果后者是 nil 就是成功,非 nil 就是发生了错误。
  • 为了防止发生错误时正在执行的函数(如果有必要的话甚至会是整个程序)被中止,在调用函数后必须检查错误。

错误处理

Go有一个预先定义的error接口类型

  1. type error interface {
  2. Error() string
  3. }

定义错误

任何时候当需要一个新的错误类型,都可以用errors包的errors.New函数接收合适的错误信息来创建,如:err := errors.New("xxx")

一个简单示例,errors.go:

  1. package main
  2. import (
  3. "errors"
  4. "fmt"
  5. )
  6. var errNotFound error = errors.New("Not found error")
  7. func main() {
  8. fmt.Printf("error: %v\n", errNotFound)
  9. }

可以把它用于计算平方根函数的参数测试:

  1. func Sqrt(f float64) (float64, error) {
  2. if f < 0 {
  3. return 0, errors.New("math - square root of negative number")
  4. }
  5. }

然后像下边这样调用Sqrt函数:

  1. if f, err := Sqrt(-1), err != nil {
  2. fmt.Printf("Error: %s\n", err)
  3. }

在大部分情况下自定义错误结构类型很有意义的,可以包含除了(低层级的)错误信息以外的其它有用信息,例如,正在进行的操作(打开文件等),全路径或名字。看下面例子中 os.Open 操作触发的 PathError 错误:

  1. type PathError struct {
  2. Op string
  3. Path string
  4. Err error
  5. }
  6. func (e *PathError) Error() string {
  7. return e.Op + " " + e.Path + " " + e.Err.Error()
  8. }

如果不通错误条件可能发生,那么对实际的错误使用类型断言或类型判断(type-switch)是很有用的,并且可以根据错误场景做一些补救和恢复操作

  1. switch err := err.(type){
  2. case ParseError:
  3. PrintParseError(err)
  4. case PathError:
  5. PrintPathError(err)
  6. ...
  7. default:
  8. fmt.Printf("Not a special error, just %s\n", err)
  9. }

考虑用json包的情况,当json.Decode在解析JSON文档发生语法错误时,指定返回一个SyntaxError类型的错误:

  1. type SyntaxError struct {
  2. msg string
  3. Offset int64
  4. }
  5. func (e *SyntaxError) Error() string { return e.msg }

在调用代码中可以像这样用类型断言测试错误是不是上面的类型:

  1. if serr, ok := err.(*json.SyntaxError); ok {
  2. line, col := findLine(f, serr.Offset)
  3. return fmt.Errorf("%s:%d:%d: %v", f.Name(), line, col, err)
  4. }

用fmt创建错误对象

通常想要返回包含错误参数的更有信息量的字符串,可以用fmt.Errorf()来实现,它和fmt.Printf()完全一样,和打印信息不同的是它用信息生成错误对象。

比如前面的平方根例子:

  1. if f < 0 {
  2. return 0, fmt.Errorf("math: square root of negative number %g", f)
  3. }

另一个例子,从命令行读取输入时,如果加了help标志,可以用有用的信息产生一个错误:

  1. if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") {
  2. err = fmt.Errorf("usage: %s infile.txt outfile.txt", filepath.Base(os.Args[0]))
  3. return
  4. }

运行时异常和panic

panic可以直接从代码初始化:当错误条件(所测试的代码)很严苛且不可恢复,程序不能继续运行时,可以使用panic函数产生一个终止程序的运行时错误。panic接收一个做任意类型的参数,通常是字符串,在程序死亡时被打印出来。

  1. func main() {
  2. fmt.Println("Staring")
  3. panic("A server error occurred")
  4. fmt.Println("Ending")
  5. }

在多层嵌套的函数调用中调用 panic,可以马上中止当前函数的执行,所有的 defer 语句都会保证执行并把控制权交还给接收到 panic 的函数调用者。这样向上冒泡直到最顶层,并执行(每层的) defer,在栈顶处程序崩溃,并在命令行中用传给 panic 的值报告错误情况:这个终止过程就是 panicking。

Recover(从panic恢复)

recover可以让程序从panicking重新获得控制权,停止终止过程进而恢复正常执行。

recover只能在defer修饰的函数中使用:用于取得panic调用中传递过来的错误值,如果是正常执行,调用recover会返回nil,且没有其他效果。

panic会导致栈被展开直到defer修饰的recover()被调用或者程序中止。

panic_recover.go:

  1. package main
  2. import "fmt"
  3. func badCall() {
  4. panic("bad end")
  5. }
  6. func test() {
  7. defer func() {
  8. if e := recover(); e != nil {
  9. fmt.Printf("Panicing %s\n", e)
  10. }
  11. }()
  12. badCall()
  13. fmt.Printf("After bad call\n")
  14. }
  15. func main() {
  16. fmt.Printf("Calling test\n")
  17. test()
  18. fmt.Printf("Test completed\n")
  19. }

结果:

  1. Calling test
  2. Panicing bad end
  3. Test completed

自定义包中的错误处理和panicking

所有自定义包实现者应该遵守的最佳实践:

  • 在包内部,总是应该从panic中recover: 不允许显式的超出包范围的panic()
  • 向包的调用者返回错误值(而不是panic)
  1. // parse.go
  2. package parse
  3. import (
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. )
  8. type ParseError struct {
  9. Index int
  10. Word string
  11. Err error
  12. }
  13. func (e *ParseError) String() string {
  14. return fmt.Sprintf("pkg parse: error parsing %q as int", e.Word)
  15. }
  16. func Parse(input string) (numbers []int, err error) {
  17. defer func() {
  18. if r := recover(); r != nil {
  19. var ok bool
  20. err, ok = r.(error)
  21. if !ok {
  22. err = fmt.Errorf("pkg: %v", r)
  23. }
  24. }
  25. }()
  26. fields := strings.Fields(input)
  27. numbers = fields2numbers(fields)
  28. return
  29. }
  30. func fields2numbers(fields []string) (numbers []int) {
  31. if len(fields) == 0 {
  32. panic("no words to parse")
  33. }
  34. for idx, field := range fields {
  35. num, err := strconv.Atoi(field)
  36. if err != nil {
  37. panic(&ParseError{idx, field, err})
  38. }
  39. numbers = append(numbers, num)
  40. }
  41. return
  42. }
  1. // panic_parse.go
  2. package main
  3. import (
  4. "fmt"
  5. "parse_panic/parse"
  6. )
  7. func main() {
  8. var examples = []string{
  9. "1 2 3 4 5",
  10. "100 50 25 12.5",
  11. "2 + 2 =4",
  12. "1st class",
  13. "",
  14. }
  15. for _, ex := range examples {
  16. fmt.Printf("Parsing %q:\n", ex)
  17. nums, err := parse.Parse(ex)
  18. if err != nil {
  19. fmt.Println(err)
  20. continue
  21. }
  22. fmt.Println(nums)
  23. }
  24. }

一种用闭包处理错误的模式

每当函数返回时,我们应该检查是否有错误发生:但是这会导致重复乏味的代码。结合 defer/panic/recover 机制和闭包可以得到一个我们马上要讨论的更加优雅的模式。不过这个模式只有当所有的函数都是同一种签名时可用,这样就有相当大的限制。一个很好的使用它的例子是 web 应用,所有的处理函数都是下面这样:

  1. func handler1(w http.ResponseWriter, r *http.Request) { ... }

假设所有的函数都有这样的签名:

  1. func f(a type1, b type2)

参数的数量和类型是不相关的。

给这个类型一个名字:

  1. fType1 = func f(a type1, b type2)

在模式中使用了两个帮助函数:

  1. check: 用来检查是否有错误和panic发生的函数:
  1. func check(err error) {if err != nil { panic(err) } }
  1. errorhandler: 一个包装函数,接收一个fType2类型的函数fn并返回一个调用fn的函数。里面就包含有defer/recover机制:
  1. func errorhandler(fn fType1) fType1 {
  2. return func(a type1, b type2) {
  3. defer func() {
  4. if err, ok := recover().(error); ok {
  5. log.Printf("run time panic: %v", err)
  6. }
  7. }()
  8. fn(a, b)
  9. }
  10. }

当错误发生时会recover并打印在日志中;除了简单的打印,应用也可以用template包为用户生成自定义的输出。check()函数会在所有的被调函数中调用:

  1. func f1(a type1, b type2) {
  2. ...
  3. f, _, err := // call function/method
  4. check(err)
  5. t, err1 := // call function/method
  6. check(err1)
  7. _, err2 := // call function/method
  8. check(err2)
  9. }

panci_defer.go:

  1. package main
  2. import "fmt"
  3. func main() {
  4. f()
  5. fmt.Println("Returned normally from f.")
  6. }
  7. func f() {
  8. defer func() {
  9. if r := recover(); r != nil {
  10. fmt.Println("Recovered in f", r)
  11. }
  12. }()
  13. fmt.Println("Calling g.")
  14. g(0)
  15. fmt.Println("Retruned normally from g.")
  16. }
  17. func g(i int) {
  18. if i > 3 {
  19. fmt.Println("Panicking!")
  20. panic(fmt.Sprintf("%v", i))
  21. }
  22. defer fmt.Println("Defer in g", i)
  23. fmt.Println("Printing in g", i)
  24. g(i + 1)
  25. }

启动外部命令和程序

os包有一个StartProcess函数可以调用或启动外部系统命令和二进制可执行文件;它的第一个参数是要运行的进程,第二个参数用来传递选项或参数,第三个参数是含有系统环境基本信息的结构体。这个函数返回被启动进程的id(pid),或启动失败返回错误。

exec包中也有同样功能的更简单的结构体和函数;主要是exec.Command(name string, arg …string) 和 Run()。首先需要系统命令或可执行文件的名字创建一个Command对象,然后用这个对象作为接收者调用Run()。

exec.go:

  1. package main
  2. import (
  3. "bytes"
  4. "fmt"
  5. "log"
  6. "os"
  7. "os/exec"
  8. )
  9. func main() {
  10. // osStart()
  11. execRun()
  12. }
  13. func osStart() {
  14. env := os.Environ()
  15. procAttr := &os.ProcAttr{
  16. Env: env,
  17. Files: []*os.File{
  18. os.Stdin,
  19. os.Stdout,
  20. os.Stderr,
  21. },
  22. }
  23. pid, err := os.StartProcess("/bin/ls", []string{"ls", "-l"}, procAttr)
  24. if err != nil {
  25. fmt.Printf("Error %v staring process!", err)
  26. os.Exit(1)
  27. }
  28. fmt.Printf("The process id is %v", pid)
  29. // show all processes
  30. pid, err = os.StartProcess("/bin/ps", []string{"ps", "-e", "-opid,ppid,comm"}, procAttr)
  31. if err != nil {
  32. fmt.Printf("Error %v starting process!", err)
  33. os.Exit(1)
  34. }
  35. fmt.Printf("The process id is %v", pid)
  36. }
  37. func execRun() {
  38. // 只执行命令,不获取结果
  39. cmd := exec.Command("pwd")
  40. err := cmd.Run()
  41. if err != nil {
  42. fmt.Printf("Error %v executing command!", err)
  43. os.Exit(1)
  44. }
  45. fmt.Printf("The command is %v", cmd)
  46. // 执行命令,并获取结果
  47. cmd = exec.Command("ls", "-l", "/var/log")
  48. out, err := cmd.CombinedOutput()
  49. if err != nil {
  50. fmt.Printf("combined out: \n %s\n", string(out))
  51. log.Fatalf("cmd.Run() failed with %s\n", err)
  52. }
  53. fmt.Printf("combined out: \n%s\n", string(out))
  54. // 执行命令,并区分stdout和stderr
  55. cmd = exec.Command("ls", "-l", "/var/log/*.log")
  56. var stdout, stderr bytes.Buffer
  57. cmd.Stdout = &stdout
  58. cmd.Stderr = &stderr
  59. err = cmd.Run()
  60. outStr, errStr := string(stdout.Bytes()), string(stderr.Bytes())
  61. fmt.Printf("out: \n%s \nerr:\n%s\n", outStr, errStr)
  62. if err != nil {
  63. log.Fatalf("cmd.Run() failed with %s\n", err)
  64. }
  65. // 多条命令组合
  66. c1 := exec.Command("grep", "failed", "/var/log/system.log")
  67. c2 := exec.Command("wc", "-l")
  68. c2.Stdin, _ = c1.StdoutPipe()
  69. c2.Stdout = os.Stdout
  70. _ = c2.Start()
  71. _ = c1.Run()
  72. _ = c2.Wait()
  73. // 设置命令级别的环境变量
  74. os.Setenv("NAME", "test")
  75. cmd := exec.Command("echo", os.ExpandEnv("$NAME"))
  76. out, err := cmd.CombinedOutput()
  77. if err != nil {
  78. log.Fatalf("cmd.Run() failed with %s\n", err)
  79. }
  80. fmt.Printf("%s", out)
  81. }

单元测试和基准测试

名为testing的包被专门用来进行自动化测试,日志和错误报告。并且还包含一些基准测试函数的功能。

对一个包做(单元)测试,需要写一些可以频繁(每次更新后)执行的小块测试单元来检查代码的正确性。于是必须写一些Go源文件来测试代码,测试程序必须属于被测试的包,并且文件名满足这种形式*_test.go,所以测试代码和包中的业务代码是分开的。

_test程序不会被普通的Go编译器编译,所以当放应用部署到生产环境时它们不会被部署;只有gotest会编译所有的程序:普通程序和测试程序。

测试文件中必须导入“testing”包,并写一些名字以TestXXX打头的全局函数,如TestFmtInterface,TestPayEmployees等。

备注:gotest 是 Unix bash 脚本,所以在 Windows 下你需要配置 MINGW 环境(参见 2.5 节);在 Windows 环境下把所有的 pkg/linux_amd64 替换成 pkg/windows。

测试函数必须有这种形式的头部:

  1. 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来编译测试程序,并执行程序中所有的TestXXX函数,如果所有的测试都通过会打印出PASS;

even.go:

  1. package even
  2. func Even(i int) bool {
  3. return i%2 == 0
  4. }
  5. func Odd(i int) bool {
  6. return i%2 != 0
  7. }

oddeven_test.go:

  1. package even
  2. import "testing"
  3. func TestEven(t *testing.T) {
  4. // if !Even(10) {
  5. // t.Log("10 must be even")
  6. // t.Fail()
  7. // }
  8. // if Even(7) {
  9. // t.Log("7 is not even")
  10. // t.Fail()
  11. // }
  12. if Even(10) {
  13. t.Log("just a test")
  14. t.Fail()
  15. }
  16. }
  17. func TestOdd(t *testing.T) {
  18. if !Odd(11) {
  19. t.Log("11 must be odd!")
  20. t.Fail()
  21. }
  22. if Odd(10) {
  23. t.Log("10 is not odd")
  24. t.Fail()
  25. }
  26. }

even_main.go:

  1. package main
  2. import (
  3. "fmt"
  4. "testing/even"
  5. )
  6. func main() {
  7. for i := 0; i < 100; i++ {
  8. fmt.Printf("Is the interger %d even? %v\n", i, even.Even(i))
  9. }
  10. }

性能调试:分析并优化Go程序

用 go test 调试

如果代码使用了 Go 中 testing 包的基准测试功能,我们可以用 gotest 标准的 -cpuprofile 和 -memprofile 标志向指定文件写入 CPU 或 内存使用情况报告。

使用方式:go test -x -v -cpuprofile=prof.out -file x_test.go

用pprof调试

可以在单机程序progexec中引入runtime/pprof包;这个包以pprof可视化工具需要的格式写入运行时报告数据。对于CPU性能分析来说需要添加一些代码:

  1. var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
  2. func main() {
  3. flag.Parse()
  4. if *cpuprofile != "" {
  5. f, err := os.Create(*cpuprofile)
  6. if err != nil {
  7. log.Fatal(err)
  8. }
  9. pprof.StartCPUProfile(f)
  10. defer pprof.StopCPUProfile()
  11. }
  12. }
  13. ...

代码定义了一个名为 cpuprofile 的 flag,调用 Go flag 库来解析命令行 flag,如果命令行设置了 cpuprofile flag,则开始 CPU 性能分析并把结果重定向到那个文件(os.Create 用拿到的名字创建了用来写入分析数据的文件)。这个分析程序最后需要在程序退出之前调用 StopCPUProfile 来刷新挂起的写操作到文件中;我们用 defer 来保证这一切会在 main 返回时触发。

现在用这个 flag 运行程序:progexec -cpuprofile=progexec.prof

然后可以像这样用 gopprof 工具:gopprof progexec progexec.prof