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

概述

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

错误处理

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

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

func Open(name string) (file *File, err error)
复制代码

使用示例:bash

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 文件不存在,控制台输出:app

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 的示例:oop

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。

相关文章
相关标签/搜索