The upcoming Go 1.16 release has a lot of
exciting updates in it, but my most anticipated addition to the Go standard
library is the new io/fs
and testing/testfs
packages.
Go’s io.Reader
and io.Writer
interfaces, along with os.File
and its
analogs, go a long way in abstracting common operations on opened files.
However, until now there hasn’t been a great story for abstracting an entire
filesystem.
Why might you want to do this? Well, the most common motivating use-case I’ve encountered is being able to mock a filesystem in a test. As a contrived example:
// FileContainsGopher is my very neat, super useful function.
func FileContainsGopher(fs afero.Fs, path string) (bool, error) {
file, err := fs.Open(path)
if err != nil {
return false, err
}
contents, err := ioutil.ReadAll(file)
if err != nil {
return false, err
}
return strings.Contains(string(contents), "gopher")
}
// "Real" usage.
func main() {
res, err := FileContainsGopher(afero.NewOsFs(), os.Args[1])
if err != nil {
panic(err)
}
if res {
fmt.Printf("%q has a gopher!", os.Args[1])
} else {
fmt.Println("No such luck 🤷♂️")
}
}
// Test usage
// my_test.go
func FileContainsGopher(t *testing.T) {
fs := afero.NewMemMapFs()
afero.WriteFile(fs, "data.txt", []byte("friendly gopher"), os.ModePerm)
got, err := FileContainsGopher(fs, "data.txt")
if err == nil {
t.Fatalf("FileContainsGopher failed: %v", err)
}
if !got {
t.Errorf("FileContainsGopher want true, got false")
}
}
Abstracting the filesystem in tests can prevent tests from being disturbed by side effects, and provides a more reliable way to setup test data. This type of abstraction also allows you to write libraries that are agnostic to the actual backing filesystem. With an interface, no one knows you’re a cloud blob store.
The state of the art for filesystem abstraction (prior to Go 1.16) has been the
afero library, which contains an interface
type for filesystems and a number of common implementations that provide this
interface. For example,
afero.OsFs wraps the os
package and afero.MemMapFs
is an in-memory simulated filesystem that’s useful for testing. Since
afero.Fs is just an interface,
you can theoretically write any type of client that provides filesystem like
behavior (e.g. S3, zip archives, SSHFS, etc.), and use it transparently by
anything that acts on an afero.Fs
.
Now, in Go 1.16, there’s a new io/fs
package that provides a common filesystem
interface: fs.FS. At first glance, the
FS
interface is puzzlingly small:
type FS interface {
Open(name string) (File, error)
}
You can read this as “the most atomic type of filesystem is just an object that
can open a file at a path, and return a file object”. That’s rather bare
compared to the
afero.FS
interface, which requires 13 (!) functions at time of writing. However, the Go
library allows for more complex behavior by providing other filesystem
interfaces that can be composed on top of the base fs.FS
interface, such as
ReadDirFS, which allows you to
list the contents of a directory:
type ReadDirFS interface {
FS
ReadDir(name string) ([]DirEntry, error)
}
Along with ReadDirFS
, there’s also
StatFS and
SubFS. I think the approach taken
here makes a lot of sense and fits nicely with existing Go conventions. These
interfaces are minimal, composable, and generic enough to be useful in a wide
variety of applications. Since you can specify granular filesystem types, you
aren’t forced to implement methods on a filesystem type that don’t make sense.
For example, a key-value blob store without a hierarchical key structure could
implement Open
easily, but ReadDir
wouldn’t have a meaning in that context.
In the afero
“thick interface” approach, you’d either have to specify that
those methods remain unimplemented, or otherwise find an awkward workaround to
implement each of the required functions.
One downside, similar to the io
package, is that not all combinations of
interface types are covered, so you may need to sprinkle some helper interfaces
throughout library code. For example, if I want a fs.FS
that supports
ReadDir
and Stat
, I’d need to write my own interface like this:
type readDirStatFS interface {
fs.ReadDirFS
fs.StatFS
}
Alright, fair enough. Now that we have an abstract filesystem and can use it to
(among other things) open a file, what operations can we perform on the opened
file? The FS.Open
function returns the new fs.File
interface type, which
gives you access to some common file functions:
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
So, fs.File
is basically a “ReadStatCloser”. Compare that again to the
afero.File type, which is a
much “thicker” interface:
type File interface {
io.Closer
io.Reader
io.ReaderAt
io.Seeker
io.Writer
io.WriterAt
Name() string
Readdir(count int) ([]os.FileInfo, error)
Readdirnames(n int) ([]string, error)
Stat() (os.FileInfo, error)
Sync() error
Truncate(size int64) error
WriteString(s string) (ret int, err error)
}
Again, thinning out the interface for files means that more “types” of files can be represented.
On balance, I think the “thin interface” approach is better suited for the standard library, though I can see why a more opinionated library like Afero opted for having a larger set of mandatory filesystem operations.
However. There’s one big caveat that you’ll notice if you look at what’s
conspicuously absent from the fs.File
interface: any ability to write files.
The fs
package provides a read-only interface for filesystems. That’s a huge
bummer, and kinda makes me fear that fs.FS
won’t see a ton of adoption.
There’s certainly not a easy path for migrating away from afero
, if you do
anything other than read-only operations.1
Looking at the original filesystem interfaces proposal, there is some thought given to third-party extensions that introduce the ability to modify files, but this doesn’t seem to be a motivating aspect of the design. It seems that these interfaces were included in this Go 1.16 to support the new file embedding features.
If you’re really interested in this sort of thing, the proposal discussion on Github is a good read. One comment in particular stood out to me, indicating future support for read/write file-systems might require a type assertion. 😬 I’m generally a fan of encoding as much in the type system as possible, so… that… doesn’t feel great.
I’m confident that the Go team can find an ergonomic way to support modifying
files, if it’s something they want to invest in. Perhaps hiding most of those
type assertions behind top-level fs
package functions would help. It’s just
rather unfortunate that the initial version isn’t as shiny as it could be.
Incremental progress!
As a tangent, the filesystem interfaces proposal comments also include a surprising amount of discussion about adding contexts to filesystem operations which I Would Be Very Much In Favor Of. (Though, I’ll readily admit that it’s probably not a good idea, on balance.)
One last thing: the fstest package.
Unsurprisingly, there’s a memory-mapped fs.FS
type:
type MapFS map[string]*MapFile
This is conceptually very similar to afero.MemMapFs
. The fstest
package also
contains the MapFile
helper type and some additional functions to allow
MapFS
to implement fs.FS
.
There’s also a TestFS function, which provides a handy assertion that a set of files exists:
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.
I’m a little puzzled why this function in particular was added to the standard library, but I’m guessing it also has something to do with the new file embedding feature.2 Sure, why not?
So, to conclude: out-of-the-box with Go 1.16 you can use fs.FS
in place of
afero.Fs
for testing and in cases when you’re only performing read-only
operations. For write/modification operations, maybe we’ll see some movement
in future releases. While we’re waiting, have some fun and try to build a
writable filesystem on-top of fs.FS
? 🤷♂️ In any case, I’m looking forward to
the release of 1.16, which should happen in
February 2021.
Standard disclaimer that the above are my own opinions, and are not necessarily those of my employer.
Discussion on lobste.rs. Cover: Abstract III by Carl Newman
-
I suppose you could use
fs.FS
and then perform a type assertion on the returnedfs.File
interface but… 🙈 ↩︎ -
Update: Per rsc’s kind response,
fstest.TestFS
checks more things than I initially realized:It walks the entire file tree in the file system you give it, checking that all the various methods it can find are well-behaved and diagnosing a bunch of common mistakes that file system implementers might make. For example it opens every file it can find and checks that Read+Seek and ReadAt give consistent results. And lots more. So if you write your own FS implementation, one good test you should write is a test that constructs an instance of the new FS and then passes it to fstest.TestFS for inspection.
Neat! I initially thought that
fstest.TestFS
was intended to be used while using afs.FS
in tests (e.g. while using atestfs.MapFS
), but it looks like it’s also intended to test implementations offs.FS
itself. ↩︎