對 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 而耿耿於懷:
不過就像 Russ Cox 在上述 issue 中的 comment 那樣:“我想我會認為 io.File 應該是介面,但現在這一切都沒有意義了”:
但在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 實現時,對一個抽象的檔案系統介面的需求就已經被提了出來並給出了實現:
最終這份實現以 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 的常見 “新擴充套件介面” 的樣例:
以 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 條有效實踐建議,上線後收到一致好評!歡迎大家訂閱!
我的網課 “Kubernetes 實戰:高可用叢集搭建、配置、運維與應用” 在慕課網熱賣中,歡迎小夥伴們訂閱學習!
Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily
我的聯絡方式:
- 微博:https://weibo.com/bigwhite20xx
- 微信公眾號:iamtonybai
- 部落格:tonybai.com
- github: https://github.com/bigwhite
- “Gopher 部落” 知識星球:https://public.zsxq.com/groups/51284458844544
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- Go 1.16 io/fs 設計與實現及正確使用姿勢Go
- 探索 Go1.16 io/fs 包以提高測試效能和可測試性Go
- 覺得還是敲程式碼比較來勁
- 為什麼我覺得 Java 的 IO 很複雜?Java
- Go 1.16 推出 Embedding FilesGo
- 你覺得程式設計師最大的悲哀是什麼?程式設計師
- 程式設計師也得懂點兒理財知識程式設計師
- 感覺主頁設計受ThinkPHP主頁的影響很大PHP
- 鬼泣的反套路設計如何讓玩家覺得自己牛逼
- 身為程式設計師寫一百萬行程式碼的感覺程式設計師行程
- Go 1.16 中關於 go get 和 go install 你需要注意的地方Go
- Go 1.16 中 Module 功能新變化Go
- Go 1.16 中值得關注的幾個變化Go
- 2018網頁UI設計:輕鬆搞定視覺層次感網頁UI視覺
- Go 1.16 新功能特性不完全前瞻Go
- 【網路程式設計】阻塞IO程式設計的坑程式設計
- 場景設計中距離感的設計
- 結對第一次—原型設計原型
- 試了下Cursor,感覺程式設計師工種危險了程式設計師
- ui培訓教程分享:平面設計怎樣視覺空間感?UI視覺
- UI設計培訓必學,提升視覺層次感小技巧!UI視覺
- VC++視覺化程式設計第一個程式設計例項出錯C++視覺化程式設計
- 抱歉,我覺得程式設計師副業賺錢並不靠譜程式設計師
- 做了三年還覺得自己是菜鳥程式設計師程式設計師
- 作為一個程式設計師程式設計中經常碰到且覺得難的事是什麼?程式設計師
- ui設計的文字怎樣提高設計感呢?UI
- C#面對抽象程式設計第一講C#抽象程式設計
- 為什麼很多大學生都會覺得程式設計很難?程式設計
- 程式設計師們,覺得自己最興奮是什麼時候?程式設計師
- 領導如何讓下屬充滿幹勁兒
- 《Go 語言併發之道》讀後感 - 第一章Go
- 學習Vue後的感覺Vue
- 總是感覺時間不夠用?程式設計師如何管理時間?程式設計師
- 【Go併發程式設計】第一篇 – Goroutines排程Go程式設計
- 【Go併發程式設計】第一篇 - Goroutines排程Go程式設計
- Common Sense Media:70%美國兒童對體驗VR感興趣VR
- 深圳IO:Shenzhen IO for Mac(燒腦程式設計遊戲)Mac程式設計遊戲
- io.EOF設計的缺陷和改進