Go 中的動態作用域變量
這是一個 API 設計的思想實驗,它從典型的 Go 單元測試慣用形式開始:
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
if err != nil {
t.Fatal(err)
}
// ...
}
這段代碼有什麼問題?斷言 if err != nil { ... }
是重複的,並且需要檢查多個條件的情況下,如果測試的作者使用 t.Error
而不是 t.Fatal
的話會容易出錯,例如:
f, err := os.Open("notfound")
if err != nil {
t.Error(err)
}
f.Close() // boom!
有什麼解決方案?當然,通過將重複的斷言邏輯移到輔助函數中,來達到 DRY(Don’t Repeat Yourself)。
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
check(t, err)
// ...
}
func check(t *testing.T, err error) {
if err != nil {
t.Helper()
t.Fatal(err)
}
}
使用 check
輔助函數使得這段代碼更簡潔一些,並且更加清晰地檢查錯誤,同時有望解決 t.Error
與 t.Fatal
的混淆使用。 將斷言抽象為一個輔助函數的缺點是,現在你需要將一個 testing.T
傳遞到每一個調用上。更糟糕的是,為了以防萬一,你需要傳遞 *testing.T
到每一個需要調用 check
的地方。
我猜,這並沒有關係。但我會觀察到只有在斷言失敗的時候才會用到變量 t —— 即使在測試場景下,大多數時候,大部分的測試是通過的,因此在相對罕見的測試失敗的情況下,會產生對這些變量 t 的固定讀寫開銷。
如果我們這樣做怎麼樣?
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
check(err)
// ...
}
func check(err error) {
if err != nil {
panic(err.Error())
}
}
是的,可以,但是有一些問題。
% go test
--- FAIL: TestOpenFile (0.00s)
panic: open notfound: no such file or directory [recovered]
panic: open notfound: no such file or directory
goroutine 22 [running]:
testing.tRunner.func1(0xc0000b4400)
/Users/dfc/go/src/testing/testing.go:874 +0x3a3
panic(0x111b040, 0xc0000866f0)
/Users/dfc/go/src/runtime/panic.go:679 +0x1b2
github.com/pkg/expect_test.check(...)
/Users/dfc/src/github.com/pkg/expect/expect_test.go:18
github.com/pkg/expect_test.TestOpenFile(0xc0000b4400)
/Users/dfc/src/github.com/pkg/expect/expect_test.go:10 +0xa1
testing.tRunner(0xc0000b4400, 0x115ac90)
/Users/dfc/go/src/testing/testing.go:909 +0xc9
created by testing.(*T).Run
/Users/dfc/go/src/testing/testing.go:960 +0x350
exit status 2
先從好的方面說起,我們不需要傳遞一個 testing.T
到每一個調用 check
函數的地方,且測試會立即失敗。我們還從 panic 中獲得了一條不錯的信息 —— 儘管重複出現了兩次。但是,哪裡斷言失敗卻不容易看到。它發生在 expect_test.go:11
,你知道這一點是不可以原諒的。
所以 panic 不是一個好的解決辦法,但是你能從堆棧跟蹤信息裏面看到什麼有用的信息嗎?這有一個提示:github.com/pkg/expect_test.TestOpenFile(0xc0000b4400)
。
TestOpenFile 有一個 t 的值,它由 tRunner 傳遞過來,所以 testing.T 在內存中位於地址 0xc0000b4400 上。如果我們可以在 check 函數內部獲取 t 會怎樣?那我們可以通過它來調用 t.Helper 來 t.Fatal。這可能嗎?
動態作用域
我們想要的是能夠訪問一個變量,而該變量的申明既不是在全局範圍,也不是在函數局部範圍,而是在調用堆棧的更高的位置上。這被稱之為動態作用域。Go 並不支持動態作用域,但事實證明,某些情況下,我們可以模擬它。回到正題:
// getT 返回由 testing.tRunner 傳遞過來的 testing.T 地址
// 而調用 getT 的函數由它(tRunner)所調用. 如果在堆棧中無法找到 testing.tRunner
// 說明 getT 在主測試 goroutine 沒有被調用,
// 這時 getT 返回 nil.
func getT() *testing.T {
var buf [8192]byte
n := runtime.Stack(buf[:], false)
sc := bufio.NewScanner(bytes.NewReader(buf[:n]))
for sc.Scan() {
var p uintptr
n, _ := fmt.Sscanf(sc.Text(), "testing.tRunner(%v", &p)
if n != 1 {
continue
}
return (*testing.T)(unsafe.Pointer(p))
}
return nil
}
我們知道每個測試(Test)由 testing 包在自己的 goroutine 上調用(看上面的堆棧信息)。testing 包通過一個名為 tRunner 的函數來啟動測試,該函數需要一個testing.T 和一個 func(testing.T)來調用。因此我們抓取當前 goroutine 的堆棧信息,從中掃描找到已 testing.tRunner 開頭的行——由於 tRunner 是私有函數,只能是 testing 包——並解析第一個參數的地址,該地址是一個指向 testing.T 的指針。有點不安全,我們將這個原始指針轉換為一個 *testing.T 我們就完成了。
如果搜索不到則可能是 getT 並不是被 Test 所調用。這實際上是行的通的,因為我們需要*testing.T 是為了調用 t.Fatal,而 testing 包要求 t.Fatal 被主測試 goroutine所調用。
import "github.com/pkg/expect"
func TestOpenFile(t *testing.T) {
f, err := os.Open("notfound")
expect.Nil(err)
// ...
}
綜上,在預期打開文件所產生的 err 為 nil 後,我們消除了斷言樣板,並且是測試看起來更加清晰易讀。
這樣好嗎?
這時你應該會問,這樣好嗎?答案是,不,這不好。此時你應該會感到震驚,但是這些不好的感覺可能值得反思。除了在 goroutine 的調用堆棧亂竄的固有不足以外,同樣存在一些嚴重的設計問題:
- expect.Nil 的行為依賴於誰調用它。同樣的參數,由於調用堆棧位置的原因可能導致行為的不同——這是不可預期的。
- 採取極端的動態作用域,將傳遞給單個函數之前的所有函數的所有變量納入單個函數的作用域中。這是一個在函數申明沒有明確記錄的情況下將數據傳入和傳出的輔助手段。
諷刺的是,這恰恰是我對context.Context的評價。我會將這個問題留給你自己判斷是否合理。
最後的話
這是個壞主意,這點沒有異議。這不是你可以在生產模式中使用的模式。但是,這也不是生產代碼。這是在測試,也許有着不同的規則適用於測試代碼。畢竟,我們使用模擬(mocks)、樁(stubs)、猴子補丁(monkey patching)、類型斷言、反射、輔助函數、構建標誌以及全局變量,所有這些使得我們更加有效率得測試代碼。所有這些,奇技淫巧是不會讓它們出現在生產代碼裏面的,所以這真的是世界末日嗎?
如果你讀完本文,你也許會同意我的觀點,儘管不太符合常規,並無必要將*testing.T 傳遞到所有需要斷言的函數中去,從而使測試代碼更加清晰。
如果你感興趣,我已分享了一個應用這個模式的小的斷言庫。小心使用。
via: //studygolang.com/subject/1?p=1
作者:Dave Cheney 譯者:dust347 校對:unknwon
本文由 GCTT 原創編譯,[Go語言中文網