對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

bigwhite-github 發表於 2021-03-24
Go

對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

1. 設計 io/fs 的背景

Go 語言的介面是 Gopher 最喜歡的語法元素之一,其隱式的契約滿足和 “當前唯一可用的泛型機制” 的特質讓其成為面向組合程式設計的強大武器,其存在為 Go 建立事物抽象奠定了基礎,同時也是建立抽象的主要手段。

Go 語言從誕生至今,最成功的介面定義之一就是 io.Writer 和 io.Reader 介面:

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Reader interface {
    Read(p []byte) (n int, err error)
}

這兩個介面建立了對資料來源中的資料操作的良好的抽象,通過該抽象我們可以讀或寫滿足這兩個介面的任意資料來源:

  • 字串
r := strings.NewReader("hello, go")
r.Read(...)
  • 位元組序列
r := bytes.NewReader([]byte("hello, go"))
r.Read(...)
  • 檔案內資料
f := os.Open("foo.txt") // f 滿足io.Reader
f.Read(...)
  • 網路 socket
r, err :=  net.DialTCP("192.168.0.10", nil, raddr *TCPAddr) (*TCPConn, error)
r.Read(...)
  • 構造 HTTP 請求
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader([]byte("hello, go"))
  • 讀取壓縮檔案內容
func main() {
    f, err := os.Open("hello.txt.gz")
    if err != nil {
        log.Fatal(err)
    }

    zr, err := gzip.NewReader(f)
    if err != nil {
        log.Fatal(err)
    }

    if _, err := io.Copy(os.Stdout, zr); err != nil {
        log.Fatal(err)
    }

    if err := zr.Close(); err != nil {
        log.Fatal(err)
    }
}

... ...

能構架出 io.Reader 和 Writer 這樣的抽象,與 Go 最初核心團隊的深厚的 Unix 背景是密不可分的,這一抽象可能深受 “在 UNIX 中,一切都是位元組流” 這一設計哲學的影響。

Unix 還有一個設計哲學:一切都是檔案,即在 Unix 中,任何有 I/O 的裝置,無論是檔案、socket、驅動等,在開啟裝置之後都有一個對應的檔案描述符,Unix 將對這些裝置的操作簡化在抽象的檔案中了。使用者只需要開啟檔案,將得到的檔案描述符傳給相應的操作函式,作業系統核心就知道如何根據這個檔案描述符得到具體裝置資訊,內部隱藏了對各種裝置進行讀寫的細節。

並且 Unix 還使用樹型的結構將各種抽象的檔案 (資料檔案、socket、磁碟驅動器、外接裝置等) 組織起來,通過檔案路徑對其進行訪問,這樣的一個樹型結構構成了檔案系統。

不過由於歷史不知名的某個原因,Go 語言並沒有在標準庫中內建對檔案以及檔案系統的抽象!我們知道如今的os.File是一個具體的結構體型別,而不是抽象型別:

// $GOROOT/src/os/types.go

// File represents an open file descriptor.
type File struct {
        *file // os specific
}

結構體 os.File 中唯一的欄位 file 指標還是一個作業系統相關的型別,我們以 os/file_unix.go 為例,在 unix 中,file 的定義如下:

// file is the real representation of *File.
// The extra level of indirection ensures that no clients of os
// can overwrite this data, which could cause the finalizer
// to close the wrong file descriptor.
type file struct {
        pfd         poll.FD
        name        string
        dirinfo     *dirInfo // nil unless directory being read
        nonblock    bool     // whether we set nonblocking mode
        stdoutOrErr bool     // whether this is stdout or stderr
        appendMode  bool     // whether file is opened for appending
}

Go 語言之父 Rob Pike 對當初 os.File 沒有被定義為 interface 而耿耿於懷

對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

不過就像 Russ Cox 在上述 issue 中的 comment 那樣:“我想我會認為 io.File 應該是介面,但現在這一切都沒有意義了”:

對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

但在Go 1.16的 embed 檔案功能設計過程中,Go 核心團隊以及參與討論的 Gopher 們認為引入一個對 File System 和 File 的抽象,將會像上面的 io.Reader 和 io.Writer 那樣對 Go 程式碼產生很大益處,同時也會給 embed 功能的實現帶去便利!於是 Rob Pike 和 Russ Cox 親自上陣完成了io/fs 的設計

2. 探索 io/fs 包

io/fs 的加入也不是 “臨時起意”,早在很多年前的 godoc 實現時,對一個抽象的檔案系統介面的需求就已經被提了出來並給出了實現:

對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

最終這份實現以 godoc 工具的vfs 包的形式一直長期存在著。雖然它的實現有些複雜,抽象程度不夠,但卻對io/fs 包的設計有著重要的參考價值。

Go 語言對檔案系統與檔案的抽象以 io/fs 中的 FS 介面型別和 File 型別落地,這兩個介面的設計遵循了 Go 語言一貫秉持的“小介面原則”,並符合開閉設計原則 (對擴充套件開放,對修改關閉)。

// $GOROOT/src/io/fs/fs.go
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 介面代表虛擬檔案系統的最小抽象,它僅包含一個 Open 方法;File 介面則是虛擬檔案的最小抽象,僅包含抽象檔案所需的三個共同方法 (不能再少了)。我們可以基於這兩個介面通過Go 常見的嵌入介面型別的方式進行擴充套件,就像 io.ReadWriter 是基於 io.Reader 的擴充套件那樣。在這份設計提案中,作者還將這種方式命名為extension interface,即在一個基本介面型別的基礎上,新增一到多個新方法以形成一個新介面。比如下面的基於 FS 介面的 extension interface 型別 StatFS:

// A StatFS is a file system with a Stat method.
type StatFS interface {
        FS

        // Stat returns a FileInfo describing the file.
        // If there is an error, it should be of type *PathError.
        Stat(name string) (FileInfo, error)
}

對於 File 這個基本介面型別,fs 包僅給出一個 extension interface:ReadDirFile,即在 File 介面的基礎上增加了一個 ReadDir 方法形成的,這種用擴充套件方法名 + 基礎介面名來命名一個新介面型別的方式也是 Go 的慣用法。

對於 FS 介面,fs 包給出了一些擴充套件 FS 的常見 “新擴充套件介面” 的樣例:

對 Go 1.16 io/fs 設計的第一感覺:得勁兒!

以 fs 包的 ReadDirFS 介面為例:

// $GOROOT/src/io/fs/readdir.go
type ReadDirFS interface {
    FS

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

// ReadDir reads the named directory
// and returns a list of directory entries sorted by filename.
//
// If fs implements ReadDirFS, ReadDir calls fs.ReadDir.
// Otherwise ReadDir calls fs.Open and uses ReadDir and Close
// on the returned file.
func ReadDir(fsys FS, name string) ([]DirEntry, error) {
    if fsys, ok := fsys.(ReadDirFS); ok {
        return fsys.ReadDir(name)
    }

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

    dir, ok := file.(ReadDirFile)
    if !ok {
        return nil, &PathError{Op: "readdir", Path: name, Err: errors.New("not implemented")}
    }

    list, err := dir.ReadDir(-1)
    sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() })
    return list, err
}

我們看到伴隨著 ReadDirFS,標準庫還提供了一個 helper 函式:ReadDir。該函式的第一個引數為 FS 介面型別的變數,在其內部實現中,ReadDir 先通過型別斷言判斷傳入的 fsys 是否實現了 ReadDirFS,如果實現了,就直接呼叫其 ReadDir 方法;如果沒有實現則給出了常規實現。其他幾個 FS 的 extension interface 也都有自己的 helper function,這也算是 Go 的一個慣例。如果你要實現你自己的 FS 的擴充套件,不要忘了這個慣例:給出伴隨你的擴充套件介面的 helper function

標準庫中一些涉及虛擬檔案系統的包在 Go 1.16 版本中做了對 io/fs 的適配,比如:os、net/http、html/template、text/template、archive/zip 等。

以 http.FileServer 為例,Go 1.16 版本之前建立一個靜態檔案 Server 一般這麼來寫:

// github.com/bigwhite/experiments/blob/master/iofs/fileserver_classic.go
package main

import "net/http"

func main() {
    http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))
}

Go 1.16 http 包對 fs 的 FS 和 File 介面做了適配後,我們可以這樣寫:

// github.com/bigwhite/experiments/blob/master/iofs/fileserver_iofs.go
package main

import (
    "net/http"
    "os"
)

func main() {
    http.ListenAndServe(":8080", http.FileServer(http.FS(os.DirFS("./"))))
}

os 包新增的 DirFS 函式返回一個 fs.FS 的實現:一個以傳入 dir 為根的檔案樹構成的 File System。

我們可以參考 DirFS 實現一個 goFilesFS,該 FS 的實現僅返回以.go 為字尾的檔案:

// github.com/bigwhite/experiments/blob/master/iofs/gofilefs/gofilefs.go

package gfs

import (
    "io/fs"
    "os"
    "strings"
)

func GoFilesFS(dir string) fs.FS {
    return goFilesFS(dir)
}

type goFile struct {
    *os.File
}

func Open(name string) (*goFile, error) {
    f, err := os.Open(name)
    if err != nil {
        return nil, err
    }
    return &goFile{f}, nil
}

func (f goFile) ReadDir(count int) ([]fs.DirEntry, error) {
    entries, err := f.File.ReadDir(count)
    if err != nil {
        return nil, err
    }
    var newEntries []fs.DirEntry

    for _, entry := range entries {
        if !entry.IsDir() {
            ss := strings.Split(entry.Name(), ".")
            if ss[len(ss)-1] != "go" {
                continue
            }
        }
        newEntries = append(newEntries, entry)
    }
    return newEntries, nil
}

type goFilesFS string

func (dir goFilesFS) Open(name string) (fs.File, error) {
    f, err := Open(string(dir) + "/" + name)
    if err != nil {
        return nil, err // nil fs.File
    }
    return f, nil
}

上述 GoFilesFS 的實現中:

  • goFilesFS 實現了 io/fs 的 FS 介面,而其 Open 方法返回的 fs.File 例項為我自定義的 goFile 結構;
  • goFile 結構通過嵌入 *os.File 滿足了 io/fs 的 File 介面;
  • 我們重寫 goFile 的 ReadDir 方法 (覆蓋 os.File 的同名方法),在這個方法中我們過濾掉非.go 字尾的檔案。

有了 GoFilesFS 的實現後,我們就可以將其傳給 http.FileServer 了:

// github.com/bigwhite/experiments/blob/master/iofs/fileserver_gofilefs.go
package main

import (
    "net/http"

    gfs "github.com/bigwhite/testiofs/gofilefs"
)

func main() {
    http.ListenAndServe(":8080", http.FileServer(http.FS(gfs.GoFilesFS("./"))))
}

通過瀏覽器開啟 localhost:8080 頁面,我們就能看到僅由 go 原始檔組成的檔案樹!

3. 使用 io/fs 提高程式碼可測性

抽象的介面意味著降低耦合,意味著程式碼可測試性的提升。Go 1.16 增加了對檔案系統和檔案的抽象之後,我們以後再面對檔案相關程式碼時,我們便可以利用 io/fs 提高這類程式碼的可測試性。

我們有這樣的一個函式:

func FindGoFiles(dir string) ([]string, error)

該函式查詢出 dir 下所有 go 原始檔的路徑並放在一個 [] string 中返回。我們可以很輕鬆的給出下面的第一版實現:

// github.com/bigwhite/experiments/blob/master/iofs/gowalk/demo1/gowalk.go

package demo

import (
    "os"
    "path/filepath"
    "strings"
)

func FindGoFiles(dir string) ([]string, error) {
    var goFiles []string
    err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
        if info.IsDir() {
            return nil
        }

        ss := strings.Split(path, ".")
        if ss[len(ss)-1] != "go" {
            return nil
        }

        goFiles = append(goFiles, path)
        return nil
    })
    if err != nil {
        return nil, err
    }

    return goFiles, nil
}

這一版的實現直接使用了 filepath 的 Walk 函式,它與 os 包是緊繫結的,即要想測試這個函式,我們需要在磁碟上真實的構造出一個檔案樹,就像下面這樣:

$tree testdata
testdata
└── foo
    ├── 1
    │   └── 1.txt
    ├── 1.go
    ├── 2
    │   ├── 2.go
    │   └── 2.txt
    └── bar
        ├── 3
        │   └── 3.go
        └── 4.go

按照 go 慣例,我們將測試依賴的外部資料檔案放在 testdata 下面。下面是針對上面函式的測試檔案:

// github.com/bigwhite/experiments/blob/master/iofs/gowalk/demo1/gowalk_test.go
package demo

import (
    "testing"
)

func TestFindGoFiles(t *testing.T) {
    m := map[string]bool{
        "testdata/foo/1.go":       true,
        "testdata/foo/2/2.go":     true,
        "testdata/foo/bar/3/3.go": true,
        "testdata/foo/bar/4.go":   true,
    }

    files, err := FindGoFiles("testdata/foo")
    if err != nil {
        t.Errorf("want nil, actual %s", err)
    }

    if len(files) != 4 {
        t.Errorf("want 4, actual %d", len(files))
    }

    for _, f := range files {
        _, ok := m[f]
        if !ok {
            t.Errorf("want [%s], actual not found", f)
        }
    }
}

FindGoFiles 函式的第一版設計顯然可測性較差,需要對依賴特定佈局的磁碟上的檔案,雖然 testdata 也是作為原始碼提交到程式碼倉庫中的。

有了 io/fs 包後,我們用 FS 介面來提升一下 FindGoFiles 函式的可測性,我們重新設計一下該函式:

// github.com/bigwhite/experiments/blob/master/iofs/gowalk/demo2/gowalk.go

package demo

import (
    "io/fs"
    "strings"
)

func FindGoFiles(dir string, fsys fs.FS) ([]string, error) {
    var newEntries []string
    err := fs.WalkDir(fsys, dir, func(path string, entry fs.DirEntry, err error) error {
        if entry == nil {
            return nil
        }

        if !entry.IsDir() {
            ss := strings.Split(entry.Name(), ".")
            if ss[len(ss)-1] != "go" {
                return nil
            }
            newEntries = append(newEntries, path)
        }
        return nil
    })

    if err != nil {
        return nil, err
    }

    return newEntries, nil
}

這次我們給 FindGoFiles 增加了一個 fs.FS 型別的引數 fsys,這是解除掉該函式與具體 FS 實現的關鍵。當然 demo1 的測試方法同樣適用於該版 FindGoFiles 函式:

// github.com/bigwhite/experiments/blob/master/iofs/gowalk/demo2/gowalk_test.go
package demo

import (
    "os"
    "testing"
)

func TestFindGoFiles(t *testing.T) {
    m := map[string]bool{
        "testdata/foo/1.go":       true,
        "testdata/foo/2/2.go":     true,
        "testdata/foo/bar/3/3.go": true,
        "testdata/foo/bar/4.go":   true,
    }

    files, err := FindGoFiles("testdata/foo", os.DirFS("."))
    if err != nil {
        t.Errorf("want nil, actual %s", err)
    }

    if len(files) != 4 {
        t.Errorf("want 4, actual %d", len(files))
    }

    for _, f := range files {
        _, ok := m[f]
        if !ok {
            t.Errorf("want [%s], actual not found", f)
        }
    }
}

但這不是我們想要的,既然我們使用了 io/fs.FS 介面,那麼一切實現了 fs.FS 介面的實體均可被用來構造針對 FindGoFiles 的測試。但自己寫一個實現了 fs.FS 介面以及 fs.File 相關介面還是比較麻煩的,Go 標準庫已經想到了這點,為我們提供了 testing/fstest 包,我們可以直接利用 fstest 包中實現的基於 memory 的 FS 來對 FindGoFiles 進行測試:

// github.com/bigwhite/experiments/blob/master/iofs/gowalk/demo3/gowalk_test.go
package demo

import (
    "testing"
    "testing/fstest"
)

/*
$tree testdata
testdata
└── foo
    ├── 1
    │   └── 1.txt
    ├── 1.go
    ├── 2
    │   ├── 2.go
    │   └── 2.txt
    └── bar
        ├── 3
        │   └── 3.go
        └── 4.go

5 directories, 6 files

*/

func TestFindGoFiles(t *testing.T) {
    m := map[string]bool{
        "testdata/foo/1.go":       true,
        "testdata/foo/2/2.go":     true,
        "testdata/foo/bar/3/3.go": true,
        "testdata/foo/bar/4.go":   true,
    }

    mfs := fstest.MapFS{
        "testdata/foo/1.go":       {Data: []byte("package foo\n")},
        "testdata/foo/1/1.txt":    {Data: []byte("1111\n")},
        "testdata/foo/2/2.txt":    {Data: []byte("2222\n")},
        "testdata/foo/2/2.go":     {Data: []byte("package bar\n")},
        "testdata/foo/bar/3/3.go": {Data: []byte("package zoo\n")},
        "testdata/foo/bar/4.go":   {Data: []byte("package zoo1\n")},
    }

    files, err := FindGoFiles("testdata/foo", mfs)
    if err != nil {
        t.Errorf("want nil, actual %s", err)
    }

    if len(files) != 4 {
        t.Errorf("want 4, actual %d", len(files))
    }

    for _, f := range files {
        _, ok := m[f]
        if !ok {
            t.Errorf("want [%s], actual not found", f)
        }
    }
}

由於 FindGoFiles 接受了 fs.FS 型別變數作為引數,使其可測性顯著提高,我們可以通過程式碼來構造測試場景,而無需在真實物理磁碟上構造複雜多變的測試場景。

4. 小結

io/fs 的加入讓我們易於面向介面程式設計,而不是面向 os.File 這個具體實現。io/fs 的加入絲毫沒有違和感,就好像這個包以及其中的抽象在 Go 1.0 版本釋出時就存在的一樣。這也是 Go interface 隱式依賴的特質帶來的好處,讓人感覺十分得勁兒!

本文中涉及的程式碼可以在這裡下載。https://github.com/bigwhite/experiments/tree/master/iofs


Go 技術專欄 “改善 Go 語⾔程式設計質量的 50 個有效實踐” 正在慕課網火熱熱銷中!本專欄主要滿足廣大 gopher 關於 Go 語言進階的需求,圍繞如何寫出地道且高質量 Go 程式碼給出 50 條有效實踐建議,上線後收到一致好評!歡迎大家訂閱!

img{512x368}

我的網課 “Kubernetes 實戰:高可用叢集搭建、配置、運維與應用” 在慕課網熱賣中,歡迎小夥伴們訂閱學習!

img{512x368}

Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯絡方式:

更多原創文章乾貨分享,請關注公眾號
  • 對 Go 1.16 io/fs 設計的第一感覺:得勁兒!
  • 加微信實戰群請加微信(註明:實戰群):gocnio