Goのファイルシステム（io/fs）を実装してみる

## はじめに

Goの`io/fs`パッケージはファイルシステムを抽象化してプログラム内で扱えるようにするためのインターフェイスを提供するもので、Go 1.16から利用可能になりました。

今回はこのパッケージの理解のために、あらかじめ用意した固定内容を返却する仮想的なファイルシステムを実装してみます。

## ゴール

下記の内容を読み取ることができるように`fs.FS`を実装します。

| ファイルパス | 内容 |
|:-|:-|
| `foo` | （ディレクトリ） |
| `foo/bar` | `hello` |
| `foo/baz`  | `world` |
| `foo/qux` | （ディレクトリ） |
| `foo/qux/quux` | `hello world` |

また、実装したファイルシステムが期待通りに動作していることを確認するために`http.FileServer`を利用してブラウザ上でファイルの内容を確認できるようにします。

## 実装すべきインターフェイス

下記はGoのファイルシステムを実装する上で必須となるものです。

* [`fs.FS`](https://pkg.go.dev/io/fs#FS)
    * これを実装することが最終的なゴールとなります。
* [`fs.File`](https://pkg.go.dev/io/fs#File)
    * `fs.FS`を実装する上で必要となります。
    * ファイルの内容を担当します。
* [`fs.FileInfo`](https://pkg.go.dev/io/fs#FileInfo)
    * `fs.File`を実装する上で必要となります。
    * ファイルのメタデータを担当します。

下記は実装するファイルシステムの内容（ディレクトリを扱わない等）によっては実装が不要となる場合もありますが、今回はディレクトリを扱うため実装する必要があります。

* [`fs.ReadDirFile`](https://pkg.go.dev/io/fs#ReadDirFile)
    * `fs.File`を拡張するインターフェイスです
* [`fs.DirEntry`](https://pkg.go.dev/io/fs#DirEntry)
    * `fs.ReadDirFile`を実装する上で必要となります。

## 実装

### `fs.FileInfo`および`fs.DirEntry`の実装

Goのファイルシステムを実装するにあたり一番末端に位置するインターフェイスです。
これらは実装すべき内容が非常に似通っているため、今回は同じ構造体に両者を実装するようにします。

また、今回は手軽に実装するため、ファイルの内容や種別（ディレクトリかどうか）をすべてここに持たせるようにします。

```go
type myFileInfo struct {
	path    string
	content []byte
	isDir   bool
}

var _ fs.FileInfo = (*myFileInfo)(nil)
// 以下は fs.FileInfo で必要な関数

func (f *myFileInfo) Name() string {
	return filepath.Base(f.path)
}

func (f *myFileInfo) Size() int64 {
	if f.IsDir() {
		return 0
	} else {
		return int64(len(f.content))
	}
}

func (f *myFileInfo) Mode() fs.FileMode {
	if f.IsDir() {
		return fs.ModeDir | 0755
	}
	return 0644
}

func (f *myFileInfo) ModTime() time.Time {
	return time.Now()
}

func (f *myFileInfo) IsDir() bool {
	return f.isDir
}

func (f *myFileInfo) Sys() any {
	return nil
}

var _ fs.DirEntry = (*myFileInfo)(nil)
// 以下は fs.DirEntry で必要な関数

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

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

ファイル名やサイズ、ディレクトリかどうか等を返却するようにします。

ディレクトリの場合は`IsDir()`で`true`を返すのみでなく、`Mode()`にて`fs.ModeDir`を立てておく必要があります（`Type()`の返却値に影響します）。

ここで`Name()`はファイルパスではなく末端のファイル名を返却する必要があります。例えばファイルパスが`foo/qux/quux`である場合は返却値は`quux`となります。

### `fs.File`および`fs.ReadDirFile`の実装

`fs.File`はファイルの内容の読み出しを、`fs.ReadDirFile`はディレクトリ内容の読み出しを担当します。

今回は`myFileInfo`にファイル内容をすべて持たせているため、自ファイルおよび自ディレクトリ配下の`myFileInfo`を持つようにします。

```go
type myFile struct {
	fileInfo *myFileInfo
	children []*myFileInfo
	offset   int64
}

var _ fs.File = (*myFile)(nil)
// 以下は fs.File で必要な関数

func (f *myFile) Stat() (fs.FileInfo, error) {
	return f.fileInfo, nil
}

func (f *myFile) Read(b []byte) (int, error) {
	if f.fileInfo.IsDir() {
		return 0, &fs.PathError{Op: "read", Path: f.fileInfo.path, Err: fs.ErrInvalid}
	}
	if f.offset >= int64(len(f.fileInfo.content)) {
		return 0, io.EOF
	}
	n := copy(b, f.fileInfo.content[f.offset:])
	f.offset += int64(n)
	return n, nil
}
func (f *myFile) Close() error {
	return nil
}

var _ fs.ReadDirFile = (*myFile)(nil)
// 以下は fs.ReadDirFile で必要な関数

func (f *myFile) ReadDir(count int) ([]fs.DirEntry, error) {
	n := int64(len(f.children)) - f.offset
	if n == 0 {
		if count <= 0 {
			return nil, nil
		}
		return nil, io.EOF
	}
	if count > 0 && n > int64(count) {
		n = int64(count)
	}
	list := make([]fs.DirEntry, n)
	for i := range list {
		list[i] = f.children[f.offset+int64(i)]
	}
	f.offset += n
	return list, nil
}

var _ io.Seeker = (*myFile)(nil)
// 以下は io.Seeker で必要な関数

func (f *myFile) Seek(offset int64, whence int) (int64, error) {
	switch whence {
	case io.SeekStart:
		offset = 0
	case io.SeekCurrent:
		offset += f.offset
	case io.SeekEnd:
		offset += in
	}
	if offset < 0 || offset > int64(len(f.fileInfo.content)) {
		return 0, &fs.PathError{Op: "seek", Path: f.fileInfo.path, Err: fs.ErrInvalid}
	}
	f.offset = offset
	return offset, nil
}

```

`Read()`はファイル内容の読み込みを行います。
ディレクトリの場合は読み込みを行えないのでエラーを返却させます。ファイルの内容を引数として渡された`[]byte`に書き込み、その長さを返却します。書き込み先のキャパシティが足らず一度で読み込みを完了できない場合にはオフセットを保持して次回の呼び出し時に再開できるようにします。
ファイルの最後まで読み終えたら`io.EOF`を返却します。

`Stat()`では先ほど実装した`myFileInfo`を返却します。

`ReadDir()`はディレクトリに含まれるファイルを返却します。
引数として一度に取得する件数が渡されるので、一度に全件を返却できない場合はオフセットを保持するようにします。すべてのファイルを返却し終えたら`io.EOF`を返却します。

また、この例では`io.Seeker`を実装しています。これはGoのファイルシステムを実装する上では必須ではありませんが、`http.FileServer`を利用する上で必要となります。

### `fs.FS`の実装

ここまでで必要なものが揃ったので、`fs.FS`を実装します。今回の例ではファイルの情報を持った`myFileInfo`をすべてここに持たせ、必要に応じて`myFile`を生成するようにします。

```go
type myFS struct {
	files []*myFileInfo
}

var _ fs.FS = (*myFS)(nil)
// 以下は fs.FS で必要な関数

func (fsys myFS) Open(name string) (fs.File, error) {
	var found *myFileInfo
	var children []*myFileInfo = []*myFileInfo{}
	for _, fi := range fsys.files {
		if fi.path == name {
			found = fi
		} else if strings.HasPrefix(fi.path, name+"/") && !strings.ContainsRune(strings.TrimPrefix(fi.path, name+"/"), '/') {
			children = append(children, fi)
		}
	}
	if found == nil {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
	}
	return &myFile{found, children, 0}, nil
}
```

`Open()`に渡される引数はファイルパスです。
手持ちの情報からそのファイルパスが存在するものであるかどうかを確認し、ファイルとして存在するのではれば`fs.File`を、ディレクトリとして存在しているのであれば`fs.DirReadFile`を返却します。今回はどちらも`myFile`に実装しているため、区別なく返却しています。

これで、簡単なGoのファイルシステムの実装が完了しました。

## 実装したファイルシステムの利用

実装した`myFS`は下記のように利用できます。

```
func main() {
	fsys := &myFS{
		[]*myFileInfo{
			{"foo", nil, true},
			{"foo/bar", []byte("hello"), false},
			{"foo/baz", []byte("world"), false},
			{"foo/qux", nil, true},
			{"foo/qux/quux", []byte("hello world"), false},
		},
	}
	http.Handle("/", http.FileServer(http.FS(fsys)))
	log.Fatal(http.ListenAndServe(":8080", http.FileServer(http.FS(fsys))))
}
```

上記を実行し、Webブラウザで`http://localhost:8080/foo`にアクセスすると期待通りの動作をしていることを確認できます。

## まとめ

`fs.FS`の実装に必要なインターフェイスは下記のような依存の階層をしており、これらを実装することでGoのファイルシステムを作成できることを確認しました。

* `fs.FS`
    * `fs.File`
        * `fs.FileInfo`
    * `fs.ReadDirFile`
        * `fs.DirEntry`

標準ライブラリやその他のライブラリには`fs.FS`を受け付けるものが多くあるため（場合によっては追加のインターフェイスの実装を要求するものもありますが）、独自のツリー構造を持つアプリケーションではファイルシステムとして実装することでそれらの恩恵をうけることができそうです。