Go 中的動態做用域變量

這是一個 API 設計的思想實驗,它從典型的 Go 單元測試慣用形式開始:git

func TestOpenFile(t *testing.T) {
        f, err := os.Open("notfound")
        if err != nil {
                t.Fatal(err)
        }

        // ...
}

這段代碼有什麼問題?斷言 if err != nil { ... } 是重複的,而且須要檢查多個條件的狀況下,若是測試的做者使用 t.Error 而不是 t.Fatal 的話會容易出錯,例如:github

f, err := os.Open("notfound")
        if err != nil {
                t.Error(err)
        }
        f.Close() // boom!

有什麼解決方案?固然,經過將重複的斷言邏輯移到輔助函數中,來達到 DRY(Don't Repeat Yourself)。golang

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.Errort.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,你知道這一點是不能夠原諒的。ui

因此 panic 不是一個好的解決辦法,可是你能從堆棧跟蹤信息裏面看到什麼有用的信息嗎?這有一個提示:github.com/pkg/expect_test.TestOpenFile(0xc0000b4400).net

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 的調用堆棧亂竄的固有不足之外,一樣存在一些嚴重的設計問題:

  1. expect.Nil 的行爲依賴於誰調用它。一樣的參數,因爲調用堆棧位置的緣由可能致使行爲的不一樣——這是不可預期的。
  2. 採起極端的動態做用域,將傳遞給單個函數以前的全部函數的全部變量歸入單個函數的做用域中。這是一個在函數申明沒有明確記錄的狀況下將數據傳入和傳出的輔助手段。

諷刺的是,這偏偏是我對context.Context的評價。我會將這個問題留給你本身判斷是否合理。

最後的話

這是個壞主意,這點沒有異議。這不是你能夠在生產模式中使用的模式。可是,這也不是生產代碼。這是在測試,也許有着不一樣的規則適用於測試代碼。畢竟,咱們使用模擬(mocks)、樁(stubs)、猴子補丁(monkey patching)、類型斷言、反射、輔助函數、構建標誌以及全局變量,全部這些使得咱們更加有效率得測試代碼。全部這些,奇技淫巧是不會讓它們出如今生產代碼裏面的,因此這真的是世界末日嗎?

若是你讀完本文,你也許會贊成個人觀點,儘管不太符合常規,並沒有必要將*testing.T 傳遞到全部須要斷言的函數中去,從而使測試代碼更加清晰。

若是你感興趣,我已分享了一個應用這個模式的小的斷言庫。當心使用。


via: https://studygolang.com/subject/1?p=1

做者:Dave Cheney 譯者:dust347 校對:unknwon

本文由 GCTT 原創編譯,[Go語言中文網

相關文章
相關標籤/搜索