很多代码会直接 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.DirFS 或 embed.FS,测试可以用 fstest.MapFS。这会让配置、模板、静态资源发现等代码更容易测试。
使用时记住 FS 路径用 /,构造路径用 path.Join,不要把用户输入不经校验地当文件名。抽象能提升可测试性,但安全边界仍然要自己守住。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。