1. 程式人生 > 其它 >Go入門系列(十八) 反射、包和測試工具

Go入門系列(十八) 反射、包和測試工具

技術標籤:go語言入門golanggo語言

本系列文章目錄 展開/收起

反射 reflect

反射在go中是一個包,包名為reflect,其作用是可以檢視一個變數的具體型別和值。

反射這個包提供了兩種介面型別 Type和 Value,分別用於記錄一個變數的型別和具體值

下面我們看看一個簡單的反射的應用:獲取變數的型別

func main(){
	var x io.Writer
	x = os.Stdout
	typ := reflect.TypeOf(x)
	fmt.Println(typ)    // *os.File
}

TypeOf原始碼如下

我們不用關注他的方法體,而是關注他的返回值,他返回的是一個reflect.Type型別。因此如果我們typ不是一個簡單的字串,而是一個reflect.Type型別,typ的內容如下:

reflect.Type和reflect.Value介面型別變數實現了Stringer介面型別,因此我們可以呼叫其String()方法得到字串形式的型別資訊:

typ_str := typ.String()

一個注意點是:reflect.Type記錄的不是變數的介面型別而是變數的動態型別,因此typ是"*os.File" 而不是 "io.Writer"

實際上我們使用下面的方式也一樣可以打印出x變數的型別。

fmt.Printf("%T", x)

或者

typ := fmt.Sprintf("%T", x)

fmt.Printf("%#v", typ)

其實使用 %T,其內部還是使用了reflect.TypeOf()。當然了 TypeOf() 返回的是reflect.Type型別,而Sprintf(“%T”,x)返回的是字串型別。

reflect.Value這個物件不僅記錄一個變數的值還記錄了型別,通過ValueOf方法我們可以得到一個reflect.Value物件。

func main(){
	x := 3
	x_val := reflect.ValueOf(x)
	x_type_str := x_val.String()
	fmt.Println(x_val)			// 3
	fmt.Println(x_type_str)		// <int Value>

	x_type := x_val.Type()
	fmt.Println(x_type)			// int, 是reflect.Type型別
	fmt.Printf("%#v", x_type)
}

只需對 Value物件呼叫 String() 方法或者 Type() 方法就能得到x的型別,所以說Value這個介面型別不僅記錄了變數的具體值,還記錄了其型別。

但是如果x是一個字串,那麼無論是 x_val 還是 x_val.String() 都是具體值。

reflect.Value.Interface()方法可以將一個reflect.Value型別轉變為一個interface{} 空介面型別,它相當於是reflect.ValueOf()的逆操作。

func main(){
	var w io.Writer
	w = os.Stdout
	w_val := reflect.ValueOf(w)
	fmt.Printf("%#v", w_val)

	w_interface := w_val.Interface()	// 返回一個interface{}空介面型別而不是io.Writer型別
	w_interface.Write()		// 報錯,因為此時w_interface的所有方法都被遮蔽,自然也沒有Write方法
}

w_interface變數裝著和w_val以及w一樣的具體值,只是型別變成了空介面,因此這個變數的所有方法都會被隱藏,我們只關心它的具體值就好。

最後,我們回想一下之前學習介面的時候,我們是通過型別斷言來判斷一個變數的型別。但是美中不足的是這種方式只能夠判斷一個變數是不是某種型別,但是不能直接得到變數的型別是什麼,而reflect很好的解決了這個問題。

除此之外reflect.Value和reflect.Type還提供了Method方法獲取一種型別有哪些方法:

func main(){
	var w io.Writer
	w = os.Stdout
	w_val := reflect.ValueOf(w)	
	w_type := w_val.Type()
	fmt.Printf("%s\n", w_val.Method(0))	// 返回reflect.Value型別
	fmt.Printf("%s\n", w_val.Method(0).Type())		
	fmt.Printf("%s\n", w_type.Method(0))    // 返回reflect.Method型別
fmt.Printf("%s\n", w_type.Method(0).Type)    // 返回reflect.Type型別
}

reflect.Value 和 reflect.Type這兩種型別都有 Method方法,需要傳入一個下標,就可以獲取這個下標對應的方法A。Value的Mathod方法返回的是這個方法A的reflect.Value型別表示方法A的具體值,而Type的Method方法返回的是一個reflect.Method型別。

reflect.Method型別的原始碼如下

type Method struct {
	// Name is the method name.
	// PkgPath is the package path that qualifies a lower case (unexported)
	// method name. It is empty for upper case (exported) method names.
	// The combination of PkgPath and Name uniquely identifies a method
	// in a method set.
	// See https://golang.org/ref/spec#Uniqueness_of_identifiers
	Name    string
	PkgPath string

	Type  Type  // method type
	Func  Value // func with receiver as first argument
	Index int   // index for Type.Method
}

如果想要獲取方法A的方法名和型別,呼叫Method的Name成員和Type成員即可。

下面我們可以做一個獲取某個變數所有方法的函式

func main(){
	var w io.Writer
	w = os.Stdout

	w_methods := getAllMethod(w)
	fmt.Printf("%#v", w_methods)
}

func getAllMethod(x interface{}) (methods map[string]string){
	methods = map[string]string{}
	x_val := reflect.ValueOf(x)
	x_type := x_val.Type()

	// 遍歷x的所有方法
	for i := 0; i < x_val.NumMethod(); i++{
		x_method := x_type.Method(i)
		methods[x_method.Name] = x_method.Type.String()
	}

	return methods
}

慎用反射

反射是一個強大並富有表達力的工具,但是它應該被小心地使用,原因有三。

第一個原因是,基於反射的程式碼是比較脆弱的。對於每一個會導致編譯器報告型別錯誤的問題,在反射中都有與之相對應的誤用問題,不同的是編譯器會在構建時馬上報告錯誤,而反射則是在真正執行到的時候才會丟擲panic異常,可能是寫完程式碼很久之後了,而且程式也可能運行了很長的時間。

第二個原因是,即使對應型別提供了相同文件,但是反射的操作不能做靜態型別檢查,而且大量反射的程式碼通常難以理解。總是需要小心翼翼地為每個匯出的型別和其它接受interface{}或reflect.Value型別引數的函式維護說明文件。

第三個原因,基於反射的程式碼通常比正常的程式碼執行速度慢一到兩個數量級。對於一個典型的專案,大部分函式的效能和程式的整體效能關係不大,所以當反射能使程式更加清晰的時候可以考慮使用。測試是一個特別適合使用反射的場景,因為每個測試的資料集都很小。但是對於效能關鍵路徑的函式,最好避免使用反射。

包 package

每個包是由一個全域性唯一的字串所標識的匯入路徑定位。出現在import語句中的匯入路徑也是字串。

import (
    "fmt"
    "math/rand"
    "encoding/json"

    "golang.org/x/net/html"

    "github.com/go-sql-driver/mysql"
)

Go語言的規範並沒有指明包的匯入路徑字串的具體含義,匯入路徑的具體含義是由構建工具來解釋的。在本章,我們將討論Go語言工具箱的功能,包括大家經常使用的構建測試等功能。當然,也有第三方擴充套件的工具箱存在。例如,Google公司內部的Go語言碼農,他們就使用內部的多語言構建系統,用不同的規則來處理包名字和定位包,用不同的規則來處理單元測試等等,因為這樣可以更緊密適配他們內部環境。

如果你計劃分享或釋出包,那麼匯入路徑最好是全球唯一的。為了避免衝突,所有非標準庫包的匯入路徑建議以所在組織的網際網路域名為字首;而且這樣也有利於包的檢索。例如,上面的import語句匯入了Go團隊維護的HTML解析器和一個流行的第三方維護的MySQL驅動。

包宣告

在每個Go語言原始檔的開頭都必須有包宣告語句。包宣告語句的主要目的是確定當前包被其它包匯入時預設的識別符號(也稱為包名)。

例如,math/rand包的每個原始檔的開頭都包含package rand包宣告語句,所以當你匯入這個包,你就可以用rand.Int、rand.Float64類似的方式訪問包的成員。

通常來說,預設的包名就是包匯入路徑名的最後一段,因此即使兩個包的匯入路徑不同,它們依然可能有一個相同的包名。例如,math/rand包和crypto/rand包的包名都是rand。稍後我們將看到如何同時匯入兩個有相同包名的包。

關於預設包名一般採用匯入路徑名的最後一段的約定也有三種例外情況。第一個例外,包對應一個可執行程式,也就是main包,這時候main包本身的匯入路徑是無關緊要的。名字為main的包是給go build(§10.7.3)構建命令一個資訊,這個包編譯完之後必須呼叫聯結器生成一個可執行程式。

第二個例外,包所在的目錄中可能有一些檔名是以_test.go為字尾的Go原始檔(譯註:前面必須有其它的字元,因為以_或.開頭的原始檔會被構建工具忽略),並且這些原始檔宣告的包名也是以_test為字尾名的。這種目錄可以包含兩種包:一種是普通包,另一種則是測試的外部擴充套件包。所有以_test為字尾包名的測試外部擴充套件包都由go test命令獨立編譯,普通包和測試的外部擴充套件包是相互獨立的。測試的外部擴充套件包一般用來避免測試程式碼中的迴圈匯入依賴

第三個例外,一些依賴版本號的管理工具會在匯入路徑後追加版本號資訊,例如“gopkg.in/yaml.v2”。這種情況下包的名字並不包含版本號字尾,而是yaml。

匯入包

可以在一個Go語言原始檔包宣告語句之後,其它非匯入宣告語句之前,包含零到多個匯入包宣告語句。每個匯入宣告可以單獨指定一個匯入路徑,也可以通過圓括號同時匯入多個匯入路徑。下面兩個匯入形式是等價的,但是第二種形式更為常見。

import "fmt"
import "os"

import (
    "fmt"
    "os"
)

匯入的包之間可以通過新增空行來分組;通常將來自不同組織的包獨自分組。包的匯入順序無關緊要,但是在每個分組中一般會根據字串順序排列。(gofmt和goimports工具都可以將不同分組匯入的包獨立排序。)

import (
    "fmt"
    "html/template"
    "os"

    "golang.org/x/net/html"
    "golang.org/x/net/ipv4"
)

如果我們想同時匯入兩個有著名字相同的包,例如math/rand包和crypto/rand包,那麼匯入宣告必須至少為一個同名包指定一個新的包名以避免衝突。這叫做匯入包的重新命名。

import (
    "crypto/rand"
    mrand "math/rand" // alternative name mrand avoids conflict
)

如果遇到包迴圈匯入的情況,Go語言的構建工具將報告錯誤。

包的匿名匯入

如果只是匯入一個包而並不使用匯入的包(的變數或者方法)將會導致一個編譯錯誤。

但是有時候我們只是想利用匯入包而產生的副作用:它會執行包級變數的初始化表示式和執行匯入包的init初始化函式。此時我們可以用下劃線_來重新命名匯入的包。

import _ "image/png" // register PNG decoder

工具

接下來將討論Go語言工具箱的具體功能,包括如何下載、格式化、構建、測試和安裝Go語言編寫的程式。

對於大多數的Go語言使用者,只需要配置一個名叫GOPATH的環境變數,用來指定當前工作目錄即可。當需要切換到不同工作區的時候,只要更新GOPATH就可以了(我理解為是要重新修改GOOTPATH環境變數為新的根目錄,而不是追加)。第二個環境變數GOROOT用來指定Go的安裝目錄,還有它自帶的標準庫包的位置。

export GOPATH=/root/go_project

假設你設定了GOPATH為/root/go_project,那麼這個目錄就是存放你本次go專案的根目錄。

GOPATH對應的工作區目錄(/root/go_project)有三個子目錄(src/bin/pkg)。其中src子目錄用於儲存原始碼(可執行檔案也要單獨放在src下的一個子目錄中,不能直接放在src目錄下)。pkg子目錄用於儲存編譯後的包的目標檔案,bin子目錄用於儲存編譯後的可執行程式。

假如,go_project專案是一個爬蟲專案,這個專案存放了多個可執行go檔案用以對應多種型別的爬蟲(普通爬蟲,增量爬蟲,全站爬蟲等),那麼這多個可執行go檔案應該分別放在src的不同目錄下。這些爬蟲所依賴的不同包也應該按照功能存放在不同的src下的目錄中。如下所示:

編譯的時候,我們可以在任何目錄執行 go build common_crawler 進行編譯(go會在環境變數找到GOPATH/src,然後再拼接common_crawler,所以編譯的時候會找到$GOPATH/src/common_crawle,而無需 go build /root/go_projects/src/common_crawler寫這麼長)。在尋找依賴包的時候,go會在 GOPATH/src 和 GOROOT/src下找依賴包。

Go的編譯不是以go檔案為最小單位的,而是以包為最小單位的,也就是說gobuild只需跟要編譯的目錄名即可(它會把這個目錄下所有的go檔案編譯為1個可執行二進位制檔案),不用也不能接某個具體的go檔案。

編譯後生成的二進位制檔案就放在了你執行go build命令時的目錄。因此我們一般都會先進入GOPATH/bin目錄,再執行gobuild common_crawler,這樣這個二進位制可執行檔案就會生成在bin目錄下。

編譯一個普通爬蟲的main包(目錄),我們可以這樣寫:

cd anywhere

go build common_crawler

或者

cd $GOPATH/src/common_crawler

go build

或者

cd $GOPATH

go build ./src/common_crawler

但是下面這種方式是錯誤的cd $GOPATH

go build src/common_crawler

go env命令用於檢視Go語言工具涉及的所有環境變數的值,包括未設定環境變數的預設值。

go get可以下載一個單一的包或者用...下載整個子目錄裡面的每個包。如果指定-u命令列標誌引數,go get命令將確保所有的包和依賴的包的版本都是最新的,然後重新編譯和安裝它們。如果不包含該標誌引數的話,而且如果包已經在本地存在,那麼程式碼將不會被自動更新。

go build命令編譯命令列引數指定的每個包。如果包是一個非main的庫(目錄),則忽略輸出結果,但這可以用於檢測包是可以正確編譯的(例如上例中,我們可以 go build spider來檢驗spider這個依賴包是否有編譯錯誤,你會發現執行了這條命令後沒有任何二進位制檔案,因為spider包只是依賴包而不是可執行的main包)。如果包的名字是main,go build將呼叫連結器在當前目錄建立一個可執行程式,以匯入路徑的最後一段作為可執行程式的名字(例如上例中執行 gobuild common_crawler)。

gorun可以快速執行某個可執行的go檔案。這個命令結合了gobuild(編譯)和執行這兩步。和 gobuild不同的是,gobuild只能接包名,也就是隻能接一個目錄不能接go檔案。而gorun不僅可以接包名,也能接go檔案,但是gorun後面如果接包不能接依賴包只能接可執行的main包。

gorun 也是會遵循go build 的尋找專案目錄和依賴包目錄的規則。例如,按照上面的例子,想執行common_crawler可以在任意目錄執行: go run common_crawler 也可以 go run common_crawler/common_crawler.go

go install命令和go build命令很相似,但是它會儲存每個包的編譯成果,而不是將它們都丟棄。被編譯的包會被儲存到$GOPATH/pkg目錄下,目錄路徑和 src目錄路徑對應,可執行程式被儲存到$GOPATH/bin目錄(在任意的一個目錄執行goinstall 都會把編譯後的可執行程式儲存到$GOPATH/bin目錄,這是和 go build不同之處)。

go install命令和go build命令都不會重新編譯沒有發生變化的包,這可以使後續構建更快捷。為了方便編譯依賴的包,go build -i命令將安裝每個目標所依賴的包。

PS:其實gobuild 和 goinstall 只要加上 -i引數都會安裝依賴包到pkg並且編譯二進位制執行檔案(不加 -i 就都不會安裝依賴包,而只是生成二進位制檔案),而且無論在哪個目錄下執行go build 和 go install,依賴包的安裝都會$GOPATH/pkg下。不同之處在於,gobuild 的二進位制檔案會生成在執行命令時的目錄,而 go install 則是無論在哪裡執行都把二進位制檔案生成到 $GOPATH/bin 下。

將一個特別的構建註釋引數放在包宣告語句之前可以提供更多的構建過程控制。例如,檔案中可能包含下面的註釋:

// +build linux darwin

該構建註釋引數告訴go build只在編譯程式對應的目標作業系統是Linux或Mac OS X時才編譯這個檔案

// +build ignore

該構建註釋則表示不編譯這個檔案

更多細節,可以參考go/build包的構建約束部分的文件。

包測試 gotest

o語言的測試技術是相對低階的。它依賴一個go test測試命令和一組按照約定方式編寫的測試函式,測試命令可以執行這些測試函式。編寫相對輕量級的純測試程式碼是有效的(但是用 gotest 測試一個大型專案可能就不太好使了)。

在包目錄內,所有以_test.go為字尾名的原始檔在執行go build時不會被構建成包的一部分,它們是go test測試的一部分。

在*_test.go檔案中,有三種類型的函式:測試函式、基準測試(benchmark)函式、示例函式。一個測試函式是以Test為函式名字首的函式,用於測試程式的一些邏輯行為是否正確;go test命令會呼叫這些測試函式並報告測試結果是PASS或FAIL。基準測試函式是以Benchmark為函式名字首的函式,它們用於衡量一些函式的效能;go test命令會多次執行基準測試函式以計算一個平均的執行時間。示例函式是以Example為函式名字首的函式,提供一個由編譯器保證正確性的示例文件。

測試函式

我們看一下,一個標準的測試函式應該怎麼寫。

下面的函式IsPalindrome用於檢查一個字串是否是對稱的:

// Package word provides utilities for word games.
package word

// IsPalindrome reports whether s reads the same forward and backward.
// (Our first attempt.)
func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s)-1-i] {
            return false
        }
    }
    return true
}

這是一個依賴包檔案而不是main檔案

在相同的目錄下,word_test.go測試檔案中包含了TestPalindrome和TestNonPalindrome兩個測試函式。每一個都是測試IsPalindrome是否給出正確的結果,並使用t.Error報告失敗資訊:

package word

import "testing"

func TestPalindrome(t *testing.T) {
    if !IsPalindrome("detartrated") {
        t.Error(`IsPalindrome("detartrated") = false`)
    }
    if !IsPalindrome("kayak") {
        t.Error(`IsPalindrome("kayak") = false`)
    }
}

func TestNonPalindrome(t *testing.T) {
    if IsPalindrome("palindrome") {
        t.Error(`IsPalindrome("palindrome") = true`)
    }
}

每一個測試檔名都應該以_test.go為字尾結尾。每個測試函式都要以Test為字首,每隔測試檔案都要引入testing包。

使用 gotest 進行包測試的時候,

gotest 包名

或者

cd某個包目錄下 && go test

go test的規則和gobuild 的一樣

gotest 這個命令會執行要測試的包下所有的_test.go檔案的所有 Test開頭的函式。如果只想要指定執行某一個或者多個Test開頭的函式,可以用 -run 引數後面接一個正則:

例如有3個測試函式包含不同的測試檔案,這3個函式分別是:

TestFrenchPalindrome

TestCanalPalindrome

TestChinaPalindrome

go test-run=”French|Canal”

滿足上面這個正則的函式名是TestFrenchPalindrome 和TestCanalPalindrome,所以只有這兩個測試函式會被測試。

-v可用於列印每個測試函式的名字和執行時間

$ go test -v
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
    word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
    word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL    gopl.io/ch11/word1  0.017s

比較常見的測試方式是以表格的形式存放測試用例,然後對這些例子一一測試:

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want  bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"detartrated", true},
        {"A man, a plan, a canal: Panama", true},
        {"Evil I did dwell; lewd did I live.", true},
        {"Able was I ere I saw Elba", true},
        {"été", true},
        {"Et se resservir, ivresse reste.", true},
        {"palindrome", false}, // non-palindrome
        {"desserts", false},   // semi-palindrome
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf("IsPalindrome(%q) = %v", test.input, got)
        }
    }
}

需要注意的是:t.Errorf不會引發panic終止程式,因此引發了一個t.Errorf 後,測試還會繼續。如果我們真的需要停止測試,或許是因為初始化失敗或可能是早先的錯誤導致了後續錯誤等原因,我們可以使用t.Fatal或t.Fatalf替代t.Errorf。它們必須在和測試函式同一個goroutine內呼叫。

測試失敗的資訊一般的形式是“f(x) = y, want z”,其中f(x)解釋了失敗的操作和對應的輸入,y是實際的執行結果,z是期望的正確的結果。(雖然不強制這樣寫,但是這是規範)

一般來說,測試檔案都是用來測試一個依賴包,而不是一個main包,例如寫了一個爬蟲,我們可能很難說用 go test 來測試一個能沿著uri路徑爬取整站的爬蟲的執行是否錯誤。而是測試這個爬蟲的一些元件的包,例如 download下載函式,crawl 爬取單個頁面函式,saveData儲存資料函式等等這種小塊功能的測試。

當然我們也可以測試main包下的可執行檔案中的函式(除了main函式之外),和測試依賴包沒什麼不同,不過,我們需要將main包中的程式碼給模組化,因為測試檔案無法測試main檔案的main函式,如下面的例子:

// Echo prints its command-line arguments.
package main

import (
    "flag"
    "fmt"
    "io"
    "os"
    "strings"
)

var (
    n = flag.Bool("n", false, "omit trailing newline")
    s = flag.String("s", " ", "separator")
)

var out io.Writer = os.Stdout // modified during testing

func main() {
    flag.Parse()
    if err := echo(!*n, *s, flag.Args()); err != nil {
        fmt.Fprintf(os.Stderr, "echo: %v\n", err)
        os.Exit(1)
    }
}

func echo(newline bool, sep string, args []string) error {
    fmt.Fprint(out, strings.Join(args, sep))
    if newline {
        fmt.Fprintln(out)
    }
    return nil
}

這是一個main檔案,裡面有main和echo兩個函式,但是測試檔案測的時候只能測echo這個函式,無法去呼叫main函式,當然啦包級的變數的初始化在測試時還是會執行的。

下面就是echo_test.go檔案中的測試程式碼

package main

import (
    "bytes"
    "fmt"
    "testing"
)

func TestEcho(t *testing.T) {
    var tests = []struct {
        newline bool
        sep     string
        args    []string
        want    string
    }{
        {true, "", []string{}, "\n"},
        {false, "", []string{}, ""},
        {true, "\t", []string{"one", "two", "three"}, "one\ttwo\tthree\n"},
        {true, ",", []string{"a", "b", "c"}, "a,b,c\n"},
        {false, ":", []string{"1", "2", "3"}, "1:2:3"},
    }
    for _, test := range tests {
        descr := fmt.Sprintf("echo(%v, %q, %q)",
            test.newline, test.sep, test.args)

        out = new(bytes.Buffer) // captured output
        if err := echo(test.newline, test.sep, test.args); err != nil {
            t.Errorf("%s failed: %v", descr, err)
            continue
        }
        got := out.(*bytes.Buffer).String()
        if got != test.want {
            t.Errorf("%s = %q, want %q", descr, got, test.want)
        }
    }
}

個人覺得關於測試方面的內容到這裡就差不多夠用了,如果希望瞭解go聖經作者基準測試函式和示例函式的內容可以到下方go聖經連結檢視。

https://books.studygolang.com/gopl-zh/

本文轉載自: 張柏沛IT技術部落格 > Go入門系列(十八) 反射、包和測試工具