Go 1.16 io/fs 設計與實現及正確使用姿勢

xiaolongtonguxe發表於2021-04-13

Go 1.16 io/fs 設計與實現及正確使用姿勢

原文來自我的部落格

摘要

go1.16 新增了一個包,io/fs,用來統一標準庫檔案 io 相關的訪問方式。本文通過對比新舊檔案 io 的用法差異,帶著問題檢視原始碼:標準庫是如何保證相容性的,如何實現一個基於作業系統中的 FS,以及如何做單元測試。相信看完後大家會掌握 go1.16 之後檔案操作的正確姿勢。

背景

最近做的一個專案需要頻繁和檔案系統打交道,在本地寫了不少單元測試。剛好近期 go 更新到了 1.16,新增了io/fs包,抽象了統一的檔案訪問相關介面,對測試也更友好,於是在專案中實踐了一下,體驗確實不錯。

為了讓大家更好的瞭解它,使用它,今天我們來從聊聊它的設計與實現。

下一篇會通過fs.FS介面來設計和實現一個物件儲存 FS,敬請期待。

先來看看我們現在是怎麼處理檔案 io 的。

go 1.16 前後的檔案 io 對比

// findExtFile 查詢dir目錄下的所有檔案,返回第一個檔名以ext為副檔名的檔案內容
// 
// 假設一定存在至少一個這樣的檔案
func findExtFile(dir string, ext string) ([]byte, error) {
    entries, err := ioutil.ReadDir(dir)
    if err != nil {
        return nil, err
    }
    for _, e := range entries {
        if filepath.Ext(e.Name()) == ext && !e.IsDir() {
            name := filepath.Join(dir, e.Name())
            // 其實可以一行程式碼返回,這裡只是為了展示更多的io操作
            // return ioutil.ReadFile(name)
            f, err := os.Open(name)
            if err != nil {
                return nil, err
            }
            defer f.Close()
            return ioutil.ReadAll(f)
        }
    }
    panic("never happen")
}

註釋說得很清楚這函式是要幹啥了,這裡面有幾個關鍵函式,ioutil.ReadDir用於讀取目錄下的所有檔案(非遞迴),os.Open用於開啟檔案,最後ioutil.ReadAll用於將整個檔案內容讀到記憶體中。

比較直接,我們來看看在 go1.16 及之後對應的函式實現:

func findExtFileGo116(dir string, ext string) ([]byte, error) {
    fsys := os.DirFS(dir)                 // 以dir為根目錄的檔案系統,也就是說,後續所有的檔案在這目錄下
    entries, err := fs.ReadDir(fsys, ".") // 讀當前目錄
    if err != nil {
        return nil, err
    }
    for _, e := range entries {
        if filepath.Ext(e.Name()) == ext && !e.IsDir() {
            // 也可以一行程式碼返回
            // return fs.ReadFile(fsys, e.Name())
            f, err := fsys.Open(e.Name()) // 檔名fullname `{dir}/{e.Name()}``
            if err != nil {
                return nil, err
            }
            defer f.Close()
            return io.ReadAll(f)
        }
    }
    panic("never happen")
}

程式碼主體基本沒變,最大的不同就是在檔案 io 呼叫上,我們使用了一個os.DirFS,這是 go 內建的基於目錄樹的 os 檔案系統實現,接下來的幾個操作都是基於這個 fs。比如讀取目錄內容fs.ReadDir(fsys, '')還有開啟檔案fsys.Open(file),實際上檔案 io 都委託給這個 fs 了,新增了一層抽象。

注意到個別熟悉的老朋友ioutil.ReadAll變成了io.ReadAll,這也是 go1.16 的一個變化,ioutil 包被廢棄了,對應的函式在ioos中。

讓我們來看看這個dirFS到底是什麼,它的定義是這樣的:

func DirFS(dir string) fs.FS {
    return dirFS(dir)
}

type dirFS string

func (dir dirFS) Open(name string) (fs.File, error) {
    if !fs.ValidPath(name) || runtime.GOOS == "windows" && containsAny(name, `\:`) {
        return nil, &PathError{Op: "open", Path: name, Err: ErrInvalid}
    }
    f, err := Open(string(dir) + "/" + name)
    if err != nil {
        return nil, err // nil fs.File
    }
    return f, nil
}

os.DirFS返回了一個fs.FS型別的介面實現,這個介面只有一個方法Open,實現上也很簡單,直接呼叫os.Open得到一個*os.File物件,注意到返回型別是fs.File,所以這意味著前者實現了後者。

還有一個關鍵點,開啟的檔名是以 dir 為根路徑的,完整的檔名是dir/name,這點一定要注意,並且 Open 的 name 引數一定不能以/打頭或者結尾,為什麼會這樣呢?讓我們走進 go1.16 的io/fs包。

Go 1.16 fs 包

Go1.16 新增了一個io/fs包,定義了fs.FS的介面,抽象了一個只讀的檔案樹,標準庫的包已經適配了這個介面。也是對新增的embed包的抽象,所有的檔案相關的都可以基於這個包來抽象,我覺得挺好的,方便了測試。不太好的地方就是只讀的,無法寫,當然這不妨礙我們去擴充套件介面。

同時,io/ioutil這個包被廢棄了,轉移到了ioos包中。事實證明,存在util這樣的包是設計上懶的表現,可能會有越來越多無關的程式碼都放這裡面,最後給使用者造成困惑。

先來看看 fs 這個包是什麼,怎麼用。

// An FS provides access to a hierarchical file system.
//
// The FS interface is the minimum implementation required of the file system.
// A file system may implement additional interfaces,
// such as ReadFileFS, to provide additional or optimized functionality.
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)
}

// A File provides access to a single file.
// The File interface is the minimum implementation required of the file.
// A file may implement additional interfaces, such as
// ReadDirFile, ReaderAt, or Seeker, to provide additional or optimized functionality.
type File interface {
    Stat() (FileInfo, error)
    Read([]byte) (int, error)
    Close() error
}

FS 只有一個方法,通過名字開啟一個檔案,符合 go 的最小介面原則,後面可以組合成更復雜的 FS。注意這個fs.File不是我們熟悉的那個*os.File,而是一個介面,上一節也提及了。這介面實際上是io.Readerio.Closer的組合。所以如果需要訪問作業系統的檔案,這介面的實現可以是這樣的:

type fsys struct {}

func (*fsys) Open(name string) (fs.File, error) { return os.Open(name) }

這實現和os.dirFS幾乎一樣。但是如果就只能開啟一個檔案,似乎也沒什麼 x 用(後面會打臉),如果要讀目錄呢?實際上io/fs已經定義了幾種常用的 FS:

// ReadFileFS is the interface implemented by a file system
// that provides an optimized implementation of ReadFile.
type ReadFileFS interface {
    FS

    // ReadFile reads the named file and returns its contents.
    // A successful call returns a nil error, not io.EOF.
    // (Because ReadFile reads the whole file, the expected EOF
    // from the final Read is not treated as an error to be reported.)
    ReadFile(name string) ([]byte, error)
}

// ReadDirFS is the interface implemented by a file system
// that provides an optimized implementation of ReadDir.
type ReadDirFS interface {
    FS

    // ReadDir reads the named directory
    // and returns a list of directory entries sorted by filename.
    ReadDir(name string) ([]DirEntry, error)
}

// A GlobFS is a file system with a Glob method.
type GlobFS interface {
    FS

    // Glob returns the names of all files matching pattern,
    // providing an implementation of the top-level
    // Glob function.
    Glob(pattern string) ([]string, error)
}

// statFS, subFS, walkDir etc.

依次是用來讀整個檔案到記憶體,讀目錄和 glob 檔名匹配,這些 fs 都擴充套件了最基本的fs.FSio/fs包裡面定義的介面,標準庫裡檔案 io 相關的操作都已經實現(只讀),比如,一個通用的fsys可以是這樣的:

// Fsys is an OS File system.
type Fsys struct {
    fs.FS
    fs.ReadFileFS
    fs.ReadDirFS
    fs.GlobFS
    fs.StatFS
}

func (fs *Fsys) Open(name string) (fs.File, error) { return os.Open(name) }

func (fs *Fsys) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }

func (fs *Fsys) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }

func (fs *Fsys) Glob(pattern string) ([]string, error) { return filepath.Glob(pattern) }

func (fs *Fsys) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }

可以看到函式名,返回值都是一毛一樣的,就是包不同!感興趣的同學看看zip.Reader.Open的實現。

非也非也,想起了天龍八部裡的包不同 hhh。

注意到ReadFile()是不是似曾相識?對,就是以前常用的那個io/ioutil.ReadFile(),現在被移到到了 io 包中;同理ioutil.ReadDir也移到了os包裡

我們對比下原始碼來看看到底改了什麼東西,相容性又是如何保證的。

在 go1.15 中,函式 ReadDir簽名是:

func ReadDir(dirname string) ([]os.FileInfo, error) { /* deleted */ }

對應的os.FileInfo原始碼

// A FileInfo describes a file and is returned by Stat and Lstat.
type FileInfo interface {
    Name() string       // base name of the file
    Size() int64        // length in bytes for regular files; system-dependent for others
    Mode() FileMode     // file mode bits
    ModTime() time.Time // modification time
    IsDir() bool        // abbreviation for Mode().IsDir()
    Sys() interface{}   // underlying data source (can return nil)
}

而在 go1.16 中,是這樣的:

// A FileInfo describes a file and is returned by Stat and Lstat.
type FileInfo = fs.FileInfo

然後我們再看看 1.16 中fs.FileInfo宣告

// A FileInfo describes a file and is returned by Stat.
type FileInfo interface {
    Name() string       // base name of the file
    Size() int64        // length in bytes for regular files; system-dependent for others
    Mode() FileMode     // file mode bits
    ModTime() time.Time // modification time
    IsDir() bool        // abbreviation for Mode().IsDir()
    Sys() interface{}   // underlying data source (can return nil)
}

這。。。不就是把程式碼從 os 包剪下到了 fs 包下,然後利用了type alias的特性,舊版本的型別名字沒有變化,實際型別已經指向了io/fs包的型別,而fs包裡的。這就是為什麼標準庫是在保證相容性的前提下如何實現了這些io/fs介面的,我們其實也可以借鑑這方式來實現介面的平滑升級而不會造成不相容。

那麼搞出來這個意義何在呢?我覺得主要有兩方面的原因:

  • 遮蔽了 fs 具體的實現,我們可以按照需要替換成不同的檔案系統實現,比如記憶體/磁碟/網路/分散式的檔案系統
  • 易於單元測試,實際也是上一點的體現

這比在程式碼裡硬編碼os.Open要更靈活,如果程式碼中全是os包,則將實現完全繫結在了作業系統的檔案系統,一個最直接的問題是單元測試比較難做(好在 go 可以使用./testdata 將測試檔案打包在程式碼裡)。但這本質上還是整合測試而非單元測試,因為單元測試不依賴外部系統全在記憶體裡搞,比如時間和 OS 相關模組(比如 fs)。我們需要 mock 的 fs 來做單元測試,把 fs 抽象成介面就好了,這也是io.fs這個包最大的作用吧,單元測試是介面第一使用者。

我們來看一個具體的栗子看看如何對 fs 進行單元測試。

食用栗子

在我們的玩具栗子中,想要實現一個檢視檔案內容是否包含666文字的邏輯。

// finder.go
type Finder struct {
    fs fs.ReadFileFS
}

// fileContains666 reports whether name contains `666` text.
func (f *Finder) fileContains666(name string) (bool, error) {
    b, err := f.fs.ReadFile(name)
    return bytes.Contains(b, []byte("666")), err
}

// Example 使用食用栗子
func Example() {
    finder := &Finder{fs: &Fsys{}}
    ok, err := finder.fileContains666("path/to/file")
    // ...
}

// finder_test.go
var testFs = fstest.MapFS{
    "666.txt": {
        Data:    []byte("hello, 666"),
        Mode:    0456,
        ModTime: time.Now(),
        Sys:     1,
    },
    "no666.txt": {
        Data:    []byte("hello, world"),
        Mode:    0456,
        ModTime: time.Now(),
        Sys:     1,
    },
}

func TestFileContains666(t *testing.T) {
    finder := &Finder{fs: testFs}
    if got, err := finder.fileContains666("666.txt"); !got || err != nil {
        t.Fatalf("fileContains666(%q) = %t, %v, want true, nil", "666.txt", got, err)
    }

    if got, err := finder.fileContains666("no666.txt"); got || err != nil {
        t.Fatalf("fileContains666(%q) = %t, %v, want false, nil", "no666.txt", got, err)
    }
}

業務邏輯的結構體中 Finder 裡有一個fs介面物件,該物件提供了讀一個檔案的功能,至於具體怎麼讀,Finder 並不關心,它只關心能搞到檔案的位元組陣列,然後看看裡面是否包含 666。正常使用的時候只需要在初始化時傳一個作業系統的實現,也就是上一節中的Fsys就好了。

我們比較關心單元測試的實現,如何不依賴作業系統的檔案系統來做呢?Go1.16 提供了一個fstest.MapFS的 fs 的實現

// A MapFS is a simple in-memory file system for use in tests,
// represented as a map from path names (arguments to Open)
// to information about the files or directories they represent.
//
// deleted comment...
type MapFS map[string]*MapFile

// A MapFile describes a single file in a MapFS.
type MapFile struct {
    Data    []byte      // file content
    Mode    fs.FileMode // FileInfo.Mode
    ModTime time.Time   // FileInfo.ModTime
    Sys     interface{} // FileInfo.Sys
}

var _ fs.FS = MapFS(nil)
var _ fs.File = (*openMapFile)(nil)

// deleted...

這個 MapFS 把io/fs裡的介面都實現了,全部在記憶體中,我們只需要提供檔名 key,還有檔案的內容來初始化這個 fs,然後拿它去初始化待測的結構體就 OK 了,這樣才是真正的單元測試。

除此之外,go1.16 在 fs 的測試上還提供了一個方法,TestFS,看看,它可以用來檢測一個檔案系統的實現是否滿足要求(walk 當前及子目錄樹,開啟所有的檔案檢測行為是否滿足標準),以及是否在檔案系統中存在某些檔案:

// TestFS tests a file system implementation.
// It walks the entire tree of files in fsys,
// opening and checking that each file behaves correctly.
// It also checks that the file system contains at least the expected files.
// 
// deleted comment...
func TestFS(fsys fs.FS, expected ...string) error { /* deleted */ }

我們可以拿上一節的Fsys來試試,

if err := fstest.TestFS(new(fsys), "finder_test.go"); err != nil {
    t.Fatal(err)
}
// TestFS found errors:
//     .: Open(/.) succeeded, want error
//     .: Open(./.) succeeded, want error
//     .: Open(/) succeeded, want erro

可以看到出錯了,原因是我們沒有按照fs.FS介面 contract 裡面要求那樣,去驗證傳入的 path 的合法性:

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 引數不能以/開頭或者結尾的原因。但是我們不禁疑問,為什麼?原始碼裡沒有明確說,我覺得有下面幾個原因:

  • 規範化,/tmp//tmp,由於fs.FS只有一個Open函式,對他來說它不關心是目錄還是檔案,所以不應該有結尾的/(可能有些同學覺得以/結尾的為目錄)
  • 擴充套件性,我們可以以某個目錄 dir 作為 base fs,然後衍生出子 fs,也就是 subFS,所以不能以根目錄/打頭

確實如此,你可能注意到了,io/fs裡的確有這樣一個介面:

// A SubFS is a file system with a Sub method.
type SubFS interface {
    FS

    // Sub returns an FS corresponding to the subtree rooted at dir.
    Sub(dir string) (FS, error)
}

同理,os.dirFS也是如此。

所有為了通過 fs 的驗收,還需要改改:

func (f *fsys) Open(name string) (fs.File, error) {
    if ok := fs.ValidPath(name); !ok {
        return nil, &fs.PathError{Op: "open", Path: name, Err: os.ErrInvalid}
    }
    return os.Open(name)
}

func (f *fsys) ReadFile(name string) ([]byte, error) {
    if ok := fs.ValidPath(name); !ok {
        return nil, &fs.PathError{Op: "open", Path: name, Err: os.ErrInvalid}
    }
    return os.ReadFile(name)
}

test pass,這樣這個 fsys 的實現就是滿足 go 的要求的了!

但是,就這樣結束了嗎?我需要實現這麼多個介面嗎?為什麼os.dirFS只實現了一個fs.FS,其餘的介面,比如讀 dir 去哪兒了?

包函式

注意到一個有意思的地方,io/fs包裡面,每個介面,同時還包含了 package 的函式:

func ReadFile(fsys FS, name string) ([]byte, error) { 
    if fsys, ok := fsys.(ReadFileFS); ok {
        return fsys.ReadFile(name)
    }

    file, err := fsys.Open(name)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    var size int
    if info, err := file.Stat(); err == nil {
        size64 := info.Size()
        if int64(int(size64)) == size64 {
            size = int(size64)
        }
    }

    data := make([]byte, 0, size+1)
    for {
        if len(data) >= cap(data) {
            d := append(data[:cap(data)], 0)
            data = d[:len(data)]
        }
        n, err := file.Read(data[len(data):cap(data)])
        data = data[:len(data)+n]
        if err != nil {
            if err == io.EOF {
                err = nil
            }
            return data, err
        }
    }
}

func ReadDir(fsys FS, name string) ([]DirEntry, error) { /* deleted */ }

func Stat(fsys FS, name string) (FileInfo, error) { /* deleted ... */ }

func Sub(fsys FS, dir string) (FS, error) { /* deleted ... */ }

這些函式有一個共性,名字和返回值都是和介面方法一樣,只是多了一個引數,fs FS,我們以ReadFile為栗子說明一下。

首先會通過 type assert 判別 fs 是不是實現了ReadFileFS,如果有就會直接用它的實現返回;否則,就會開啟這個檔案,得到一個fs.File,然後呼叫File.Stat得到這檔案的大小,最後將檔案的內容全部讀到記憶體裡返回。

另外幾個函式的實現都類似。也就是說,其實並不需要我們將所有的 fs 都實現,只需要fs.FSOpen實現就好了,只要滿足 Open 返回的檔案,是fs.File或者fs.ReadDirFile介面的實現:

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

type ReadDirFile interface {
    File

    ReadDir(n int) ([]DirEntry, error)
}

type DirEntry interface {
    Name() string

    IsDir() bool

    Type() FileMode

    Info() (FileInfo, error)
}

io/fs裡的包函式會通過 Open 的檔案型別來執行對應的檔案 io。我們可以通過fstest.TestFS來驗證我們的猜想:

var Fsys fs.FS = new(fsys2)

type fsys2 struct{}

func (f *fsys2) Open(name string) (fs.File, error) {
    if !fs.ValidPath(name) {
        return nil, &fs.PathError{Op: "open", Path: name, Err: os.ErrInvalid}
    }
    return os.Open(name)
}

func TestFs(t *testing.T) {
    if err := fstest.TestFS(Fsys, "fs_test.go"); err != nil {
        t.Fatal(err)
    }
}

// === RUN   TestFs
// --- PASS: TestFs (0.10s)
// PASS

no warning, no error. 是的,我們只需要實現 Open 就足矣。

你可能已經發現了,這個實現,不是和前面的os.dirFS一摸一樣?!

是的。以上便是整個io.fs的設計和實現。

最後在提一下遞迴遍歷目錄的寫法,以前我們是用filepath.WalkDir,現在可以直接用fs.WalkDir,如:

fs.WalkDir(Fsys, "/tmp", func(path string, d fs.DirEntry, err error) error {
    fmt.Println(path)
    return nil
})

小結

在日常的開發中,我們不要使用具體的 fs 實現,而是使用其介面fs.FS,然後在需要檔案內容或者目錄等更細的操作時,使用io/fs裡的包函式,比如fs.ReadDir或者fs.ReadFile,在初始化的使用,傳入os.DirFS(dir)給 fs,在測試時,則使用fstest.MapFS

比如:

type BussinessLogic struct {
    fs fs.FS
}

func (l *BussinessLogic) doWithFile(name string) error {
    // 讀檔案
    b, err := fs.ReadFile(l.fs, name)
    // 讀目錄
    entries, err := fs.ReadDir(l.fs, name)
}

func NewBussinessLogic() *BussinessLogic {
    return &BussinessLogic{fs: os.DirFS("/")} // 注意這裡是以根目錄為基礎的fs,按需初始化
} 

// 單元測試

func TestLogic(t *testing.T) {
    fs := fstest.MapFS{/* 初始化map fs */}
    logic := &BussinessLogic{fs: fs}
    // testing...
    if err := logic.doWithFile("path/to/file"); err != nil {
        t.Fatal(err)
    }
}
更多原創文章乾貨分享,請關注公眾號
  • Go 1.16 io/fs 設計與實現及正確使用姿勢
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章