概述
开发 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 可以做如下事情:
- 启动程序,可以按照开发者的自定义要求运行程序。
- 可让被调试的程序在开发者设定的调置的断点处停住。(断点可以是条件表达式)
- 当程序被停住时,可以检查此时程序中所发生的事。
- 动态的改变当前程序的执行环境。
编译Go程序的时候需要注意以下几点
- 传递参数 -ldflags "-s",忽略 debug 的打印信息
- 传递 -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
- 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
}
-
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。