探索 Go1.16 io/fs 包以提高測試效能和可測試性

liupzmin-github發表於2021-03-04

io/fs概述及其存在的必要性

​要理解為什麼 Go1.16 版本引入io/fs,就必須先要理解 embedding(內嵌)的基本原理。當開發一個工具的時候,嵌入那些日後需要被訪問(定址)的內容涉及到很多方面,但本文僅僅討論其中之一。

​對於每個要嵌入靜態檔案的工具來說,其工作本質都大同小異。當它們執行的時候,每個靜態檔案都會被轉換為位元組,放入一個.go檔案之中,最後被編譯成二進位制檔案。一旦進行編譯,工具本身就必須負責將針對檔案系統的呼叫轉換為對一個虛擬檔案系統的呼叫

​當執行嵌入了assets靜態檔案的程式後,程式碼訪問這些檔案的方式依然是針對檔案系統的呼叫,我們必須把這種呼叫轉換為一種虛擬呼叫(因為實際訪問的檔案內容已被轉換為位元組,並編譯程式序本身)。此時,我們面臨一個問題:如何在程式碼中確定一個呼叫是針對虛擬的assets還是真實的檔案系統?

​想象一下這樣一個工具:它會遍歷一個目錄,並返回所能找到的所有以.go結尾的檔名稱。如果此工具不能和檔案系統互動,那麼它將毫無用處。現在,假設有一個 web 應用,它內嵌了一些靜態檔案,比如images, templates, and style sheets等等。那這個 Web 應用程式在訪問這些相關assets時應使用虛擬檔案系統,而不是真實檔案系統。

​要分辨出這兩種不同的呼叫,就需要引入一個供開發人員使用的 API,該 API 可以指導該工具何時訪問虛擬化,何時訪問檔案系統。這類 API 都各有特色,像早期的嵌入工具 Packr,它使用的就是自定義的 API。

type Box
    func Folder(path string) *Box
    func New(name string, path string) *Box
    func NewBox(path string) *Box
    func (b *Box) AddBytes(path string, t []byte) error
    func (b *Box) AddString(path string, t string) error
    func (b *Box) Bytes(name string) []byte
    func (b *Box) Find(name string) ([]byte, error)
    func (b *Box) FindString(name string) (string, error)
    func (b *Box) Has(name string) bool
    func (b *Box) HasDir(name string) bool
    func (b *Box) List() []string
    func (b *Box) MustBytes(name string) ([]byte, error)
    func (b *Box) MustString(name string) (string, error)
    func (b *Box) Open(name string) (http.File, error)
    func (b *Box) Resolve(key string) (file.File, error)
    func (b *Box) SetResolver(file string, res resolver.Resolver)
    func (b *Box) String(name string) string
    func (b *Box) Walk(wf WalkFunc) error
    func (b *Box) WalkPrefix(prefix string, wf WalkFunc) error

​使用自定義 API 的好處就是工具開發者可以完全掌控使用者體驗。這包括使開發人員更輕鬆地管理需要在幕後維護的複雜關係。缺點也很明顯,那就是使用者需要去學習這種新的 API。其程式碼也就嚴重依賴於這種自定義的 API,這使得它們難以隨時間升級。

​另一種方式就是提供一種模擬標準庫的 API , Pkger 就是此例之一:

type File interface {
    Close() error
    Name() string
    Open(name string) (http.File, error)
    Read(p []byte) (int, error)
    Readdir(count int) ([]os.FileInfo, error)
    Seek(offset int64, whence int) (int64, error)
    Stat() (os.FileInfo, error)
    Write(b []byte) (int, error)
}

​這種方式使用已知的、大家都熟悉的 API,會更容易吸引使用者,而且也避免了再去學習新的 API 。

​Go 1.16標準庫引入的io/fs包就採用了此種方式,其優點就是使用了使用者熟知的 API 介面,因此也就降低了學習成本,使得使用者更加容易接受。

​但有其利必有其弊,雖然使用現有 API 迎合了使用者使用習慣、增加了程式的相容性,但同時也導致了大而複雜的介面。這亦是io/fs所面臨的問題,不幸的是,要正確模擬對檔案系統的呼叫,需要很大的介面占用空間,我們很快就會看到。

測試基於檔案系統的程式碼

io/fs包不僅僅只是支撐1.16 版本嵌入功能這麼簡單,它帶來的最大便利之一就是豐富了單元測試,它可以讓我們編寫更加易於測試的檔案系統互動方面的程式碼。

除了增加程式碼的可測試性之外,io/fs還可以幫助我們編寫更加易讀的測試用例,並且在我們測試檔案系統互動程式碼時擁有不尋常的效能表現。

為了更深入地瞭解io/fs包,我們來實現一段程式碼,它的功能是遍歷一個給定的根目錄,並從中搜尋以.go結尾的檔案。在迴圈遍歷過程中,程式需要跳過一些符合我們預先設定字首的目錄,比如.git , node_modules , testdata等等。我們沒必要去搜尋.git , node_modules資料夾,因為我們清楚它們肯定不會包含.go檔案。一旦我們找到了符合要求的檔案,我們就把檔案的路徑加入到一個列表中然後繼續搜尋。

func GoFiles(root string) ([]string, error) {
    var data []string

    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        base := filepath.Base(path)
        for _, sp := range SkipPaths {
            // if the name of the folder has a prefix listed in SkipPaths
            // then we should skip the directory.
            // e.g. node_modules, testdata, _foo, .git
            if strings.HasPrefix(base, sp) {
                return filepath.SkipDir
            }
        }

        // skip non-go files
        if filepath.Ext(path) != ".go" {
            return nil
        }

        data = append(data, path)

        return nil
    })

    return data, err
}

這個函式的執行結果將產生一個類似於下面這樣的slice

[
    "benchmarks_test.go",
    "cmd/fsdemo/main.go",
    "cmd/fsdemo/main_test.go",
    "fsdemo.go",
    "fsdemo_test.go",
    "mock_file.go",
]

我現在提出的問題是:我們該如何測試這段程式碼?因為這段程式碼直接和檔案系統互動,我們該如何保證在檔案系統上呈現一個準確無誤的測試場景呢?

鑑於測試方法會有很多種,在深入io/fs之前,我們先看一下最常見的兩種方法,從而對比一下io/fs能帶給我們怎樣的便利。

JIT Test File Creation

第一個測試檔案系統程式碼的方法就是在執行時刻建立必須的資料夾結構。

本文將以benchmark的方式呈現單元測試,如此我們就可以對比各種測試方法的效能。這也是為何setup(建立測試用的檔案結構)的程式碼會被包含在基準程式碼當中,我們基準測試的目標就是setup的過程。在此情況下,各種測試方法都不會改變setup的底層函式。

func BenchmarkGoFilesJIT(b *testing.B) {
    for i := 0; i < b.N; i++ {

        dir, err := ioutil.TempDir("", "fsdemo")
        if err != nil {
            b.Fatal(err)
        }

        names := []string{"foo.go", "web/routes.go"}

        for _, s := range SkipPaths {
            // ex: ./.git/git.go
            // ex: ./node_modules/node_modules.go
            names = append(names, filepath.Join(s, s+".go"))
        }

        for _, f := range names {
            if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(f)), 0755); err != nil {
                b.Fatal(err)
            }
            if err := ioutil.WriteFile(filepath.Join(dir, f), nil, 0666); err != nil {
                b.Fatal(err)
            }
        }

        list, err := GoFiles(dir)

        if err != nil {
            b.Fatal(err)
        }

        lexp := 2
        lact := len(list)
        if lact != lexp {
            b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
        }

        sort.Strings(list)

        exp := []string{"foo.go", "web/routes.go"}
        for i, a := range list {
            e := exp[i]
            if !strings.HasSuffix(a, e) {
                b.Fatalf("expected %q to match expected %q", list, exp)
            }
        }

    }
}

​在BenchmarkGoFilesJIT測試用例中,我們使用io/ioutil包來為測試建立滿足需求場景的臨時資料夾和檔案。此刻,意味著要建立包含.go檔案的node_modules.git目錄,以便於確認這些.go檔案不會出現在處理結果中。如果GoFiles函式正常工作的話,我們在結果集中將看到兩個條目,foo.go 以及 web/routes.go

​這種JIT方式有兩大缺點:其一,隨著時間的推移,編寫和維護setup部分的程式碼將會變得非常麻煩,為測試用例做大量的setup本身也會引入更多的 bug。其二,也是最大的弊端,JIT測試會建立大量的檔案和資料夾,這勢必會在檔案系統上產生大量的i/o競爭和i/o操作,從而讓我們的任務效能非常低效。

goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesJIT-16                                      1470            819064 ns/op

Pre-Existing File Fixtures

另一種測試GoFiles的方法是建立一個名為testdata的目錄,並且在裡面建立好測試場景所需的全部檔案。

testdata
└── scenario1
        ├── _ignore
        │   └── ignore.go
        ├── foo.go
        ├── node_modules
        │   └── node_modules.go
        ├── testdata
        │   └── testdata.go
        └── web
                └── routes.go

5 directories, 5 files

使用這種方法,我們就可以清理掉很多我們的測試程式碼,讓GoFiles函式指向事先準備好的已包含相應測試場景的資料夾。

func BenchmarkGoFilesExistingFiles(b *testing.B) {
    for i := 0; i < b.N; i++ {

        list, err := GoFiles("./testdata/scenario1")

        if err != nil {
            b.Fatal(err)
        }

        lexp := 2
        lact := len(list)
        if lact != lexp {
            b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
        }

        sort.Strings(list)

        exp := []string{"foo.go", "web/routes.go"}
        for i, a := range list {
            e := exp[i]
            if !strings.HasSuffix(a, e) {
                b.Fatalf("expected %q to match expected %q", list, exp)
            }
        }

    }
}

這種方法大大減少了測試的消耗,從而提高了測試的可靠性和可讀性。與 JIT 方法相比,此方法呈現的測試速度也快得多。

goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesExistingFiles-16                        9795            120648 ns/op
BenchmarkGoFilesJIT-16                                  1470            819064 ns/op

這種方法的缺點是為 GoFiles 函式建立可靠測試所需的檔案/資料夾的數量和組合(意指數量和組合可能都很巨大)。到目前為止,我們僅僅測試了 “成功” 的情況,我們還沒有為錯誤場景或其它潛在的情況編寫測試。

使用這種方式,一個很常見的問題就是,開發者會逐漸的為多個測試重複使用這些場景(指 testdata 中的測試場景)。隨時間推移,開發者並非為新的測試建立新的結構,而是去更改現有的場景以滿足新的測試。這將測試全部耦合在了一起,使測試程式碼變得異常脆弱。

使用io/fs重寫GoFiles函式,我們將會解決所有的問題!

使用 FS

​通過上面的瞭解,我們知道io/fs包支援針對virtual file system的實現(譯者注:意指io/fs包提供了很多針對fs.FS的功能)。為了利用io/fs提供的功能,我們可以通過重寫GoFiles函式讓它接受一個fs.FS作為引數。在正式的程式碼中,我們可以呼叫os.DirFS來獲得一個由底層檔案系統支援的fs.FS介面的實現。

​為了遍歷一個fs.FS的實現,我們需要使用fs.WalkDir函式,fs.WalkDir函式的功能近乎等同於filepath.Walk函式。儘管這些差異很值得推敲一番,但這超出了本文的範圍,因此我們將在以後的文章中另行闡述。

func GoFilesFS(root string, sys fs.FS) ([]string, error) {
    var data []string

    err := fs.WalkDir(sys, ".", func(path string, de fs.DirEntry, err error) error {
        if err != nil {
            return err
        }

        base := filepath.Base(path)
        for _, sp := range SkipPaths {
            // if the name of the folder has a prefix listed in SkipPaths
            // then we should skip the directory.
            // e.g. node_modules, testdata, _foo, .git
            if strings.HasPrefix(base, sp) {
                return filepath.SkipDir
            }
        }

        // skip non-go files
        if filepath.Ext(path) != ".go" {
            return nil
        }

        data = append(data, path)

        return nil
    })

    return data, err
}

得益於io/fs包相容性 API 帶來的便利,GoFilesFS函式避免了昂貴的重寫,僅需要很小的修改就可完工。

實現 FS

現在,該函式已更新為使用fs.FS,讓我們看看如何為它編寫測試。在此之前,我們先來實現fs.FS

type FS interface {
    // Open opens the named file.
    //
    // When Open returns an error, it should be of type *PathError
    // with the Op field set to "open", the Path field set to name,
    // and the Err field describing the problem.
    //
    // Open should reject attempts to open names that do not satisfy
    // ValidPath(name), returning a *PathError with Err set to
    // ErrInvalid or ErrNotExist.
    Open(name string) (File, error)
}

Open函式接收一個檔案的路徑,然後返回一個fs.File和一個error。如文件所述,需要滿足某些關於錯誤的需求。

對於我們的測試來說,我們將會使用一個模擬檔案型別的切片,並稍後將其實現為fs.FS,該切片還將實現所有本次測試所需的功能。

type MockFS []*MockFile

func (mfs MockFS) Open(name string) (fs.File, error) {
    for _, f := range mfs {
        if f.Name() == name {
            return f, nil
        }
    }

    if len(mfs) > 0 {
        return mfs[0].FS.Open(name)
    }

    return nil, &fs.PathError{
        Op:   "read",
        Path: name,
        Err:  os.ErrNotExist,
    }
}

MockFS.Open中,我們在已知檔案列表中迴圈匹配請求的名稱,如果匹配成功則返回該檔案;如果沒有找到,則嘗試在第一個檔案中遞迴開啟。最後,如果沒有找到,則按文件要求返回適當的error

我們的MockFS目前還未實現完整,我們還需要實現fs.ReadDirFS介面來模擬檔案。儘管fs.ReadDirFS文件未提及以下約束,但fs.ReadDirFileFile.ReadDir則需要它們。因此,它們也值得留意和實現。

// ReadDir reads the contents of the directory and returns
// a slice of up to n DirEntry values in directory order.
// Subsequent calls on the same file will yield further DirEntry values.
//
// If n > 0, ReadDir returns at most n DirEntry structures.
// In this case, if ReadDir returns an empty slice, it will return
// a non-nil error explaining why.
// At the end of a directory, the error is io.EOF.
//
// If n <= 0, ReadDir returns all the DirEntry values from the directory
// in a single slice. In this case, if ReadDir succeeds (reads all the way
// to the end of the directory), it returns the slice and a nil error.
// If it encounters an error before the end of the directory,
// ReadDir returns the DirEntry list read until that point and a non-nil error.

儘管這些規則聽起來令人困惑,但實際上,這種邏輯非常簡單。

func (mfs MockFS) ReadDir(n int) ([]fs.DirEntry, error) {
    list := make([]fs.DirEntry, 0, len(mfs))

    for _, v := range mfs {
        list = append(list, v)
    }

    sort.Slice(list, func(a, b int) bool {
        return list[a].Name() > list[b].Name()
    })

    if n < 0 {
        return list, nil
    }

    if n > len(list) {
        return list, io.EOF
    }
    return list[:n], io.EOF
}

實現 File 介面

我們已經完成了fs.FS的實現,但仍需要實現一組介面來滿足fs包的需要。幸運的是,我們可以將所有介面實現到一個型別當中,從而使我們的測試更加簡便。

繼續之前,我要申明一點:我故意沒有完全實現介面的檔案讀取部分,因為這將增加不必要的複雜度,而這些複雜度不是本文所需要的。所以我們將在後續的文章中探討相關主題。

為了測試我們的程式碼,我們將要實現四個介面: fs.File , fs.FileInfo , fs.ReadDirFile , and fs.DirEntry

type File interface {
    Stat() (FileInfo, error)
    Read([]byte) (int, error)
    Close() error
}

type FileInfo interface {
    Name() string
    Size() int64
    Mode() FileMode
    ModTime() time.Time
    IsDir() bool
    Sys() interface{}
}

type ReadDirFile interface {
    File
    ReadDir(n int) ([]DirEntry, error)
}

type DirEntry interface {
    Name() string
    IsDir() bool
    Type() FileMode
    Info() (FileInfo, error)
}

乍看之下,這些介面的體量之大似乎壓人心魄。但是不用多慮,因為它們很多重疊的功能,所以我們可以把他們凝聚到一個型別當中。

type MockFile struct {
    FS      MockFS
    isDir   bool
    modTime time.Time
    mode    fs.FileMode
    name    string
    size    int64
    sys     interface{}
}

MockFile型別包含一個fs.FS的實現MockFS,它將持有我們測試用到的所有檔案。MockFile 型別中的其餘欄位供我們設定為其相應功能的返回值。

func (m *MockFile) Name() string {
    return m.name
}

func (m *MockFile) IsDir() bool {
    return m.isDir
}

func (mf *MockFile) Info() (fs.FileInfo, error) {
    return mf.Stat()
}

func (mf *MockFile) Stat() (fs.FileInfo, error) {
    return mf, nil
}

func (m *MockFile) Size() int64 {
    return m.size
}

func (m *MockFile) Mode() os.FileMode {
    return m.mode
}

func (m *MockFile) ModTime() time.Time {
    return m.modTime
}

func (m *MockFile) Sys() interface{} {
    return m.sys
}

func (m *MockFile) Type() fs.FileMode {
    return m.Mode().Type()
}

func (mf *MockFile) Read(p []byte) (int, error) {
    panic("not implemented")
}

func (mf *MockFile) Close() error {
    return nil
}

func (m *MockFile) ReadDir(n int) ([]fs.DirEntry, error) {
    if !m.IsDir() {
        return nil, os.ErrNotExist
    }

    if m.FS == nil {
        return nil, nil
    }
    return m.FS.ReadDir(n)
}

Stat() (fs.FileInfo, error)方法可以返回MockFile本身,因為它已經實現了fs.FileInfo介面,此為我們如何用一個MockFile型別實現眾多所需的介面的一個例證!

使用 FS 進行測試

鑑於我們已經擁有了MockFSMockFile,那麼是時候為GoFilesFS函式編寫測試了。依例,我們首先要為測試設定資料夾和檔案結構。通過兩個輔助函式NewFileNewDir、以及使用切片直接構建一個fs.FS(指 MockFS)的便捷性,我們可以在記憶體中快速的構建出複雜的資料夾和檔案結構。


func NewFile(name string) *MockFile {
    return &MockFile{
        name: name,
    }
}

func NewDir(name string, files ...*MockFile) *MockFile {
    return &MockFile{
        FS:    files,
        isDir: true,
        name:  name,
    }
}

func BenchmarkGoFilesFS(b *testing.B) {
    for i := 0; i < b.N; i++ {
        files := MockFS{
            // ./foo.go
            NewFile("foo.go"),
            // ./web/routes.go
            NewDir("web", NewFile("routes.go")),
        }

        for _, s := range SkipPaths {
            // ex: ./.git/git.go
            // ex: ./node_modules/node_modules.go
            files = append(files, NewDir(s, NewFile(s+".go")))
        }

        mfs := MockFS{
            // ./
            NewDir(".", files...),
        }

        list, err := GoFilesFS("/", mfs)

        if err != nil {
            b.Fatal(err)
        }

        lexp := 2
        lact := len(list)
        if lact != lexp {
            b.Fatalf("expected list to have %d files, but got %d", lexp, lact)
        }

        sort.Strings(list)

        exp := []string{"foo.go", "web/routes.go"}
        for i, a := range list {
            e := exp[i]
            if e != a {
                b.Fatalf("expected %q to match expected %q", list, exp)
            }
        }

    }
}

本次setup的程式碼非常簡單高效地完成了我們所需的工作,如果我們需要在測試中增加檔案或資料夾,可以通過插入一行或兩行來迅速完成。更重要的是,在嘗試編寫測試時,我們不會因複雜的setup程式碼而分心。

BenchmarkGoFilesFS-16                                       432418              2605 ns/op

總結

​使用BenchmarkGoFilesJIT方式,我們有很多直接操作filesystem的檔案setupteardown程式碼(譯者注:指檔案結構的建立和銷燬),這會讓測試程式碼本身引入很多潛在的errorbugsetupteardown程式碼會讓測試的重心偏移,其複雜性使得很難對測試方案進行更改。而且,這種方式的基準測試效能最差。

​不同的是,BenchmarkGoFilesExistingFiles方式使用預先在testdata中準備好的檔案結構場景。這使得測試過程不再需要setup程式碼,僅僅需要為測試程式碼指明場景在磁碟中的位置。這種方式還有其它便利之處,例如其使用的是可以用標準工具輕鬆編輯和操縱的真實檔案。與JIT方式相比,因其使用了已存在的場景資料,這極大地增加了測試的效能。其成本是需要在repo中建立和提交很多的場景資料,而且這些場景資料很容易被其他的測試程式碼濫用,最終導致測試用例變得脆弱不堪。

​這兩種方式都有其它的一些缺陷,比如難以模擬大檔案、檔案的許可權、錯誤等等,而io/fs,可以幫我們解決這些問題!

​我們已經看到了如何通微小的程式碼改動來使用io/fs包,得益於此,我們的測試程式碼變得更易於編寫。這種方式不需要teardown程式碼,設定場景資料就像為切片追加資料一樣簡單,測試中的修改也變得遊刃有餘。我們的MockFile型別可以讓我們像MockFS型別一樣模擬出檔案的大小、檔案的許可權、錯誤甚至更多。最重要的是,我們看到,通過使用 io / fs 並實現其介面,與 JIT 測試相比,我們可以將檔案系統測試的速度提高 300%以上。

goos: darwin
goarch: amd64
pkg: fsdemo
cpu: Intel(R) Xeon(R) W-2140B CPU @ 3.20GHz
BenchmarkGoFilesFS-16                               432418                          2605 ns/op
BenchmarkGoFilesExistingFiles-16                      9795                        120648 ns/op
BenchmarkGoFilesJIT-16                                1470                        819064 ns/op

​雖然本文介紹瞭如何使用新的io/fs包來增強我們的測試,但這只是該包的冰山一角。比如,考慮一個檔案轉換管道,該管道根據檔案的型別在檔案上執行轉換程式。再比如,將.md 檔案從 Markdown 轉換為 HTML,等等。使用io/fs包,您可以輕鬆建立帶有介面的管道,並且測試該管道也相對簡單。 Go 1.16 有很多令人興奮的地方,但是,對我來說,io/fs包是最讓我興奮的一個。

譯者原文

更多原創文章乾貨分享,請關注公眾號
  • 探索 Go1.16 io/fs 包以提高測試效能和可測試性
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章