错误处理,调试和测试 - Go Web 开发实战笔记

1,962 阅读16分钟

概述

开发 Web 应用过程中,错误自然难免,开发者培养良好的处理错误、调试和测试习惯,能有效的提高开发效率,保证产品质量。

错误处理

Go 语言定义了一个叫做 error 的类型来显式表达错误,在使用时,通过把返回的 error 变量与 nil 进行比较来判定操作是否成功。

例如 os.Open 函数在打开文件失败时将返回一个不为 nil 的 error 变量:

func Open(name string) (file *File, err error)

使用示例:

package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	f, err := os.Open("filename.ext")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(f)
}

执行以上代码,因为 filename.ext 文件不存在,控制台输出:

2019/07/30 14:52:51 open filename.ext: no such file or directory

类似于 os.Open 函数,标准包中所有可能出错的 API 都会返回一个 error 变量,以方便错误处理。

Error 类型

error 类型是一个接口类型,这是它的定义:

type error interface {
    Error() string
}

以下是 Go 语言 errors 包中的 New 函数的实现:

// Package errors implements functions to manipulate errors.
package errors

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}
  • errorString 是一个结构体类型,只有一个字符串字段 s。
  • 使用了 errorString 指针接受者(Pointer Receiver),来实现 error 接口的 Error() string 方法。
  • New 函数有一个字符串参数,通过这个参数创建了 errorString 类型的变量,并返回了它的地址,于是它就创建并返回了一个新的错误。

如何使用 errors.New 的示例:

package main

import (
	"errors"
	"fmt"
	"math"
)

func Sqrt(f float64) (float64, error) {
	if f < 0 {
		return 0, errors.New("math: square root of negative number")
	} else {
		return math.Sqrt(f), nil
	}
}

func main() {
	f, err := Sqrt(-1)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println(f)
}

执行以上代码,因为 -1 小于 0,所以控制台输出:

math: square root of negative number
0

如果把 -1 换成 4,控制台输出:

2

自定义 Error

error 是一个 interface,所以在实现自己的包时,通过定义实现此接口的结构,就可以实现自己的错误定义。

示例:

package main

import (
	"fmt"
	"math"
)

type SyntaxError struct {
	msg    string // 错误描述
	Offset int64  // 错误发生的位置
}

func (e *SyntaxError) Error() string { return e.msg }

// 求平方根
func Sqrt(f float64) (float64, error) {
	if f < 0 {
		return 0, &SyntaxError{"负数没有平方根", 24}
	} else {
		return math.Sqrt(f), nil
	}
}

func main() {
	var fm float64 = -1
	f, err := Sqrt(fm)
	if err != nil {
		if err, ok := err.(*SyntaxError); ok {
			fmt.Printf("错误: 第 %v 行有误,%v。\n", err.Offset, err.msg)
		}
		return
	}
	fmt.Println(f)
}

执行以上代码,因为 -1 小于 0,所以控制台输出:

错误: 第 24 行有误,负数没有平方根。

处理错误

Go 在错误处理上采用了与 C 类似的检查返回值的方式,而不是其它多数主流语言采用的异常方式,这造成了代码编写上的一个很大的缺点:错误处理代码的冗余,可以通过复用检测函数来减少类似处理错误的代码。

例如:

func init() {
    http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

上面的例子中获取数据和模板展示调用时都有检测错误,当有错误发生时,调用了统一的处理函数 http.Error,返回给客户端 500 错误码,并显示相应的错误数据。但是当越来越多的 HandleFunc 加入之后,这样的错误处理逻辑代码就会越来越多,其实可以通过自定义路由器来缩减代码。

可以自定义 HTTP 处理 appHandler 类型,包括返回一个 error 值来减少重复:

type appHandler func(http.ResponseWriter, *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

ServeHTTP 方法调用 appHandler 函数,并且显示返回的错误(如果有的话)。注意这个方法的接收者——fn,是一个函数。(Go 可以这样做!)方法调用表达式 fn(w, r) 中定义的接收者。

现在当向 http 包注册了 viewRecord,就可以使用 Handle 函数(代替 HandleFunc)appHandler 作为一个 http.Handler(而不是一个 http.HandlerFunc)。

func init() {
    http.Handle("/view", appHandler(viewRecord))
}

当请求 /view 的时候,逻辑处理变成如下代码,和第一种实现方式相比较已经简单了很多。

func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

上面的例子错误处理的时候所有的错误返回给用户的都是 500 错误码,然后打印出来相应的错误代码,其实可以把这个错误信息定义的更加友好,调试的时候也方便定位问题,可以自定义返回的错误类型:

type appError struct {
    Error   error
    Message string
    Code    int
}

自定义路由器改成如下方式:

type appHandler func(http.ResponseWriter, *http.Request) *appError

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if e := fn(w, r); e != nil { // e is *appError, not os.Error.
        c := appengine.NewContext(r)
        c.Errorf("%v", e.Error)
        http.Error(w, e.Message, e.Code)
    }
}

修改完自定义错误之后,逻辑处理改成如下方式:

func viewRecord(w http.ResponseWriter, r *http.Request) *appError {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return &appError{err, "Record not found", 404}
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        return &appError{err, "Can't display record", 500}
    }
    return nil
}

如上所示,在访问 view 的时候可以根据不同的情况获取不同的错误码和错误信息,虽然这个和第一个版本的代码量差不多,但是这个显示的错误更加明显,提示的错误信息更加友好,扩展性也比第一个更好。

使用 GDB 调试

Go 内部内置支持 GDB,可以使用 GDB 进行调试。

GDB 调试简介

GDB 是 FSF (自由软件基金会)发布的一个强大的类 UNIX 系统下的程序调试工具。使用 GDB 可以做如下事情:

  1. 启动程序,可以按照开发者的自定义要求运行程序。
  2. 可让被调试的程序在开发者设定的调置的断点处停住。(断点可以是条件表达式)
  3. 当程序被停住时,可以检查此时程序中所发生的事。
  4. 动态的改变当前程序的执行环境。

编译Go程序的时候需要注意以下几点

  1. 传递参数 -ldflags "-s",忽略 debug 的打印信息
  2. 传递 -gcflags "-N -l" 参数,这样可以忽略 Go 内部做的一些优化,聚合变量和函数等优化,这样对于 GDB 调试来说非常困难,所以在编译的时候加入这两个参数避免这些优化。

常用命令

GDB 的一些常用命令如下所示:

  • list
    简写命令 l,用来显示源代码,默认显示十行代码,后面可以带上参数显示的具体行,例如:list 15,显示十行代码,其中第 15 行在显示的十行里面的中间,如下所示。

    10          time.Sleep(2 * time.Second)
    11          c <- i
    12      }
    13      close(c)
    14  }
    15  
    16  func main() {
    17      msg := "Starting main"
    18      fmt.Println(msg)
    19      bus := make(chan int)
    
  • break
    简写命令 b,用来设置断点,后面跟上参数设置断点的行数,例如 b 10 在第十行设置断点。

  • delete
    简写命令 d,用来删除断点,后面跟上断点设置的序号,这个序号可以通过 info breakpoints 获取相应的设置的断点序号,如下是显示的设置断点序号:

    Num     Type           Disp Enb Address            What
    2       breakpoint     keep y   0x0000000000400dc3 in main.main at /home/xiemengjun/gdb.go:23
    breakpoint already hit 1 time
    
  • backtrace
    简写命令 bt,用来打印执行的代码过程,如下所示:

    #0  main.main () at /home/xiemengjun/gdb.go:23
    #1  0x000000000040d61e in runtime.main () at /home/xiemengjun/go/src/pkg/runtime/proc.c:244
    #2  0x000000000040d6c1 in schedunlock () at /home/xiemengjun/go/src/pkg/runtime/proc.c:267
    #3  0x0000000000000000 in ?? ()
    
  • info
    info 命令用来显示信息,后面有几种参数,我们常用的有如下几种:

    • info locals
      显示当前执行的程序中的变量值

    • info breakpoints
      显示当前设置的断点列表

    • info goroutines
      显示当前执行的goroutine列表,如下代码所示,带*的表示当前执行的

    * 1  running runtime.gosched
    * 2  syscall runtime.entersyscall
      3  waiting runtime.gosched
      4 runnable runtime.gosched
    
  • print
    简写命令 p,用来打印变量或者其他信息,后面跟上需要打印的变量名,当然还有一些很有用的函数 $len()$cap(),用来返回当前 string、slices 或者 maps 的长度和容量。

  • whatis
    用来显示当前变量的类型,后面跟上变量名,例如 whatis msg,显示如下:

    type = struct string
    
  • next
    简写命令 n,用来单步调试,跳到下一步,当有断点之后,可以输入n跳转到下一步继续执行。

  • coutinue
    简称命令 c,用来跳出当前断点处,后面可以跟参数N,跳过多少次断点。

  • next
    该命令用来改变运行过程中的变量值,格式如:set variable =

Gdb 安装

最快捷的方法是使用brew来安装,命令如下:

brew install gdb

安装完后,如果 MAC 系统调试程序会遇到如下错误:

(gdb) run
Starting program: /usr/local/bin/xxx
Unable to find Mach task port for process-id 28885: (os/kern) failure (0x5).
 (please check gdb is codesigned - see taskgated(8))

这是因为 Darwin 内核在你没有特殊权限的情况下,不允许调试其他进程。调试某个进程,意味着对这个进程有完全的控制权限。所以出于安全考虑默认是禁止的。所以允许 gdb 控制其它进程最好的方法就是用系统信任的证书对它进行签名。
具体请查看 GDB Wiki:sourceware.org/gdb/wiki/Bu…

Gdb 调试

通过下面这个代码来演示如何通过 GDB 来调试 Go 程序,下面是将要演示的代码:

package main

import (
    "fmt"
    "time"
)

func counting(c chan<- int) {
    for i := 0; i < 10; i++ {
        time.Sleep(2 * time.Second)
        c <- i
    }
    close(c)
}

func main() {
    msg := "Starting main"
    fmt.Println(msg)
    bus := make(chan int)
    msg = "starting a gofunc"
    go counting(bus)
    for count := range bus {
        fmt.Println("count:", count)
    }
}

编译文件,生成可执行文件gdbfile:

go build -gcflags "-N -l" gdbfile.go

然后通过 gdb 命令启动调试:

gdb gdbfile

启动之后首先看看这个程序是不是可以运行起来,只要输入 run 命令回车后程序就开始运行,程序正常的话可以看到程序输出如下,和在命令行直接执行程序输出是一样的:

(gdb) run
Starting program: /Users/play/goweb/src/error/gdbfile
[New Thread 0x1903 of process 4325]
Starting main
count: 0
count: 1
count: 2
count: 3
count: 4
count: 5
count: 6
count: 7
count: 8
count: 9
[Inferior 1 (process 4325) exited normally]

现在程序已经跑起来了,接下来开始给代码设置断点:

(gdb) b 23
Breakpoint 1 at 0x108e0f5: file /Users/play/goweb/src/error/gdbfile.go, line 23.
(gdb) run
Starting program: /Users/play/goweb/src/error/gdbfile
[New Thread 0x2503 of process 4519]
[New Thread 0x2303 of process 4519]
Starting main
[New Thread 0x1803 of process 4519]
[New Thread 0x1903 of process 4519]
[New Thread 0x2203 of process 4519]
[New Thread 0x240f of process 4519]
[Switching to Thread 0x240f of process 4519]

Thread 6 hit Breakpoint 1, main.main () at /Users/play/goweb/src/error/gdbfile.go:23
23			fmt.Println("count:", count)

上面例子 b 23 表示在第23行设置了断点,之后输入 run 开始运行程序。现在程序在前面设置断点的地方停住了,如果需要查看断点相应上下文的源码,输入 list 就可以看到源码显示从当前停止行的前五行开始:

(gdb) list
18		fmt.Println(msg)
19		bus := make(chan int)
20		msg = "starting a gofunc"
21		go counting(bus)
22		for count := range bus {
23			fmt.Println("count:", count)
24		}
25	}

现在 GDB 在运行当前的程序的环境中已经保留了一些有用的调试信息,只需打印出相应的变量,查看相应变量的类型及值:

(gdb) info locals
count = 0
bus = 0xc420078060
msg = 0x10c107a "starting a gofunc"
(gdb) p count
$1 = 0
(gdb) p bus
$2 = (chan int) 0xc420078060
(gdb) whatis bus
type = chan int

接下来该让程序继续往下执行,继续下面的命令:

(gdb) c
Continuing.
count: 0

Thread 6 hit Breakpoint 1, main.main () at /Users/play/goweb/src/error/gdbfile.go:23
23			fmt.Println("count:", count)
(gdb) c
Continuing.
count: 1

Thread 6 hit Breakpoint 1, main.main () at /Users/play/goweb/src/error/gdbfile.go:23
23			fmt.Println("count:", count)
(gdb)

每次输入c之后都会执行一次代码,又跳到下一次for循环,继续打印出来相应的信息。

设想目前需要改变上下文相关变量的信息,跳过一些过程,并继续执行下一步,得出修改后想要的结果:

(gdb) info locals
count = 2
bus = 0xc420078060
msg = 0x10c107a "starting a gofunc"
(gdb) set variable count=9
(gdb) info locals
count = 9
bus = 0xc420078060
msg = 0x10c107a "starting a gofunc"
(gdb) c
Continuing.
count: 9
[Switching to Thread 0x2303 of process 4519]

Thread 2 hit Breakpoint 1, main.main () at /Users/play/goweb/src/error/gdbfile.go:23
23			fmt.Println("count:", count)

最后查看前面整个程序运行的过程中到底创建了多少个 goroutine,每个 goroutine 都在做什么:

(gdb) info goroutines
* 1 running  runtime.gopark
  2 waiting  runtime.gopark
  3 waiting  runtime.gopark
  4 waiting  runtime.gopark
  5 waiting  runtime.gopark
* 6 syscall  runtime.systemstack_switch
  17 waiting  runtime.gopark
  33 waiting  runtime.gopark
(gdb) goroutine 1 bt
#0  runtime.mach_semaphore_wait () at /usr/local/go/src/runtime/sys_darwin_amd64.s:540
#1  0x0000000001024342 in runtime.semasleep1 (ns=-1, ~r1=0)
    at /usr/local/go/src/runtime/os_darwin.go:438
#2  0x000000000104a5f3 in runtime.semasleep.func1 ()
    at /usr/local/go/src/runtime/os_darwin.go:457
#3  0x0000000001024474 in runtime.semasleep (ns=-1, ~r1=3)
    at /usr/local/go/src/runtime/os_darwin.go:456
#4  0x000000000100c869 in runtime.notesleep (n=0xc420034548)
    at /usr/local/go/src/runtime/lock_sema.go:167
#5  0x000000000102bd55 in runtime.stopm () at /usr/local/go/src/runtime/proc.go:1952
#6  0x000000000102cf1c in runtime.findrunnable (gp=0xc420000180, inheritTime=false)
    at /usr/local/go/src/runtime/proc.go:2415
#7  0x000000000102da2b in runtime.schedule ()
    at /usr/local/go/src/runtime/proc.go:2541
#8  0x000000000102dd56 in runtime.park_m (gp=0xc420000180)
    at /usr/local/go/src/runtime/proc.go:2604
#9  0x000000000104bd3b in runtime.mcall ()
    at /usr/local/go/src/runtime/asm_amd64.s:351
#10 0x0000000000000000 in ?? ()

编写测试用例

开发程序其中很重要的一点是测试,Go 语言中自带有一个轻量级的测试框架 testing 和自带的 go test 命令来实现单元测试和性能测试。

如何编写测试用例

由于 go test 命令只能在一个相应的目录下执行所有文件,所以接下来新建一个项目目录 gotest,这样所有的代码和测试代码都在这个目录下。

接下来在该目录下面创建两个文件:gotest.go 和 gotest_test.go

  1. gotest.go 文件里面创建了一个包,里面有一个函数实现了除法运算:
package gotest

import (
	"errors"
)

// 除法
func Division(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("除数不能为0")
	}
	return a / b, nil
}
  1. gotest_test.go 单元测试文件,记住下面的这些原则:

    • 文件名必须是 _test.go 结尾的,这样在执行 go test 的时候才会执行到相应的代码
    • 你必须 import testing 这个包
    • 所有的测试用例函数必须是 Test 开头
    • 测试用例会按照源代码中写的顺序依次执行
    • 测试函数 TestXxx() 的参数是 testing.T,这样可以使用该类型来记录错误或者是测试状态
    • 测试格式:func TestXxx (t *testing.T),Xxx 部分可以为任意的字母数字的组合,但是首字母不能是小写字母[a-z],例如 Testintdiv 是错误的函数名。
    • 函数中通过调用 testing.T 的 Error,Errorf,FailNow,Fatal,FatalIf 方法,说明测试不通过,调用 Log 方法用来记录测试的信息。

下面是测试用例的代码:

package gotest

import (
	"testing"
)

func Test_Division_1(t *testing.T) {
	if i, e := Division(6, 2); i != 3 || e != nil { //try a unit test on function
		t.Error("除法函数测试没通过") // 如果不是如预期的那么就报错
	} else {
		t.Log("第一个测试通过了") //记录一些你期望记录的信息
	}
}

func Test_Division_2(t *testing.T) {
	t.Error("就是不通过")
}

在项目目录下面执行 go test,就会显示如下信息:

--- FAIL: Test_Division_2 (0.00s)
	gotest_test.go:16: 就是不通过
FAIL
exit status 1
FAIL	_/Users/play/goweb/src/error/gotest	0.005s

从这个结果显示测试没有通过,因为在第二个测试函数中写死了测试不通过的代码 t.Error,那么第一个函数执行的情况怎么样呢?默认情况下执行 go test 是不会显示测试通过的信息的,需要带上参数 go test -v,这样就会显示如下信息:

=== RUN   Test_Division_1
--- PASS: Test_Division_1 (0.00s)
	gotest_test.go:11: 第一个测试通过了
=== RUN   Test_Division_2
--- FAIL: Test_Division_2 (0.00s)
	gotest_test.go:16: 就是不通过
FAIL
exit status 1
FAIL	_/Users/play/goweb/src/error/gotest	0.005s

上面的输出详细的展示了这个测试的过程,可以看到测试函数1 Test_Division_1 测试通过,而测试函数2 Test_Division_2 测试失败了,最后得出结论测试不通过。接下来把测试函数 2 修改成如下代码:

func Test_Division_2(t *testing.T) {
    if _, e := Division(6, 0); e == nil { //try a unit test on function
        t.Error("Division did not work as expected.") // 如果不是如预期的那么就报错
    } else {
        t.Log("one test passed.", e) //记录一些你期望记录的信息
    }
}   

然后执行 go test -v,就显示如下信息,测试通过了:

=== RUN   Test_Division_1
--- PASS: Test_Division_1 (0.00s)
	gotest_test.go:11: 第一个测试通过了
=== RUN   Test_Division_2
--- PASS: Test_Division_2 (0.00s)
	gotest_test.go:19: one test passed. 除数不能为0
PASS
ok  	_/Users/play/goweb/src/error/gotest	0.005s

如何编写压力测试

压力测试用来检测函数(方法)的性能,需要注意以下几点:

  • 压力测试用例必须遵循如下格式,其中 XXX 可以是任意字母数字的组合,但是首字母不能是小写字母

    func BenchmarkXXX(b *testing.B) { ... }
    
  • go test 不会默认执行压力测试的函数,如果要执行压力测试需要带上参数 -test.bench,语法:-test.bench="test_name_regex",例如 go test -test.bench=".*" 表示测试全部的压力测试函数

  • 在压力测试用例中,请记得在循环体内使用 testing.B.N,以使测试可以正常的运行

  • 文件名也必须以 _test.go 结尾

新建一个压力测试文件 webbench_test.go,代码如下所示:

package gotest

import (
	"testing"
)

func Benchmark_Division(b *testing.B) {
	for i := 0; i < b.N; i++ { //use b.N for looping
		Division(4, 5)
	}
}

func Benchmark_TimeConsumingFunction(b *testing.B) {
	b.StopTimer() //调用该函数停止压力测试的时间计数

	//做一些初始化的工作,例如读取文件数据,数据库连接之类的,
	//这样这些时间不影响我们测试函数本身的性能

	b.StartTimer() //重新开始时间
	for i := 0; i < b.N; i++ {
		Division(4, 5)
	}
}

执行命令 go test -run="webbench_test.go" -test.bench=".*",可以看到如下结果:

goos: darwin
goarch: amd64
Benchmark_Division-4                	2000000000	         0.29 ns/op
Benchmark_TimeConsumingFunction-4   	2000000000	         0.59 ns/op
PASS
ok  	_/Users/play/goweb/src/error/gotest	1.856s

上面的结果显示没有执行任何 TestXXX 的单元测试函数,显示的结果只执行了压力测试函数,第一条显示了Benchmark_Division 执行了 2000000000 次,每次的执行平均时间是 0.29 纳秒,第二条显示了 Benchmark_TimeConsumingFunction 执行了 2000000000,每次的平均执行时间是 0.59 纳秒。最后一条显示总共的执行时间 1.856s。