錯誤處理,調試和測試 - 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。

相關文章
相關標籤/搜索