百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

图书《Go 语言标准库》更新了:io/fs 包讲解

liebian365 2024-11-09 13:43 6 浏览 0 评论

断更了很久的开源图书《Go语言标准库》终于更新了(文末阅读原文可以直达该图书)。被网友催更无数次,后续希望能不断更新!以下是这次更新的章节,是 Go1.16 新增的 io/fs 包。内容较多,希望耐心读完。

Go 语言从 1.16 开始增加了 io/fs 包,该包定义了一个文件系统需要的相关基础接口,因此我们称之为抽象文件系统。该文件系统是层级文件系统或叫树形文件系统,Unix 文件系统就是这种类型。

本节除了讲解标准库的相关内容,还会实现一个文件系统作为例子。

注意,因为抽象了一个文件系统,之前 os 包中和文件系统相关的功能都移到 io/fs 包了,os 中的原类型只是 io/fs 对应类型的别名。如果你的系统要求 Go1.16,应该优先使用 io/fs 包。

三个核心接口

一个文件系统有些必要的元素,io/fs 包提供两个最小的接口来表示,即 fs.FS 和 fs.File。但因为 fs.File 依赖 fs.FileInfo 接口,因此实际上是三个接口。

fs.FS

该接口提供了对层级文件系统的访问。一个文件系统的最低要求是必须实现 fs.FS 接口,但一般还会实现额外的接口,比如 ReadFileFS,该接口在后文讲解。

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 方法出错,应该返回 *PathError 类型的错误,该类型定义如下:
type PathError struct {
    Op   string
    Path string
    Err  error
}

返回该类型错误时,Op 字段设置为 "open",Path 字段设置为文件名,而 Err 字段描述错误原因。

注:在 os 那小节提到过该类型,Go 1.16 后,os.PathError 只是 fs.PathError 的别名。

type PathError = fs.PathError
  • 对于指定的文件名,需要满足 ValidPath(name) 函数,如果不满足,则返回 *PathError 的 Err 为 fs.ErrInvalid 或 fs.ErrNotExist 的错误。
func ValidPath(name string) bool

传递给该函数的 name 应该是一个非根,且是 / 分隔的,例如 x/y/z。除了只包含 .,其他情况不能有 ...

因为 Open 方法返回一个 fs.File 接口类型,因此一个文件系统只实现 fs.FS 还不够,需要同时实现 fs.File 接口。

fs.File

该接口提供对单个文件的访问。File 接口是文件的最低实现要求。一个文件可以实现其他接口,例如fs.ReadDirFile,fs.ReaderAt 或 fs.Seeker,以提供额外或优化的功能。

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

通过 fs.FS 接口的 Open 打开文件后,通过 fs.File 接口的 Read 方法进行读操作,这个方法和 io.Reader 接口的 Read 方法签名一样。

对操作系统有所了解的读者应该知晓(特别是 Unix 系统),目录也是文件,只是特殊的文件。因此,在遍历文件目录树时,我们通常需要判断文件是什么类型,也可能需要获取文件的一些元数据信息,比如文件名、大小、修改时间等,而这就是 Stat 方法的功能。该方法会返回一个 FileInfo 类型,它也是一个接口。这就是文件系统需要实现的第三接口,稍后讲解。

在 Go 中,你应该始终记住,打开文件,进行操作后,记得关闭文件,否则会泄露文件描述符。所以,fs.File 的第是三个方法就是 Close 方法,它的签名和 io.Closer 是一致的。

fs.FileInfo

该接口描述一个文件的元数据信息,它由 Stat 返回。为了方便,在 io/fs 包有一个 Stat 函数:

func Stat(fsys FS, name string) (FileInfo, error)

该函数接受任意的 FS 文件系统和该系统下的任意一个文件。如果 fsys 实现了 StatFS,则直接通过 StatFS 的 Stat 方法获取 FileInfo,否则需要 Open 文件,然后调用 File 的 Stat 方法来获取 FileInfo。关于 fs.StatFS 接口后文讲解。

本节开头提到了,Go1.16 开始,os 包中和文件系统相关的类型移到 io/fs 包中了,fs.FileInfo 就是其中之一。因为在 os 中已经讲过该接口了,此处不再赘述。

实现一个文件系统

介绍完这三个核心接口,我们尝试实现一个文件系统。这是一个基于内存的文件系统,这个实现相对比较简陋。

实现 fs.File 和 fs.FileInfo

实现文件系统先实现这两个接口。我们通过一个类型来实现:

type file struct {
 name    string
 content *bytes.Buffer
 modTime time.Time
 closed  bool
}

func (f *file) Read(p []byte) (int, error) {
 if f.closed {
  return 0, errors.New("file closed")
 }

 return f.content.Read(p)
}

func (f *file) Stat() (fs.FileInfo, error) {
 if f.closed {
  return nil, errors.New("file closed")
 }

 return f, nil
}

// Close 关闭文件,可以调用多次。
func (f *file) Close() error {
 f.closed = true
 return nil
}

// 实现 fs.FileInfo

func (f *file) Name() string {
 return f.name
}

func (f *file) Size() int64 {
 return int64(f.content.Len())
}

func (f *file) Mode() fs.FileMode {
 // 固定为 0444
 return 0444
}

func (f *file) ModTime() time.Time {
 return f.modTime
}

// IsDir 目前未实现目录功能
func (f *file) IsDir() bool {
 return false
}

func (f *file) Sys() interface{} {
 return nil
}
  • file 同时实现 fs.File 和 fs.FileInfo;
  • 文件内容放在 file 的 bytes.Buffer 类型中,它实现了 io.Reader,因此 file 的 Read 可以直接通过它实现;
  • 目前是一个简化实现,因此 IsDir 未实现目录功能,只返回 false;

实现 fs.FS

实现了 fs.File,通过它可以实现 fs.FS:

type FS struct {
 files map[string]*file
}

func NewFS() *FS {
 return &FS{
  files: make(map[string]*file),
 }
}

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

 if f, ok := fsys.files[name]; !ok {
  return nil, &fs.PathError{
   Op:   "open",
   Path: name,
   Err:  fs.ErrNotExist,
  }
 } else {
  return f, nil
 }
}
  • FS 类型中的 files 存放所有的文件;
  • 按照前面 Open 方法的实现要求,先通过 ValidPath 函数进行校验,接着通过 name 查找 file;

细心的读者应该会发现,io/fs 并没有提供 Write 相关的功能,那我们读什么呢?为此,我们实现一个 Write 的功能。

func (fsys *FS) WriteFile(name, content string) error {
 if !fs.ValidPath(name) {
  return &fs.PathError{
   Op:   "write",
   Path: name,
   Err:  fs.ErrInvalid,
  }
 }

 f := &file{
  name:    name,
  content: bytes.NewBufferString(content),
  modTime: time.Now(),
 }

 fsys.files[name] = f

 return nil
}

WriteFile 方法就是生成一个 file 然后存入 files 中。

验证

一个基于内存的文件系统已经实现完成,接下来需要验证下。

func TestMemFS(t *testing.T) {
 name := "x/y/name.txt"
 content := "This is polarisxu, welcome."
 memFS := memfs.NewFS()
 err := memFS.WriteFile(name, content)
 if err != nil {
  t.Fatal(err)
 }

 f, err := memFS.Open(name)
 if err != nil {
  t.Fatal(err)
 }
 defer f.Close()

 fi, err := f.Stat()
 if err != nil {
  t.Fatal(err)
 }

 t.Log(fi.Name(), fi.Size(), fi.ModTime())

 var result = make([]byte, int(fi.Size()))
 n, err := f.Read(result)
 if err != nil {
  t.Fatal(err)
 }

 if string(result[:n]) != content {
  t.Errorf("expect: %s, actual: %s", content, result[:n])
 }
}

如果测试通过,说明基于内存的简单文件系统已经完成。至于缺失的功能,本节后面再完善。

增强型接口

上面实现的内存文件系统中,目录功能是有问题的,比如我们没法遍历整个文件系统。要实现一个更完整的文件系统,需要实现 io/fs 包中的其他接口。

fs.DirEntry 和相关接口

在文件系统中,一个目录下可能会有子目录或文件,这称为 entry,在 io/fs 包中用 DirEntry 接口表示:

type DirEntry interface {
 // Name returns the name of the file (or subdirectory) described by the entry.
 // This name is only the final element of the path (the base name), not the entire path.
 // For example, Name would return "hello.go" not "/home/gopher/hello.go".
 Name() string

 // IsDir reports whether the entry describes a directory.
 IsDir() bool

 // Type returns the type bits for the entry.
 // The type bits are a subset of the usual FileMode bits, those returned by the FileMode.Type method.
 Type() FileMode

 // Info returns the FileInfo for the file or subdirectory described by the entry.
 // The returned FileInfo may be from the time of the original directory read
 // or from the time of the call to Info. If the file has been removed or renamed
 // since the directory read, Info may return an error satisfying errors.Is(err, ErrNotExist).
 // If the entry denotes a symbolic link, Info reports the information about the link itself,
 // not the link's target.
 Info() (FileInfo, error)
}
  • Name() 方法和 FileInfo 接口的 Name() 方法类似,代表的是 base name,而我们上面实现的文件系统没有处理这一点;
  • Type() 方法返回一个 fs.FileMode,表示 entry 的位类型,关于 FileMode 的详细信息在 os 包中有讲解;
  • Info() 方法和 Stat 有点类似,获取元数据信息;如果 entry 是软链接,Info() 返回的 FileInfo 是链接本身的信息,而不是目标文件;

为了方便遍历文件系统(目录),io/fs 包提供了 ReadDir 函数,用来获取某个目录下的所有目录项:

func ReadDir(fsys FS, name string) ([]DirEntry, error)

对于这个函数的实现,如果第一个参数实现了 fs.ReadDirFS 接口,直接调用该接口的 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)
}

否则看是否实现了 fs.ReadDirFile 接口,没实现则报错;否则调用该接口的 ReadDir 方法:

type ReadDirFile interface {
 File

 // 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.
 ReadDir(n int) ([]DirEntry, error)
}

这个接口的 ReadDir 比 ReadDirFS 复杂多了,但 ReadDirFS 的 ReadDir 必须自己对 entry 进行排序。此外,如果目录下内容特别多,ReadDirFile 接口会更适合,它可以分段读取。而且目录应该实现 ReadDirFile 接口。

其他 fs.FS 相关的接口

在讲解 fs.FS 接口时提到还有其他接口,用于增强 fs.FS,即嵌入了 fs.FS 接口,除了已经介绍的 ReadDirFS 接口,还有如下接口。

fs.ReadFileFS

该接口的定义如下:

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)
}

也就是说这是一个支持 ReadFile 的文件系统,如果一个文件系统实现了该接口,则 fs.ReadFile 函数会先直接使用该接口的 ReadFile 方法来实现:

func ReadFile(fsys FS, name string) ([]byte, error)

如果没实现该接口,则通过 fs.FS 的 Open 方法获取 fs.File 类型,然后调用 fs.File 的 Read 方法来实现。有兴趣可以查看 fs.ReadFile 函数的实现。

fs.StatFS

该接口的定义如下:

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)
}

如果一个文件系统支持 Stat 功能,则 fs.Stat 函数会优先使用该文件系统的 Stat 方法,否则通过 fs.FS 的 Open 方法获取 fs.File 类型,然后调用 fs.File 的 Stat 方法来实现。

fs.GlobFS

该接口的定义如下:

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)
}

类似的,实现了该接口,表示文件系统支持 Glob 方法。对应的,io/fs 提供了 Glob 函数:

func Glob(fsys FS, pattern string) (matches []string, err error)
  • 这是用于文件模式匹配的;
  • 语法和 path.Match 相同;
  • 模式(pattern)可以描述层级,比如:/usr/*/bin/ed;
  • 该函数会忽略文件系统错误,比如 IO 错误;唯一的错误是模式语法错误;

和其他 fs.FS 相关接口对应的函数一样,Glob 函数内部实现优先调用 fs.GlobFS 接口,如果没实现该接口,则使用 ReadDir 遍历目录树来查找匹配的目标。

fs.SubFS

该接口的定义如下:

type SubFS interface {
 FS

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

这个接口的作用主要是让一个文件系统支持定义子文件系统。io/fs 包也提供了一个相应的函数 Sub:

func Sub(fsys FS, dir string) (FS, error)

通过该函数可以获得一个子文件系统,该子文件系统的根由第二个参数 dir 指定。

类似的,该函数的实现会优先判断 fsys 是否实现了 fs.SubFS 接口,以便调用其 Sub 方法。如果未实现,同时 dir 是 .,则原样返回 fsys,否则返回一个新实现的 fs.FS。

不过有一点需要注意,对于 os 实现的 fs.FS 文件系统(磁盘文件系统),Sub 并不能提到 chroot 的进制,它不会限制子文件系统根之外的操作,典型的,子文件系统内部的文件软连到根之外,Sub 得到的子文件系统不会阻止这种行为。

查看 fs.Sub 函数的源码可以发现,如果 fsys 没有实现 fs.SubFS,Sub 函数返回的 FS 实现了不少 FS 相关接口。

设计思想

上面啰啰嗦嗦讲了好几个 fs.FS 相关接口,其中目的之一是希望理解其设计思想。

io/fs 包中和 fs.FS 相关的接口如下:

  • fs.ReadDirFS
  • fs.ReadFileFS
  • fs.StatFS
  • fs.SubFS
  • fs.GlobFS

Go 以简单著称,大道至简。Go 强调定义小接口。fs.FS 接口只有一个方法:Open,其他 fs.FS 相关接口都内嵌了 fs.FS 接口,以此来扩展文件系统的功能。同时 io/fs 包辅以相关便捷函数(比如 Stat、Sub、Glob 等),达到操作 fs.FS 的目的。

完善内存文件系统

是时候完善我们上面实现的内存文件系统了。

前面只是实现了文件的写入、读取,并没有实现文件系统该有的目录树。现在补充完善这部分内容,实现一个较完整的内存文件系统。具体看代码前,看看如何设计。

如何设计

先通过一个类图表示 io/fs 包相关接口的关系。

要基于这些接口实现一个文件系统,我们需要先了解一些文件系统相关的知识(前面有提到,这里总结下)。

  • 文件系统是一个树形结构,有一个根目录;
  • 一个目录下的目录项,可以是文件或子目录;
  • 一切皆文件,所以目录也是文件;(虽然如此,但两者还是有不小区别,因此实现时不一定适合使用嵌入)

所以,我们在实现 fs.FS 接口时,定义的类型 FS 有一个根目录字段:

type FS struct {
 rootDir *dir
}

文件的实现

从上面类图可以看出,一个文件需要实现 fs.File 接口,同时因为该接口依赖 fs.FileInfo 接口,我们可以选择用一个单独的类型实现 fs.FileInfo 接口,也可以直接用这个文件类型(file)实现该接口,内存文件系统直接使用文件类型实现了 fs.FileInfo 接口。此外,一个文件还是其所在目录的目录项,因此还需要实现 fs.DirEntry 接口。因此内存文件系统的 file 类型实现了以下接口:

  • fs.File
  • fs.FileInfo
  • fs.DirEntry

具体如何实现这些接口,需要先思考一个问题:文件内容用什么表示?因为是内存文件系统,因此一切都在内存中。文件内容本质上是字节数组,但因为要实现 fs.File 接口,这其中关键的是 Read 方法,它的签名和 io.Reader 接口的 Read 方法是一样的,因此在 file 类型中,我们用一个 bytes.Buffer 字段来存放文件内容。

至于其他接口的实现相对较简单,这里不赘述。值得一提的是,因为 file 类型实现了 fs.FileInfo 接口,所以在实现 Stat 方法时,直接返回 file 的实例即可。

目录的实现

对于目录,我们用类型 dir 表示,它首先是其所在目录的目录项,因此需要实现 fs.DirEntry 接口;其次目录也是文件,因此它需要实现 fs.File 接口。同时,读取目录的内容,即读取其目录项,不应该通过 Read 读取,而 fs.ReadDirFile 接口是用来读目录的,因此 dir 应该实现它。同样的,因为 fs.DirEntry 和 fs.File 都依赖 fs.FileInfo 接口,跟 file 一样,我们不单独实现,而是让 dir 直接实现它。因此内存文件系统的 dir 类型实现了以下接口:

  • fs.DirEntry
  • fs.File
  • fs.ReadDirFile
  • fs.FileInfo

因为目录涉及到有目录项,构成了一个树形结构。这里使用一个 map 来存放所有的目录项,key 是目录项的名称,value 是目录项的实例。

// dir 代表一个目录
type dir struct {
 name    string
 modTime time.Time

 // 存放该目录下的子项,value 可能是 *dir 或 *file
 children map[string]fs.DirEntry
}

因为 Read 对于目录来说没有实际价值,因此它的实现返回错误即可。dir 的难点在于实现 fs.ReadDirFile 接口中的 ReadDir 方法:给定一个目录,该方法需要返回该目录下的所有目录项。而且,根据 fs.ReadDirFile 中 ReadDir 方法的实现要求,它应该支持分步读取目录项。所以,在 dir 类型中增加一个字段:idx,用来表示当前读取到什么位置的目录项了。具体实现代码见后文。

fs.FS 接口的实现

对于内存文件系统,如何实现 Open 方法呢?我们需要根据参数 name 在文件系统的目录树中找到该文件所在位置。因此,我们将该文件用 / 分隔,从左到右,一部分一部分,从文件系统的根开始,在目录树中查找,直到找到对应的文件,然后返回该文件。如果没找到,返回错误。

具体来说,在遍历文件系统目录树时,如果某个目录项是文件,且是 name 的最后一部分,表示找到了该文件;如果某个目录项是目录,则递归遍历它的目录项。

创建目录和文件的实现

io/fs 没有定义创建目录和文件的接口,从这个维度看,io/fs 定义的文件系统是一个只读文件系统。但实际的文件系统,必然要有写入的接口。因此我们还需要实现创建目录和创建文件(写入内容)的功能。

先看创建目录的实现。

创建目录,实际上是构建一个层级关系。方法签名如下:

func (fsys *FS) MkdirAll(path string) error

根据传入的 path,比如 x/y/z,能够创建对应的目录结构。因此我们将 path 通过 / 分隔,从左到右,一步步从文件系统的根开始在对应的层级创建目录。创建时,需要判断是否已经存在对应的目录。关键代码如下:

cur := fsys.rootDir
parts := strings.Split(path, "/")
for _, part := range parts {
  child := cur.children[part]
  if child == nil {
    childDir := &dir{
      name:     part,
      modTime:  time.Now(),
      children: make(map[string]fs.DirEntry),
    }
    cur.children[part] = childDir
    cur = childDir
  } else {
    childDir, ok := child.(*dir)
    if !ok {
      return fmt.Errorf("%s is not directory", part)
    }

    cur = childDir
  }
}

文件的创键和内容写入通过 WriteFile 方法实现。签名如下:

func (fsys *FS) WriteFile(name, content string) error

在非完善版本中,粗暴的直接将传递的文件名(包括路径)和 file 实例关联,没有处理目录层级关系。因此,这里的实现的关键是要找到该文件(name 对应)的目录 dir 实例。和上面创建目录的思路类似,一步步处理。

// getDir 通过一个路径获取其 dir 类型实例
func (fsys *FS) getDir(path string) (*dir, error) {
 parts := strings.Split(path, "/")

 cur := fsys.rootDir
 for _, part := range parts {
  child := cur.children[part]
  if child == nil {
   return nil, fmt.Errorf("%s is not exists", path)
  }

  childDir, ok := child.(*dir)
  if !ok {
   return nil, fmt.Errorf("%s is not directory", path)
  }

  cur = childDir
 }

 return cur, nil
}

得到了文件应该放置的目录(dir)后,就可以构建一个 file 实例,并将该实例放置到其目录的目录项中。

filename := filepath.Base(name)

dir.children[filename] = &file{
  name:    filename,
  content: bytes.NewBufferString(content),
  modTime: time.Now(),
}

完整的实现代码

以下是 dir 类型的实现,代表一个目录,注意注释。

// dir 代表一个目录
type dir struct {
 name    string
 modTime time.Time

 // 存放该目录下的子项,value 可能是 *dir 或 *file
 children map[string]fs.DirEntry

 // ReadDir 遍历用
 idx int
}

// dir 虽然是一个目录,但根据一切皆文件的思想,目录也是文件,因此需要实现 fs.File 接口
// 这样,fs.FS 的 Open 方法可以对目录起作用。

func (d *dir) Read(p []byte) (int, error) {
 return 0, &fs.PathError{
  Op:   "read",
  Path: d.name,
  Err:  errors.New("is directory"),
 }
}

func (d *dir) Stat() (fs.FileInfo, error) {
 return d, nil
}

func (d *dir) Close() error {
 return nil
}

// ReadDir 实现 fs.ReadDirFile 接口,方便遍历目录
func (d *dir) ReadDir(n int) ([]fs.DirEntry, error) {
 names := make([]string, 0, len(d.children))
 for name := range d.children {
  names = append(names, name)
 }

 totalEntry := len(names)
 if n <= 0 {
  n = totalEntry
 }

 dirEntries := make([]fs.DirEntry, 0, n)
 for i := d.idx; i < n && i < totalEntry; i++ {
  name := names[i]
  child := d.children[name]

  f, isFile := child.(*file)
  if isFile {
   dirEntries = append(dirEntries, f)
  } else {
   dirEntry := child.(*dir)
   dirEntries = append(dirEntries, dirEntry)
  }

  d.idx = i
 }

 return dirEntries, nil
}

// 因为 fs.Stat 对目录也是有效的,因此 dir 需要实现 fs.FileInfo 接口

func (d *dir) Name() string {
 return d.name
}

func (d *dir) Size() int64 {
 return 0
}

func (d *dir) Mode() fs.FileMode {
 return fs.ModeDir | 0444
}

func (d *dir) ModTime() time.Time {
 return d.modTime
}

func (d *dir) IsDir() bool {
 return true
}

func (d *dir) Sys() interface{} {
 return nil
}

// 因为 dir 是一个目录项,因此需要实现 fs.DirEntry 接口

func (d *dir) Type() fs.FileMode {
 return d.Mode()
}

func (d *dir) Info() (fs.FileInfo, error) {
 return d, nil
}

接着是 file 的实现,代表一个文件,注意注释。

// file 代表一个文件
type file struct {
 name    string
 // 存放文件内容
 content *bytes.Buffer
 modTime time.Time
 closed  bool
}

// 实现 fs.File 接口

func (f *file) Read(p []byte) (int, error) {
 if f.closed {
  return 0, errors.New("file closed")
 }

 return f.content.Read(p)
}

func (f *file) Stat() (fs.FileInfo, error) {
 if f.closed {
  return nil, errors.New("file closed")
 }

 return f, nil
}

// Close 关闭文件,可以调用多次。
func (f *file) Close() error {
 f.closed = true
 return nil
}

// 实现 fs.FileInfo 接口

func (f *file) Name() string {
 return f.name
}

func (f *file) Size() int64 {
 return int64(f.content.Len())
}

func (f *file) Mode() fs.FileMode {
 // 固定为 0444
 return 0444
}

func (f *file) ModTime() time.Time {
 return f.modTime
}

func (f *file) IsDir() bool {
 return false
}

func (f *file) Sys() interface{} {
 return nil
}

// 文件也是某个目录下的目录项,因此需要实现 fs.DirEntry 接口

func (f *file) Type() fs.FileMode {
 return f.Mode()
}

func (f *file) Info() (fs.FileInfo, error) {
 return f, nil
}

有了目录(dir)和文件(file),看 fs.FS 的实现。

// FS 是 fs.FS 的内存文件系统实现
type FS struct {
 rootDir *dir
}

// NewFS 创建一个内存文件系统的实例
func NewFS() *FS {
 return &FS{
  rootDir: &dir{
   children: make(map[string]fs.DirEntry),
  },
 }
}

// Open 实现 fs.FS 的 Open 方法
func (fsys *FS) Open(name string) (fs.File, error) {
 // 1、校验 name
 if !fs.ValidPath(name) {
  return nil, &fs.PathError{
   Op:   "open",
   Path: name,
   Err:  fs.ErrInvalid,
  }
 }

 // 2、根目录处理
 if name == "." || name == "" {
  // 重置目录的遍历
  fsys.rootDir.idx = 0
  return fsys.rootDir, nil
 }

 // 3、根据 name 在目录树中进行查找
 cur := fsys.rootDir
 parts := strings.Split(name, "/")
 for i, part := range parts {
  // 不存在返回错误
  child := cur.children[part]
  if child == nil {
   return nil, &fs.PathError{
    Op:   "open",
    Path: name,
    Err:  fs.ErrNotExist,
   }
  }

  // 是否是文件
  f, ok := child.(*file)
  if ok {
   // 文件名是最后一项
   if i == len(parts)-1 {
    return f, nil
   }

   return nil, &fs.PathError{
    Op:   "open",
    Path: name,
    Err:  fs.ErrNotExist,
   }
  }

  // 是否是目录
  d, ok := child.(*dir)
  if !ok {
   return nil, &fs.PathError{
    Op:   "open",
    Path: name,
    Err:  errors.New("not a directory"),
   }
  }
  // 重置,避免遍历问题
  d.idx = 0

  cur = d
 }

 return cur, nil
}

// MkdirAll 这不是 io/fs 的要求,但一个文件系统目录树需要可以构建
// 这个方法就是用来创建目录
func (fsys *FS) MkdirAll(path string) error {
 if !fs.ValidPath(path) {
  return errors.New("Invalid path")
 }

 if path == "." {
  return nil
 }

 cur := fsys.rootDir
 parts := strings.Split(path, "/")
 for _, part := range parts {
  child := cur.children[part]
  if child == nil {
   childDir := &dir{
    name:     part,
    modTime:  time.Now(),
    children: make(map[string]fs.DirEntry),
   }
   cur.children[part] = childDir
   cur = childDir
  } else {
   childDir, ok := child.(*dir)
   if !ok {
    return fmt.Errorf("%s is not directory", part)
   }

   cur = childDir
  }
 }

 return nil
}

// WriteFile 也不是 io/fs 的要求,和 MkdirAll 类似,文件内容也需要有接口写入
func (fsys *FS) WriteFile(name, content string) error {
 if !fs.ValidPath(name) {
  return &fs.PathError{
   Op:   "write",
   Path: name,
   Err:  fs.ErrInvalid,
  }
 }

 var err error
 dir := fsys.rootDir

 path := filepath.Dir(name)
 if path != "." {
  dir, err = fsys.getDir(path)
  if err != nil {
   return err
  }
 }
 filename := filepath.Base(name)

 dir.children[filename] = &file{
  name:    filename,
  content: bytes.NewBufferString(content),
  modTime: time.Now(),
 }

 return nil
}

// getDir 通过一个路径获取其 dir 类型实例
func (fsys *FS) getDir(path string) (*dir, error) {
 parts := strings.Split(path, "/")

 cur := fsys.rootDir
 for _, part := range parts {
  child := cur.children[part]
  if child == nil {
   return nil, fmt.Errorf("%s is not exists", path)
  }

  childDir, ok := child.(*dir)
  if !ok {
   return nil, fmt.Errorf("%s is not directory", path)
  }

  cur = childDir
 }

 return cur, nil
}

为了完整性,我把所有代码列出来了,关键的地方加上了注释。

验证正确性并学习 fs.WalkDir

用心的读者可能会发现,io/fs 包还有一个类型和函数没有介绍,那就是 fs.WalkDir 函数和 WalkDirFunc 类型。它们是遍历目录用的。这里通过验证上面内存文件系统的正确性来学习它们。

首先,我们使用 MkdirAll 和 WriteFile 创建如下的目录树:

├── a
│   ├── b
│   │   └── z
├── x
│   └── y
│   │   └── z
│   └── name.txt

即执行如下代码:

memFS := memfs.NewFS()
memFS.MkdirAll("a/b/z")
memFS.MkdirAll("x/y/z")
memFS.WriteFile("x/name.txt", "This is polarisxu, welcome.")

基于这个目录树,我们该怎么遍历它?

自己实现遍历方法

遍历目录树也是一个面试常考的基础题目。熟悉的朋友应该知道,这需要用到递归。基于上面的内存文件系统 API,我们实现遍历目录树。

通过 io/fs 包的 ReadDir 函数读取目录下所有目录项,然后遍历这些目录项,如果某个目录项是目录,递归处理它。

func walk(fsys fs.FS, parent, base string) error {
 dirEntries, err := fs.ReadDir(fsys, filepath.Join(parent, base))
 if err != nil {
  return err
 }
 for _, dirEntry := range dirEntries {
  name := dirEntry.Name()
    fmt.Println(name)

  if dirEntry.IsDir() {
   err = walk(fsys, filepath.Join(parent, base), name)
  }
 }
 return err
}

然后用根目录调用它:

walk(memFS, "", ".")

使用 fs.WalkDir 实现

其实标准库为我们实现了这样的功能,通过它提供的方法可以很容易的实现遍历。

fs.WalkDir(memFS, ".", func(path string, d fs.DirEntry, err error) error {
 fmt.Pritnln(path)
 return nil
})

是不是不要太简单?

关于 fs.WalkDir 和 fs.WalkDirFunc 有一大段文字说明,介绍其中的一些细节。比如在回调函数中,如果返回 fs.SkipDir,则会停止该目录的遍历。这里细说了。

小结

io/fs 包基本上是在 os 包的基础上抽象出来的。之所以抽象,是因为 Go1.16 的 embed 功能,它需要文件系统,但又不同于 os 的文件系统。所以做了这个抽象。

基于 io/fs 包的接口,标准库不少地方做了改动,以支持 fs.FS 接口。此外还有第三方实现了它的文件系统:

  • https://github.com/jhchabran/gistfs:用于读取 GitHub gists 的文件;
  • https://github.com/benbjohnson/hashfs:hash 文件,以便 HTTP Cache;
  • https://github.com/psanford/memfs:内存文件系统的实现;本小节文件系统的实现参考了它。

相关推荐

快递查询教程,批量查询物流,一键管理快递

作为商家,每天需要查询许许多多的快递单号,面对不同的快递公司,有没有简单一点的物流查询方法呢?小编的回答当然是有的,下面随小编一起来试试这个新技巧。需要哪些工具?安装一个快递批量查询高手快递单号怎么快...

一键自动查询所有快递的物流信息 支持圆通、韵达等多家快递

对于各位商家来说拥有一个好的快递软件,能够有效的提高自己的工作效率,在管理快递单号的时候都需要对单号进行表格整理,那怎么样能够快速的查询所有单号信息,并自动生成表格呢?1、其实方法很简单,我们不需要一...

快递查询单号查询,怎么查物流到哪了

输入单号怎么查快递到哪里去了呢?今天小编给大家分享一个新的技巧,它支持多家快递,一次能查询多个单号物流,还可对查询到的物流进行分析、筛选以及导出,下面一起来试试。需要哪些工具?安装一个快递批量查询高手...

3分钟查询物流,教你一键批量查询全部物流信息

很多朋友在问,如何在短时间内把单号的物流信息查询出来,查询完成后筛选已签收件、筛选未签收件,今天小编就分享一款物流查询神器,感兴趣的朋友接着往下看。第一步,运行【快递批量查询高手】在主界面中点击【添...

快递单号查询,一次性查询全部物流信息

现在各种快递的查询方式,各有各的好,各有各的劣,总的来说,还是有比较方便的。今天小编就给大家分享一个新的技巧,支持多家快递,一次能查询多个单号的物流,还能对查询到的物流进行分析、筛选以及导出,下面一起...

快递查询工具,批量查询多个快递快递单号的物流状态、签收时间

最近有朋友在问,怎么快速查询单号的物流信息呢?除了官网,还有没有更简单的方法呢?小编的回答当然是有的,下面一起来看看。需要哪些工具?安装一个快递批量查询高手多个京东的快递单号怎么快速查询?进入快递批量...

快递查询软件,自动识别查询快递单号查询方法

当你拥有多个快递单号的时候,该如何快速查询物流信息?比如单号没有快递公司时,又该如何自动识别再去查询呢?不知道如何操作的宝贝们,下面随小编一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号若干...

教你怎样查询快递查询单号并保存物流信息

商家发货,快递揽收后,一般会直接手动复制到官网上一个个查询物流,那么久而久之,就会觉得查询变得特别繁琐,今天小编给大家分享一个新的技巧,下面一起来试试。教程之前,我们来预览一下用快递批量查询高手...

简单几步骤查询所有快递物流信息

在高峰期订单量大的时候,可能需要一双手当十双手去查询快递物流,但是由于逐一去查询,效率极低,追踪困难。那么今天小编给大家分享一个新的技巧,一次能查询多个快递单号的物流,下面一起来学习一下,希望能给大家...

物流单号查询,如何查询快递信息,按最后更新时间搜索需要的单号

最近有很多朋友在问,如何通过快递单号查询物流信息,并按最后更新时间搜索出需要的单号呢?下面随小编一起来试试吧。需要哪些工具?安装一个快递批量查询高手快递单号若干怎么快速查询?运行【快递批量查询高手】...

连续保存新单号功能解析,导入单号查询并自动识别批量查快递信息

快递查询已经成为我们日常生活中不可或缺的一部分。然而,面对海量的快递单号,如何高效、准确地查询每一个快递的物流信息,成为了许多人头疼的问题。幸运的是,随着科技的进步,一款名为“快递批量查询高手”的软件...

快递查询教程,快递单号查询,筛选更新量为1的单号

最近有很多朋友在问,怎么快速查询快递单号的物流,并筛选出更新量为1的单号呢?今天小编给大家分享一个新方法,一起来试试吧。需要哪些工具?安装一个快递批量查询高手多个快递单号怎么快速查询?运行【快递批量查...

掌握批量查询快递动态的技巧,一键查找无信息记录的两种方法解析

在快节奏的商业环境中,高效的物流查询是确保业务顺畅运行的关键。作为快递查询达人,我深知时间的宝贵,因此,今天我将向大家介绍一款强大的工具——快递批量查询高手软件。这款软件能够帮助你批量查询快递动态,一...

从复杂到简单的单号查询,一键清除单号中的符号并批量查快递信息

在繁忙的商务与日常生活中,快递查询已成为不可或缺的一环。然而,面对海量的单号,逐一查询不仅耗时费力,还容易出错。现在,有了快递批量查询高手软件,一切变得简单明了。只需一键,即可搞定单号查询,一键处理单...

物流单号查询,在哪里查询快递

如果在快递单号多的情况,你还在一个个复制粘贴到官网上手动查询,是一件非常麻烦的事情。于是乎今天小编给大家分享一个新的技巧,下面一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号怎么快速查询?...

取消回复欢迎 发表评论: