探索 Go1.16 io/fs 包以提高測試效能和可測試性
- 原文地址:https://www.gopherguides.com/articles/golang-1.16-io-fs-improve-test-performance
- 原文作者:Mark Bates
- 譯者:richard
io/fs
概述及其存在的必要性
要理解為什麼 Go
在1.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.ReadDirFile
和File.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 進行測試
鑑於我們已經擁有了MockFS
和 MockFile
,那麼是時候為GoFilesFS
函式編寫測試了。依例,我們首先要為測試設定資料夾和檔案結構。通過兩個輔助函式NewFile
和NewDir
、以及使用切片直接構建一個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
的檔案setup
和teardown
程式碼(譯者注:指檔案結構的建立和銷燬),這會讓測試程式碼本身引入很多潛在的error
和bug
。setup
和teardown
程式碼會讓測試的重心偏移,其複雜性使得很難對測試方案進行更改。而且,這種方式的基準測試效能最差。
不同的是,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
包是最讓我興奮的一個。
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 軟體效能測試和可靠性測試
- fio 命令 測試IO效能
- 如何提高程式碼的可測試性
- 介面測試和效能測試的區別
- PerfDog 助力自動化效能測試探索
- Jmeter介面測試+效能測試JMeter
- 軟體效能測試有哪些效能指標?可做效能測試的軟體檢測機構安利指標
- (一)效能測試(壓力測試、負載測試)負載
- 效能測試
- 微服務測試之效能測試微服務
- 效能測試之測試指標指標
- MySQL 5.6 innodb_io_capacity引數效能測試MySql
- 提升軟體測試效率與靈活性:探索Mock測試的重要性Mock
- 進行app效能和安全性測試的重要性APP
- 軟體相容性測試該怎麼進行?哪些軟體測試公司可做相容性測試?
- 測試人員如何提高API功能測試效率?API
- 新潮測試平臺之效能測試
- 提高應用程式安全性的測試和方法有哪些?
- 【PG效能測試】pgbench效能測試工具簡單使用
- Jmeter效能測試:高併發分散式效能測試JMeter分散式
- 測試開發之效能篇-效能測試設計
- IO測試工具之fio
- 效能測試面試題面試題
- 功能測試、自動化測試、效能測試的區別
- 小白測試系列:介面測試與效能測試的區別
- 【效能測試】效能測試各知識第1篇:效能測試大綱【附程式碼文件】
- 效能測試流程
- Kafka效能測試Kafka
- Redis 效能測試Redis
- 效能測試-概述
- JMeter效能測試JMeter
- iOS打測試包與分發測試iOS
- 初識效能測試(測試小白麵試總結)
- 測試測試測試測試測試測試
- MYSQL 效能測試方法 - 基準測試(benchmarking)MySql
- 效能測試有哪些指標需要測試?指標
- 效能測試之測試分析與調優
- 軟體效能測試常見指標。在哪裡測試測試?指標