Go 1.16 io/fs 設計與實現及正確使用姿勢
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 包被廢棄了,對應的函式在io
和os
中。
讓我們來看看這個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
這個包被廢棄了,轉移到了io
和os
包中。事實證明,存在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.Reader
和io.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.FS
。io/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.FS
的Open
實現就好了,只要滿足 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)
}
}
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- 對 Go 1.16 io/fs 設計的第一感覺:得勁兒!Go
- Postman 正確使用姿勢Postman
- TiDB 的正確使用姿勢TiDB
- Redis的正確使用姿勢Redis
- 實現Flutter彈窗的正確姿勢..Flutter
- 中國菜刀使用(實戰正確姿勢)
- 使用快取的正確姿勢快取
- laravel 使用 es 的正確姿勢Laravel
- 使用列舉的正確姿勢
- Java日誌正確使用姿勢Java
- BigDecimal 在資金計算時正確使用姿勢Decimal
- 這才是實現分散式鎖的正確姿勢!分散式
- 「分散式」實現分散式鎖的正確姿勢?!分散式
- 原始碼|使用FutureTask的正確姿勢原始碼
- 在vscode使用editorconfig的正確姿勢VSCode
- 虛幻私塾的正確使用姿勢
- Spring Boot使用AOP的正確姿勢Spring Boot
- 使用 react Context API 的正確姿勢ReactContextAPI
- Swift中使用Contains的正確姿勢SwiftAI
- npm run dev 的正確使用姿勢NPMdev
- 程式設計師玩連連看的正確姿勢程式設計師
- Go 中數字轉換字串的正確姿勢Go字串
- GIT使用rebase和merge的正確姿勢Git
- git commit 的正確姿勢GitMIT
- redis應用系列一:分散式鎖正確實現姿勢Redis分散式
- 翻譯 | 新手開始學習程式設計的正確姿勢程式設計
- Go併發程式設計--正確使用goroutineGo程式設計
- 提意見的正確"姿勢"
- 擼.NET Core的正確姿勢
- Homestead 開啟mongodb正確姿勢MongoDB
- 開啟Git的正確姿勢Git
- 玩轉 Ceph 的正確姿勢
- 【通俗易懂】JWT-使用的可能正確姿勢JWT
- 在Vue中使用JSX的正確姿勢(有福利)VueJS
- 正確姿勢使用vue cli3建立專案Vue
- “5Why分析法”的正確使用姿勢
- 探索 Go1.16 io/fs 包以提高測試效能和可測試性Go
- MySQL 5.6建索引的正確姿勢MySql索引