Go fs.FS 入门:让文件读取逻辑更容易测试

用配置文件读取示例讲 io/fs.FS 的基本用法,展示 os.DirFS、fstest.MapFS、embed.FS 和测试边界。

很多代码会直接 os.ReadFile("config.json")。这很简单,但测试时会依赖真实文件路径。Go 的 io/fs 提供了 fs.FS 接口,可以让函数从“文件系统”读取,而不关心这个文件系统来自磁盘、内存还是 embed。这样文件读取逻辑更容易测试。

本文用读取 JSON 配置做例子。

从 os.ReadFile 到 fs.ReadFile

普通写法:

func LoadConfig(path string) (Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return Config{}, err
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return Config{}, err
	}
	return cfg, nil
}

改成接收 fs.FS

func LoadConfigFS(fsys fs.FS, name string) (Config, error) {
	data, err := fs.ReadFile(fsys, name)
	if err != nil {
		return Config{}, err
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return Config{}, err
	}
	return cfg, nil
}

生产环境用磁盘:

cfg, err := LoadConfigFS(os.DirFS("/etc/myapp"), "config.json")

os.DirFS 把某个目录包装成 fs.FS

测试用 fstest.MapFS

func TestLoadConfigFS(t *testing.T) {
	fsys := fstest.MapFS{
		"config.json": {
			Data: []byte(`{"addr":":8080","debug":true}`),
		},
	}
	cfg, err := LoadConfigFS(fsys, "config.json")
	if err != nil {
		t.Fatal(err)
	}
	if cfg.Addr != ":8080" {
		t.Fatalf("addr = %q", cfg.Addr)
	}
}

测试不需要创建临时文件,也不依赖工作目录。fstest.MapFS 非常适合小型文件树。

和 embed.FS 配合

如果默认配置嵌入二进制:

//go:embed defaults/*.json
var defaults embed.FS

读取:

cfg, err := LoadConfigFS(defaults, "defaults/config.json")

同一个 LoadConfigFS 可以读取磁盘、内存和 embed 文件。函数依赖的是抽象能力,而不是具体路径。

路径是斜杠

fs.FS 使用斜杠路径,即使在 Windows 上也用 /。不要用 filepath.Join 构造 FS 内部路径,应该用 path.Join

name := path.Join("defaults", "config.json")

filepath 面向操作系统路径,path 面向斜杠路径。这个区别在跨平台代码里很重要。

目录遍历

func ListTemplates(fsys fs.FS) ([]string, error) {
	var names []string
	err := fs.WalkDir(fsys, "templates", func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && strings.HasSuffix(p, ".html") {
			names = append(names, p)
		}
		return nil
	})
	return names, err
}

测试里同样可以用 fstest.MapFS 构造目录树。这样模板发现逻辑不需要真实磁盘。

安全边界

os.DirFS 本身不会阻止路径逃逸的所有风险。如果你把用户输入直接作为文件名读取,仍然要校验。对于公开下载接口,不要直接让用户控制 FS 路径。fs.ValidPath 可以检查路径是否符合 FS 规则:

if !fs.ValidPath(name) {
	return errors.New("invalid path")
}

但业务上还要限制目录、扩展名和权限。抽象文件系统不是安全沙箱。

子目录文件系统

有时你只想把某个子目录交给函数,可以用 fs.Sub

sub, err := fs.Sub(fsys, "templates")
if err != nil {
	return err
}
names, err := ListTemplates(sub)

这样 ListTemplates 可以从 "." 开始遍历,不需要知道外层目录结构。对于 embed 资源很有用,因为嵌入路径常常带一层前缀。

//go:embed templates/*.html
var embedded embed.FS

func TemplateFS() (fs.FS, error) {
	return fs.Sub(embedded, "templates")
}

子文件系统能让 API 更干净,但错误处理不能省。路径写错时,应该在启动阶段暴露,而不是等用户请求才发现模板不存在。

测试目录遍历结果

fstest.MapFS 可以模拟目录:

fsys := fstest.MapFS{
	"templates/index.html": {Data: []byte("index")},
	"templates/admin.html": {Data: []byte("admin")},
	"README.md":            {Data: []byte("doc")},
}

测试时可以断言只返回 html 文件。因为 map 遍历顺序不稳定,结果最好排序后比较:

slices.Sort(names)

文件系统测试经常受顺序影响。只要输出来自 map 或目录遍历,就要考虑排序,让测试稳定。

让包 API 更小

引入 fs.FS 后,不一定要让整个业务层都知道文件系统。可以把读取逻辑封装在一个 loader 里:

type TemplateLoader struct {
	fsys fs.FS
}

func NewTemplateLoader(fsys fs.FS) *TemplateLoader {
	return &TemplateLoader{fsys: fsys}
}

func (l *TemplateLoader) Load(name string) (string, error) {
	data, err := fs.ReadFile(l.fsys, name)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

业务代码依赖 TemplateLoader,测试 loader 时用 fstest.MapFS。这样抽象边界更集中,不会让每个函数都多一个 fsys 参数。

错误信息要带文件名

读取失败时包装文件名:

data, err := fs.ReadFile(fsys, name)
if err != nil {
	return nil, fmt.Errorf("read %s: %w", name, err)
}

否则线上只看到 file does not exist,不知道是哪个模板或配置缺失。文件相关错误一定要带路径上下文。

小结

fs.FS 让文件读取逻辑脱离具体磁盘路径。生产可以用 os.DirFSembed.FS,测试可以用 fstest.MapFS。这会让配置、模板、静态资源发现等代码更容易测试。

使用时记住 FS 路径用 /,构造路径用 path.Join,不要把用户输入不经校验地当文件名。抽象能提升可测试性,但安全边界仍然要自己守住。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页