
文件
前面我们已经学习了 Go 的变量、常量、数据类型、输入输出、条件控制、切片、字符串、映射表、指针、结构体、函数、方法、接口、类型和错误。接下来学习 Go 程序如何操作文件。
文件操作看起来只是"打开、读取、写入、关闭",但实际开发还需要考虑:
- 文件路径如何拼接;
- 文件是否存在;
- 文件应该以什么模式打开;
- 是否覆盖原内容;
- 是否追加到文件末尾;
- 是否需要缓冲;
- 是否需要逐行读取;
- 如何创建目录;
- 如何读取文件信息;
- 如何遍历目录;
- 如何安全地处理用户传入的路径;
- 写入失败时如何避免留下半截文件;
- 如何把文件操作抽象成可测试的数据结构。
Go 官方的 os 包提供了面向操作系统文件的接口,io 包提供了 Reader 和 Writer 等通用抽象,io/fs 包进一步把"文件系统"抽象成可以替换的接口。
本文的核心判断是:
- 小文件可以使用 os.ReadFile 和 os.WriteFile;
- 需要控制打开方式时使用 os.OpenFile;
- 打开文件后应尽快安排 Close;
- 大文件应使用流式读取,不要一次性加载到内存;
- 文本按行处理时可以使用 bufio.Scanner,但要知道它的 token 大小限制;
- 路径拼接使用 filepath.Join,不要手写分隔符;
- 多层目录使用 os.MkdirAll;
- 重要文件写入时可以使用临时文件加 Rename 实现原子替换;
- 业务代码依赖 io.Reader、io.Writer 或 fs.FS 后,更容易测试和替换真实文件系统。
本文按照"文件概念 → 快速读写 → 文件句柄 → OpenFile → 流式读取 → 缓冲读写 → 目录和元数据 → 路径处理 → 临时文件 → 原子写入 → io/fs → 错误处理 → 实现行文件数据结构"的顺序展开。
本文代码在 go1.27.0 darwin/arm64 环境中实际编译运行。临时目录名称、文件权限显示和系统路径可能因环境不同而变化。
Go 中的文件是什么
在 Go 中,文件通常通过三个概念来理解:
- 路径:文件在文件系统中的名字,例如 notes.txt;
- 文件句柄:打开文件后得到的 *os.File;
- 文件内容:通过 \[\]byte、字符串或 Reader/Writer 流传输的数据。
路径只是一个字符串:
path := "notes.txt"
打开路径后,得到一个文件对象:
file, err := os.Open(path)
*os.File 同时提供了许多能力:
- Read:读取字节;
- Write:写入字节;
- Seek:移动读写位置;
- Stat:读取文件信息;
- Close:关闭文件;
- Sync:把缓冲数据同步到存储设备;
- ReadDir:读取目录内容。
os 包的设计是跨平台的。文件名、目录、权限和底层错误由操作系统决定,但 Go 使用统一的 error 返回失败原因。
最简单的文件读写
对于内容不大的文件,可以直接使用:
os.ReadFile
os.WriteFile
下面写入一个文件,再把它读取出来:
package main
import (
"fmt"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-file-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/message.txt"
err = os.WriteFile(path, []byte("hello, file\n"), 0o644)
if err != nil {
panic(err)
}
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
fmt.Printf("%q\n", data)
}
运行结果:
"hello, file\n"
os.WriteFile 的三个参数分别是:
os.WriteFile(name string, data []byte, perm fs.FileMode) error
- name:文件路径;
- data:要写入的全部内容;
- perm:创建文件时使用的权限;
- 返回值:写入过程中的错误。
os.ReadFile 返回整个文件内容:
data, err := os.ReadFile(path)
它适合配置文件、小型 JSON 文件、模板和测试数据。文件很大时,应该改用流式读取。
os.WriteFile 是否会覆盖原文件
如果目标文件已经存在,os.WriteFile 会打开并截断它,然后写入新内容:
err := os.WriteFile("message.txt", []byte("new content"), 0o644)
原来的内容会被替换。
如果希望保留原内容并追加,应该使用 os.OpenFile 和 os.O_APPEND:
file, err := os.OpenFile(
"message.txt",
os.O_WRONLY|os.O_CREATE|os.O_APPEND,
0o644,
)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString("append line\n")
return err
打开文件并关闭文件
os.Open 以只读方式打开文件:
package main
import (
"fmt"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-open-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/data.txt"
if err := os.WriteFile(path, []byte("go\n"), 0o644); err != nil {
panic(err)
}
file, err := os.Open(path)
if err != nil {
fmt.Println("open failed:", err)
return
}
defer file.Close()
data, err := os.ReadFile(file.Name())
if err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Printf("name: %s\n", file.Name())
fmt.Printf("content: %q\n", data)
}
运行结果:
name: /var/folders/.../data.txt
content: "go\n"
打开成功后,应该尽快安排关闭:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
这样即使后续读取失败,函数返回前也会关闭文件。
为什么必须关闭文件
打开文件会消耗操作系统资源。文件关闭后:
- 文件描述符可以被再次使用;
- 写入缓冲有机会被提交;
- 文件锁和系统状态可以释放;
- 长时间运行的程序不会不断耗尽文件描述符。
如果在循环中反复打开文件,不能只依赖一个很晚才执行的 defer:
for _, path := range paths {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
}
这种写法会等外层函数结束时才关闭所有文件。更好的做法是把一次处理封装到独立函数中,或者在循环内显式关闭。
func processOne(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
return processFile(file)
}
os.OpenFile 和打开标志
os.OpenFile 可以精确控制打开方式:
file, err := os.OpenFile(name, flag, perm)
常用标志包括:
| 标志 | 作用 |
|---|---|
| os.O_RDONLY | 只读 |
| os.O_WRONLY | 只写 |
| os.O_RDWR | 读写 |
| os.O_CREATE | 文件不存在时创建 |
| os.O_TRUNC | 打开时截断原内容 |
| os.O_APPEND | 写入文件末尾 |
| os.O_EXCL | 配合 O_CREATE,文件已存在时失败 |
| os.O_SYNC | 写操作尽量同步到底层存储 |
读取已有文件:
file, err := os.OpenFile(path, os.O_RDONLY, 0)
创建并覆盖:
file, err := os.OpenFile(
path,
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
0o644,
)
创建并追加:
file, err := os.OpenFile(
path,
os.O_WRONLY|os.O_CREATE|os.O_APPEND,
0o644,
)
只允许创建新文件:
file, err := os.OpenFile(
path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0o600,
)
如果 path 已经存在,O_EXCL 会让打开失败。这个模式适合创建不能被覆盖的锁文件、一次性输出文件或临时结果。
打开标志的组合
O_RDONLY、O_WRONLY 和 O_RDWR 是访问模式,三者必须选择一个。其他标志可以使用按位或组合:
flag := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
如果忘记 O_TRUNC:
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644)
写入较短的新内容时,旧文件末尾可能保留下来。
例如原来内容是:
abcdef
重新写入:
xy
如果没有截断,文件可能变成:
xycdef
需要覆盖整个文件时,应该使用 O_TRUNC,或者使用 os.WriteFile。
文件权限和 FileMode
创建文件时可以指定权限:
err := os.WriteFile(path, data, 0o640)
常见的八进制权限:
- 0o600:所有者可读写;
- 0o644:所有者可读写,其他用户只读;
- 0o700:所有者可读写执行;
- 0o755:所有者可读写执行,其他用户可读执行。
权限还会受到操作系统 umask 影响。传入的权限是创建时的请求值,最终权限可能更严格。
os.Stat 可以获取文件信息:
info, err := os.Stat(path)
if err != nil {
return err
}
fmt.Println(info.Name())
fmt.Println(info.Size())
fmt.Println(info.Mode())
fmt.Println(info.ModTime())
fmt.Println(info.IsDir())
FileInfo 提供:
- Name:基本文件名;
- Size:字节数;
- Mode:文件模式和权限;
- ModTime:修改时间;
- IsDir:是否为目录;
- Sys:操作系统相关的底层信息。
读取文件内容
使用 File.Read
File.Read 把数据读取到调用者提供的字节切片中:
package main
import (
"fmt"
"io"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-read-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/data.txt"
if err := os.WriteFile(path, []byte("abcdef"), 0o644); err != nil {
panic(err)
}
file, err := os.Open(path)
if err != nil {
panic(err)
}
defer file.Close()
buffer := make([]byte, 3)
for {
n, err := file.Read(buffer)
if n > 0 {
fmt.Printf("%q\n", buffer[:n])
}
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
}
}
运行结果:
"abc"
"def"
Read 返回两个值:
- n:本次实际读取的字节数;
- err:读取错误,读到结尾时通常是 io.EOF。
必须先处理 n,再处理 err。一次 Read 可能同时返回 n 大于零和 io.EOF。
使用 io.ReadAll
如果需要读取完整内容,可以把文件作为 io.Reader 传给 io.ReadAll:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return err
}
os.ReadFile 内部也适合完成这种"小文件全部读入内存"的任务。
io.ReadAll 会不断读取,直到 Reader 返回错误。成功读完时,错误通常是 nil,而不是把 io.EOF 返回给调用方。
使用 io.ReadFull
如果必须读取固定数量的字节,可以使用 io.ReadFull:
buffer := make([]byte, 8)
n, err := io.ReadFull(file, buffer)
if err != nil {
fmt.Println("read bytes:", n, "error:", err)
return err
}
如果文件不足 8 个字节,io.ReadFull 会返回 io.ErrUnexpectedEOF。
这种方式适合读取固定长度的文件头、二进制协议和定长记录。
写入文件内容
使用 File.Write
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
n, err := file.Write([]byte("hello"))
if err != nil {
return err
}
if n != len("hello") {
return io.ErrShortWrite
}
File.Write 返回实际写入的字节数。普通文件通常会一次写完,但实现 io.Writer 的类型可能产生短写,因此通用代码应该注意 n。
使用 File.WriteString
写字符串时可以直接使用 WriteString:
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString("first line\n")
if err != nil {
return err
}
_, err = file.WriteString("second line\n")
return err
File.WriteString 避免显式创建 \[\]byte,适合写文本内容。
使用 io.Copy
如果数据已经来自另一个 Reader,可以使用 io.Copy:
source, err := os.Open(inputPath)
if err != nil {
return err
}
defer source.Close()
target, err := os.Create(outputPath)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, source)
return err
io.Copy 会从 source 读取并写入 target。它不需要把整个文件加载到内存中。
读取位置和 Seek
*os.File 同时实现 io.Reader、io.ReaderAt、io.Seeker 和 io.Writer 等接口。
Seek 可以改变当前读写位置:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
position, err := file.Seek(0, io.SeekEnd)
if err != nil {
return err
}
fmt.Println("file size:", position)
常用的 whence:
- io.SeekStart:从文件开头计算;
- io.SeekCurrent:从当前位置计算;
- io.SeekEnd:从文件末尾计算。
回到开头:
_, err = file.Seek(0, io.SeekStart)
读取文件末尾:
_, err = file.Seek(-4, io.SeekEnd)
Seek 的偏移量是字节,不是字符。对于 UTF-8 文本,跳到字符中间可能得到无效的字节序列。
使用 ReadAt 读取固定位置
ReadAt 不改变文件当前的 Seek 位置:
buffer := make([]byte, 4)
n, err := file.ReadAt(buffer, 10)
if err != nil && err != io.EOF {
return err
}
fmt.Printf("read %d bytes: %q\n", n, buffer[:n])
ReadAt 适合随机访问固定位置的数据。它仍然可能返回 n 大于零和 io.EOF,因此也要先处理 n。
使用 defer 正确关闭文件
一个标准的文件读取函数通常是:
func readFile(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
return data, nil
}
defer 应该紧跟在成功打开之后。这样代码的生命周期很清楚:
- 打开失败,直接返回;
- 打开成功,安排关闭;
- 继续读取;
- 函数返回时关闭文件。
如果 Close 失败对业务很重要,例如写入事务文件,就应该把 Close 错误纳入返回值。
func writeFile(path string, data []byte) (err error) {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
if closeErr := file.Close(); err == nil && closeErr != nil {
err = fmt.Errorf("close %s: %w", path, closeErr)
}
}()
if _, err = file.Write(data); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
对于读文件,Close 的失败通常不如打开和读取失败重要;对于写文件,Close 可能暴露缓冲提交失败,因此要根据场景决定是否检查。
bufio 缓冲读写
直接调用 File.Read 和 File.Write 可以工作,但频繁的小读写会增加系统调用次数。bufio 包提供带缓冲的 Reader 和 Writer。
bufio.Reader 逐行读取
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
func main() {
dir, err := os.MkdirTemp("", "go-buffer-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/lines.txt"
content := "first\nsecond\nthird\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
panic(err)
}
file, err := os.Open(path)
if err != nil {
panic(err)
}
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
fmt.Println(strings.TrimSuffix(line, "\n"))
}
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
}
}
运行结果:
first
second
third
ReadString 会一直读取,直到遇到指定分隔符。即使最后一行没有换行符,也应该先处理返回的字符串,再处理错误。
上面的代码需要导入 io:
import "io"
bufio.Scanner 逐行读取
Scanner 更适合简单的逐行处理:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan %s: %w", path, err)
}
Scanner 默认按行切分,并且 scanner.Text 返回当前行内容,不包含换行符。
Scanner 的正确结构是:
- 循环调用 Scan;
- 在循环中处理 Text;
- 循环结束后检查 scanner.Err。
不要只写:
for scanner.Scan() {
fmt.Println(scanner.Text())
}
如果底层读取失败,这段代码会直接结束循环,却丢失错误原因。
Scanner 的 token 大小限制
Scanner 默认的 token 最大长度有限。读取超长行时,可能出现:
bufio.Scanner: token too long
可以使用 Buffer 调整最大 token:
scanner := bufio.NewScanner(file)
buffer := make([]byte, 64*1024)
scanner.Buffer(buffer, 1024*1024)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
这里把最大行长度设置为 1 MiB。
如果文件中可能存在非常长的行,或者需要精细控制内存,应该考虑 bufio.Reader.ReadString、ReadBytes 或自定义读取策略。
Scanner 使用自定义分隔规则
Scanner 不只能按行读取,也可以按单词、逗号或自定义格式切分:
scanner := bufio.NewScanner(strings.NewReader("go,rust,java"))
scanner.Split(bufio.ScanWords)
ScanWords 默认按空白切分,逗号不会自动作为分隔符。自定义 SplitFunc 可以处理自己的文本协议。
对于标准 CSV 文件,应当优先使用 encoding/csv,而不是自己拆分逗号,因为字段可能包含引号和逗号。
bufio.Writer 批量写入
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-writer-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/output.txt"
file, err := os.Create(path)
if err != nil {
panic(err)
}
defer file.Close()
writer := bufio.NewWriter(file)
for index := 1; index <= 3; index++ {
fmt.Fprintf(writer, "line %d\n", index)
}
if err := writer.Flush(); err != nil {
panic(err)
}
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
fmt.Print(string(data))
}
运行结果:
line 1
line 2
line 3
bufio.Writer 的内容先写入内存缓冲区。只有 Flush 后,数据才会继续写入底层文件。
因此:
writer.Flush()
不是可有可无的步骤。忘记 Flush 可能导致文件内容不完整。
如果希望把 Flush 错误返回:
if err := writer.Flush(); err != nil {
return fmt.Errorf("flush file: %w", err)
}
Reader 和 Writer 的选择
| 场景 | 推荐方式 |
|---|---|
| 小文件全部读取 | os.ReadFile |
| 小文件一次写完 | os.WriteFile |
| 大文件顺序读取 | os.Open + io.Copy 或 io.ReadAll 分段 |
| 逐行读取 | bufio.Scanner 或 bufio.Reader |
| 频繁写小片段 | bufio.Writer |
| 两个文件之间复制 | io.Copy |
| 固定位置读取 | ReadAt |
| 需要移动位置 | Seek |
目录操作
文件和目录都属于文件系统对象,但目录需要额外的遍历和创建操作。
创建目录
创建一级目录:
err := os.Mkdir("logs", 0o755)
如果父目录不存在,Mkdir 会失败。创建多层目录使用 MkdirAll:
err := os.MkdirAll("data/cache/images", 0o755)
MkdirAll 会创建缺少的父目录。如果目标目录已经存在,通常返回 nil。
完整示例:
package main
import (
"fmt"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-dir-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/data/cache/images"
if err := os.MkdirAll(path, 0o755); err != nil {
panic(err)
}
info, err := os.Stat(path)
if err != nil {
panic(err)
}
fmt.Println(info.IsDir())
fmt.Println(info.Mode().Perm())
}
运行结果:
true
493
493 是十进制表示的 0o755。权限输出也可以使用八进制格式:
fmt.Printf("%#o\n", info.Mode().Perm())
读取目录内容
os.ReadDir 读取目录并返回 DirEntry 列表:
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fmt.Println(entry.Name(), entry.IsDir())
}
os.ReadDir 返回的条目按文件名排序。需要逐步读取大量目录内容时,可以使用打开后的 File.ReadDir。
file, err := os.Open(dir)
if err != nil {
return err
}
defer file.Close()
entries, err := file.ReadDir(-1)
if err != nil {
return err
}
File.ReadDir 的参数 n:
- n 大于零时,最多返回 n 个条目;
- n 小于等于零时,返回全部剩余条目。
读取目录元数据
DirEntry 提供更适合遍历目录的接口:
for _, entry := range entries {
fmt.Println("name:", entry.Name())
fmt.Println("dir:", entry.IsDir())
info, err := entry.Info()
if err != nil {
return err
}
fmt.Println("size:", info.Size())
}
如果只需要名字和是否为目录,使用 DirEntry 不必立即读取完整 FileInfo。
删除文件和目录
删除一个文件或空目录:
err := os.Remove(path)
删除目录树:
err := os.RemoveAll(path)
RemoveAll 即使路径不存在,通常也不会返回错误。它具有递归删除能力,处理用户输入路径时必须非常谨慎。
遍历目录树
filepath.WalkDir 可以递归遍历目录:
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "go-walk-example-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
panic(err)
}
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a"), 0o644); err != nil {
panic(err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b"), 0o644); err != nil {
panic(err)
}
err = filepath.WalkDir(dir, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relative, err := filepath.Rel(dir, path)
if err != nil {
return err
}
fmt.Println(relative)
return nil
})
if err != nil {
panic(err)
}
}
运行结果:
.
a.txt
sub
sub/b.txt
回调函数有三个参数:
- path:当前路径;
- entry:当前目录项;
- walkErr:访问当前路径时发生的错误。
必须先判断 walkErr。若目录没有访问权限,entry 可能是不完整的。
如果只遍历普通文件:
if entry.Type().IsRegular() {
fmt.Println(path)
}
如果希望跳过当前目录:
return fs.SkipDir
当回调处理的是目录时返回 fs.SkipDir,可以跳过该目录的子树。
filepath 处理路径
文件路径不能简单地使用字符串加斜杠拼接。不同系统的路径分隔符可能不同,应当使用 path/filepath:
path := filepath.Join("data", "logs", "app.log")
filepath.Join 会根据当前操作系统选择分隔符,并清理多余的分隔部分。
常用函数:
filepath.Base("/var/log/app.log") // app.log
filepath.Dir("/var/log/app.log") // /var/log
filepath.Ext("app.log") // .log
filepath.Clean("a/../b") // b
Join 和字符串拼接的区别
不推荐:
path := dir + "/" + filename
推荐:
path := filepath.Join(dir, filename)
如果 filename 以分隔符开头,Join 的行为仍然由 filepath 处理,但业务代码还应该验证用户提供的文件名是否允许跳出目标目录。
获取绝对路径
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
fmt.Println(absolute)
Abs 会基于当前工作目录生成绝对路径。它不会保证目标路径一定存在。
当前工作目录可以通过 os.Getwd 获取:
workingDir, err := os.Getwd()
if err != nil {
return err
}
修改当前进程工作目录:
err := os.Chdir(dir)
通常不建议在库代码中随意修改工作目录,因为它会影响同一进程中的其他代码和 goroutine。
计算相对路径
relative, err := filepath.Rel(root, target)
if err != nil {
return err
}
fmt.Println(relative)
安全检查时可以判断相对路径是否跳出了根目录:
relative, err := filepath.Rel(root, target)
if err != nil {
return err
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return errors.New("path escapes root")
}
只检查字符串前缀是不够的,路径应该先 Clean,必要时还要解析符号链接。对于不可信路径,不能只依靠简单字符串判断。
os.OpenInRoot
较新的 Go 版本提供了 os.OpenInRoot,可以把打开操作限制在指定根目录下:
root, err := os.OpenRoot("/srv/uploads")
if err != nil {
return err
}
defer root.Close()
file, err := root.Open("user/avatar.png")
if err != nil {
return err
}
defer file.Close()
Root 的方法使用相对于根目录的名字,调用者不需要先拼接绝对路径。它适合实现文件服务、解压目录和用户上传目录等受限访问场景。
使用这类 API 时仍然要定义自己的文件名规则,不应当允许业务层任意读取系统文件。
文件错误和 PathError
os 包中的文件错误通常带有路径、操作和底层错误。例如打开不存在的文件:
file, err := os.Open("missing.txt")
if err != nil {
fmt.Println(err)
}
错误内容可能类似:
open missing.txt: no such file or directory
底层错误类型通常是 *os.PathError,它包含:
- Op:执行的操作;
- Path:相关路径;
- Err:底层错误。
可以用 errors.Is 判断常见错误:
file, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file does not exist")
}
return err
}
defer file.Close()
不要通过比较错误字符串来判断文件不存在。
使用 errors.As 提取 PathError
file, err := os.Open(path)
if err != nil {
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("operation:", pathErr.Op)
fmt.Println("path:", pathErr.Path)
fmt.Println("cause:", pathErr.Err)
}
return err
}
defer file.Close()
errors.Is 适合判断语义,errors.As 适合读取结构化信息。
判断目录是否存在
info, err := os.Stat(path)
switch {
case err == nil:
fmt.Println("exists:", info.IsDir())
case errors.Is(err, os.ErrNotExist):
fmt.Println("does not exist")
default:
fmt.Println("stat failed:", err)
}
os.Stat 会跟随符号链接。如果需要读取链接本身的信息,可以使用 os.Lstat。
临时文件和临时目录
临时文件应该使用 os.CreateTemp:
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.CreateTemp("", "go-file-*.txt")
if err != nil {
panic(err)
}
defer os.Remove(file.Name())
defer file.Close()
fmt.Println(file.Name())
if _, err := file.WriteString("temporary content"); err != nil {
panic(err)
}
}
运行结果中的文件名类似:
/var/folders/.../go-file-123456.txt
CreateTemp 会创建并打开一个具有唯一名字的文件。返回的文件默认使用较严格的权限,适合保存短期中间数据。
创建临时目录:
dir, err := os.MkdirTemp("", "go-work-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
临时目录内的文件可以在测试、导入、压缩和转换过程中使用。
临时文件的清理
临时文件至少要考虑三种退出路径:
- 正常完成;
- 中途返回错误;
- 程序 panic。
defer 可以覆盖正常返回和 panic 的清理:
file, err := os.CreateTemp("", "go-temp-*")
if err != nil {
return err
}
name := file.Name()
defer func() {
file.Close()
os.Remove(name)
}()
如果希望保留失败现场用于排查,可以只在成功时删除,失败时把路径写入日志。
原子写入文件
直接使用 os.WriteFile 覆盖重要配置文件时,如果进程在写入中途退出,目标文件可能只剩下一半内容。
一种常见策略是:
- 在目标目录创建临时文件;
- 把完整内容写入临时文件;
- Flush 或 Sync;
- 关闭临时文件;
- 使用 Rename 替换目标文件;
- 失败时删除临时文件。
Rename 通常在同一个文件系统内是原子的。下面实现一个简化的原子写入函数:
package main
import (
"fmt"
"os"
"path/filepath"
)
func AtomicWriteFile(name string, data []byte, perm os.FileMode) (err error) {
dir := filepath.Dir(name)
base := filepath.Base(name)
temp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("create temporary file: %w", err)
}
tempName := temp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tempName)
}
}()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return fmt.Errorf("chmod temporary file: %w", err)
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("write temporary file: %w", err)
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("sync temporary file: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary file: %w", err)
}
if err := os.Rename(tempName, name); err != nil {
return fmt.Errorf("rename temporary file: %w", err)
}
cleanup = false
return nil
}
func main() {
dir, err := os.MkdirTemp("", "go-atomic-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "config.txt")
if err := AtomicWriteFile(path, []byte("complete content\n"), 0o640); err != nil {
panic(err)
}
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
fmt.Printf("%q\n", data)
}
运行结果:
"complete content\n"
这个实现有几个边界:
- 临时文件和目标文件必须位于同一文件系统,Rename 才能直接替换;
- Sync 提高了数据持久化的可靠性,但具体保证仍取决于操作系统和存储设备;
- 替换目录项后,如果需要更严格的崩溃一致性,还要考虑同步父目录;
- Windows、Unix 和网络文件系统对 Rename 的细节可能不同;
- 原子替换解决的是"读者看到半截文件"的问题,不等于解决并发写入协调。
对于普通日志或不重要的缓存,os.WriteFile 可能已经足够。原子写入应当用于配置、索引、状态快照等不能接受半截内容的文件。
io.Reader 和 io.Writer 抽象
文件操作不应该总是绑定到 *os.File。Go 标准库大量函数依赖接口:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
因此同一个函数可以同时处理文件、内存缓冲、网络连接和压缩流:
func copyContent(dst io.Writer, src io.Reader) error {
_, err := io.Copy(dst, src)
return err
}
调用:
source, _ := os.Open("input.txt")
defer source.Close()
target, _ := os.Create("output.txt")
defer target.Close()
err := copyContent(target, source)
测试时可以换成 strings.Reader 和 bytes.Buffer:
var buffer bytes.Buffer
source := strings.NewReader("hello")
err := copyContent(&buffer, source)
fmt.Println(buffer.String(), err)
这样测试不需要创建真实文件。
io/fs 文件系统抽象
io/fs 包把文件系统抽象为 fs.FS:
type FS interface {
Open(name string) (File, error)
}
fs.FS 使用斜杠分隔的相对路径,并且要求路径符合 fs.ValidPath 规则。常见的实现有:
- os.DirFS;
- embed.FS;
- testing/fstest.MapFS;
- zip 文件系统;
- 自定义内存文件系统。
使用 os.DirFS
package main
import (
"fmt"
"io/fs"
"os"
)
func main() {
dir, err := os.MkdirTemp("", "go-fs-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
if err := os.WriteFile(dir+"/hello.txt", []byte("hello fs"), 0o644); err != nil {
panic(err)
}
files := os.DirFS(dir)
data, err := fs.ReadFile(files, "hello.txt")
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
运行结果:
hello fs
os.DirFS 把一个操作系统目录暴露为 fs.FS。之后的代码只需要依赖 fs.FS,不需要知道根目录的绝对路径。
fs.ValidPath
fmt.Println(fs.ValidPath("hello.txt"))
fmt.Println(fs.ValidPath("a/b.txt"))
fmt.Println(fs.ValidPath("../secret.txt"))
fmt.Println(fs.ValidPath("/tmp/file.txt"))
运行结果:
true
true
false
false
fs.ValidPath 要求:
- 路径使用斜杠;
- 不能是绝对路径;
- 不能包含 . 或 .. 路径元素;
- 空字符串只表示根目录。
使用 fstest.MapFS 测试文件系统
testing/fstest.MapFS 可以在内存中构造文件:
import (
"fmt"
"testing/fstest"
)
func exampleMapFS() {
files := fstest.MapFS{
"config/app.txt": &fstest.MapFile{
Data: []byte("memory file"),
},
}
data, err := files.ReadFile("config/app.txt")
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
运行结果:
memory file
这使得依赖 fs.FS 的函数可以在测试中使用内存数据,而不用创建临时目录。
实现一个可复用的行文件数据结构
前面学习了文件的底层 API。现在实现一个更接近业务的结构:LineFile。
它保存:
- 文件路径;
- 按行拆分后的内容;
- 加载、追加、删除和原子保存方法。
定义结构体:
type LineFile struct {
Path string
Lines []string
}
加载文件
func (file *LineFile) Load() error {
input, err := os.Open(file.Path)
if err != nil {
return fmt.Errorf("open line file: %w", err)
}
defer input.Close()
file.Lines = file.Lines[:0]
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
file.Lines = append(file.Lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan line file: %w", err)
}
return nil
}
这里使用指针接收者,因为 Load 会修改 Lines。
添加、查找和删除
func (file *LineFile) Append(line string) {
file.Lines = append(file.Lines, line)
}
func (file *LineFile) Find(text string) []int {
var indexes []int
for index, line := range file.Lines {
if strings.Contains(line, text) {
indexes = append(indexes, index)
}
}
return indexes
}
func (file *LineFile) RemoveAt(index int) error {
if index < 0 || index >= len(file.Lines) {
return fmt.Errorf("line index %d out of range", index)
}
copy(file.Lines[index:], file.Lines[index+1:])
file.Lines = file.Lines[:len(file.Lines)-1]
return nil
}
RemoveAt 使用切片的 copy 把后面的元素向前移动,再缩短切片长度。
如果元素包含指针或大型对象,可以在缩短之前清理最后一个元素,避免底层数组暂时保留引用。这里的 string 是值类型,因此直接缩短即可。
保存文件
func (file *LineFile) Save() error {
content := strings.Join(file.Lines, "\n")
if len(file.Lines) > 0 {
content += "\n"
}
return AtomicWriteFile(file.Path, []byte(content), 0o644)
}
保存时统一使用换行符。LineFile 内部不保存换行符,因此加载和保存的职责很清楚。
完整的 LineFile 实现
package main
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
type LineFile struct {
Path string
Lines []string
}
func (file *LineFile) Load() error {
input, err := os.Open(file.Path)
if err != nil {
return fmt.Errorf("open line file: %w", err)
}
defer input.Close()
file.Lines = file.Lines[:0]
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
file.Lines = append(file.Lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan line file: %w", err)
}
return nil
}
func (file *LineFile) Append(line string) {
file.Lines = append(file.Lines, line)
}
func (file *LineFile) Find(text string) []int {
var indexes []int
for index, line := range file.Lines {
if strings.Contains(line, text) {
indexes = append(indexes, index)
}
}
return indexes
}
func (file *LineFile) RemoveAt(index int) error {
if index < 0 || index >= len(file.Lines) {
return fmt.Errorf("line index %d out of range", index)
}
copy(file.Lines[index:], file.Lines[index+1:])
file.Lines = file.Lines[:len(file.Lines)-1]
return nil
}
func (file *LineFile) Save() error {
content := strings.Join(file.Lines, "\n")
if len(file.Lines) > 0 {
content += "\n"
}
return atomicWriteFile(file.Path, []byte(content), 0o644)
}
func atomicWriteFile(name string, data []byte, perm os.FileMode) (err error) {
dir := filepath.Dir(name)
base := filepath.Base(name)
temp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("create temporary file: %w", err)
}
tempName := temp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tempName)
}
}()
if err := temp.Chmod(perm); err != nil {
_ = temp.Close()
return fmt.Errorf("chmod temporary file: %w", err)
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
return fmt.Errorf("write temporary file: %w", err)
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return fmt.Errorf("sync temporary file: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("close temporary file: %w", err)
}
if err := os.Rename(tempName, name); err != nil {
return fmt.Errorf("rename temporary file: %w", err)
}
cleanup = false
return nil
}
func main() {
dir, err := os.MkdirTemp("", "go-line-file-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(path, []byte("learn go\nwrite code\n"), 0o644); err != nil {
panic(err)
}
document := LineFile{Path: path}
if err := document.Load(); err != nil {
panic(err)
}
document.Append("test file")
fmt.Println("matches:", document.Find("go"))
if err := document.RemoveAt(1); err != nil {
panic(err)
}
if err := document.Save(); err != nil {
panic(err)
}
data, err := os.ReadFile(path)
if err != nil {
panic(err)
}
fmt.Printf("%q\n", data)
var missing LineFile
missing.Path = filepath.Join(dir, "missing.txt")
if err := missing.Load(); err != nil {
fmt.Println("not found:", errors.Is(err, os.ErrNotExist))
}
}
运行结果:
matches: [0]
"learn go\ntest file\n"
not found: true
LineFile 把"文件字节流"提升成了"可以操作的行集合":
- Load 负责从文件恢复内存状态;
- Append、Find、RemoveAt 负责业务操作;
- Save 负责把内存状态持久化;
- atomicWriteFile 避免直接覆盖时产生半截文件;
- errors.Is 让调用方仍然可以识别文件不存在。
使用 JSON 文件保存结构体
文件不一定只保存文本。encoding/json 可以把结构体编码成 JSON,再通过文件 API 保存。
package main
import (
"encoding/json"
"fmt"
"os"
)
type Config struct {
Host string
Port int
Debug bool
}
func main() {
dir, err := os.MkdirTemp("", "go-json-file-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := dir + "/config.json"
config := Config{
Host: "localhost",
Port: 8080,
Debug: true,
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o640); err != nil {
panic(err)
}
raw, err := os.ReadFile(path)
if err != nil {
panic(err)
}
var loaded Config
if err := json.Unmarshal(raw, &loaded); err != nil {
panic(err)
}
fmt.Printf("%+v\n", loaded)
}
运行结果:
{Host:localhost Port:8080 Debug:true}
文件持久化通常分成两个方向:
- 编码:结构体到字节;
- 解码:字节到结构体。
文件 API 只负责字节的存取,JSON、CSV、Gob 等包负责具体的数据格式。
泛型 JSON 文件存储
可以把 JSON 文件封装成泛型结构:
type JSONFile[T any] struct {
Path string
}
func (file JSONFile[T]) Load() (T, error) {
var value T
data, err := os.ReadFile(file.Path)
if err != nil {
return value, fmt.Errorf("read json file: %w", err)
}
if err := json.Unmarshal(data, &value); err != nil {
return value, fmt.Errorf("decode json file: %w", err)
}
return value, nil
}
func (file JSONFile[T]) Save(value T) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("encode json file: %w", err)
}
data = append(data, '\n')
return os.WriteFile(file.Path, data, 0o640)
}
调用方式:
configFile := JSONFile[Config]{Path: "config.json"}
if err := configFile.Save(config); err != nil {
return err
}
loaded, err := configFile.Load()
如果文件的重要性较高,可以把 Save 中的 os.WriteFile 替换成原子写入函数。
文件并发访问
os.File 的方法可以被多个 goroutine 并发调用,但这不代表业务上的写入天然安全。
例如两个 goroutine 同时追加多段文本:
go func() {
file.WriteString("worker A\n")
}()
go func() {
file.WriteString("worker B\n")
}()
操作可能不会让进程崩溃,但两段业务消息的顺序和组合仍然需要由程序保证。
可以用互斥锁包装文件写入:
type LockedWriter struct {
mu sync.Mutex
file *os.File
}
func (writer *LockedWriter) WriteLine(line string) error {
writer.mu.Lock()
defer writer.mu.Unlock()
_, err := writer.file.WriteString(line + "\n")
return err
}
如果多个进程同时写同一个文件,进程内的 mutex 不够。此时需要使用操作系统文件锁、单独的写入进程、数据库或其他协调机制。Go 标准库没有提供一个跨平台、通用的文件锁 API。
文件同步和持久化
Write 成功通常表示数据已经被操作系统接受,并不等价于数据已经稳定写入物理介质。
对于需要更强持久性的文件,可以调用:
if err := file.Sync(); err != nil {
return fmt.Errorf("sync file: %w", err)
}
但 Sync 会增加成本,也受到文件系统和存储设备实现影响。日志、缓存和普通临时数据通常不需要每次写入都 Sync。
原子写入和持久化是两个不同问题:
- 原子写入关注读者是否看到半截内容;
- Sync 关注写入内容在崩溃后是否尽可能保留;
- 目录同步关注 Rename 后的目录项是否持久化;
- 并发协调关注多个写入者的顺序和冲突。
根据数据的重要程度选择方案,不要把所有文件都按最严格的方式处理。
常见错误
只打开不关闭
不推荐:
file, err := os.Open(path)
if err != nil {
return err
}
data, err := io.ReadAll(file)
return err
推荐:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return err
}
_ = data
return nil
用字符串拼接路径
不推荐:
path := root + "/" + name
推荐:
path := filepath.Join(root, name)
把大文件一次性读入内存
不推荐:
data, err := os.ReadFile(largePath)
推荐使用流式复制:
source, err := os.Open(largePath)
if err != nil {
return err
}
defer source.Close()
target, err := os.Create(outputPath)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, source)
return err
忽略 Scanner.Err
不推荐:
for scanner.Scan() {
fmt.Println(scanner.Text())
}
推荐:
for scanner.Scan() {
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
忘记 Flush
不推荐:
writer := bufio.NewWriter(file)
writer.WriteString("data")
推荐:
writer := bufio.NewWriter(file)
if _, err := writer.WriteString("data"); err != nil {
return err
}
if err := writer.Flush(); err != nil {
return err
}
把权限写成十进制
可读性较差:
os.WriteFile(path, data, 420)
推荐使用八进制字面量:
os.WriteFile(path, data, 0o644)
用 os.RemoveAll 处理不可信路径
RemoveAll 具有递归删除能力。用户传入的路径必须经过严格校验,不能直接交给 RemoveAll。
把 Rename 当成跨文件系统复制
Rename 通常只能在同一个文件系统内直接完成。如果临时目录和目标目录不在同一个文件系统,应该改用复制后删除,或者把临时文件创建在目标目录中。
文件操作速查表
| 需求 | 推荐 API |
|---|---|
| 读取小文件 | os.ReadFile |
| 覆盖写入小文件 | os.WriteFile |
| 精确控制打开方式 | os.OpenFile |
| 只读打开 | os.Open |
| 创建并覆盖 | os.Create |
| 追加写入 | O_APPEND |
| 流式复制 | io.Copy |
| 逐行读取 | bufio.Scanner |
| 控制行读取细节 | bufio.Reader |
| 缓冲写入 | bufio.Writer |
| 读取文件信息 | os.Stat |
| 创建多层目录 | os.MkdirAll |
| 读取目录 | os.ReadDir |
| 遍历目录树 | filepath.WalkDir |
| 拼接路径 | filepath.Join |
| 创建临时文件 | os.CreateTemp |
| 原子替换 | 临时文件 + os.Rename |
| 抽象文件系统 | io/fs |
| 判断文件不存在 | errors.Is(err, os.ErrNotExist) |
| 提取路径错误 | errors.As(err, *os.PathError) |
总结
本文学习了 Go 的文件操作:
- 路径描述文件位置,*os.File 表示打开后的文件句柄;
- os.ReadFile 和 os.WriteFile 适合小文件;
- os.OpenFile 可以组合 O_CREATE、O_TRUNC、O_APPEND 等标志;
- 打开成功后应立即 defer Close;
- File.Read 需要先处理 n,再处理 err;
- io.ReadAll 适合把 Reader 全部读取到内存;
- io.Copy 适合在两个 Reader 和 Writer 之间流式传输;
- bufio.Scanner 适合逐行读取,但要检查 Err 和 token 限制;
- bufio.Writer 使用后必须 Flush;
- os.MkdirAll 可以创建多层目录;
- os.ReadDir 和 filepath.WalkDir 用于遍历目录;
- filepath.Join 比字符串拼接更适合跨平台路径;
- errors.Is 可以判断 os.ErrNotExist;
- errors.As 可以提取 *os.PathError;
- os.CreateTemp 适合创建安全的临时文件;
- 临时文件加 Rename 可以实现原子写入;
- io.Reader、io.Writer 和 fs.FS 让文件代码更容易测试;
- LineFile 和 JSONFile 可以把底层文件操作封装成业务数据结构;
- 文件并发写入需要额外的同步策略;
- Write 成功、Sync 成功和数据真正持久化是不同层次的保证。
实际选择文件 API 时,可以先问自己:
- 文件是否足够小,可以一次性加载?
- 是否需要追加、覆盖还是独占创建?
- 是否需要逐行处理?
- 是否可能出现超长行?
- 文件是否需要原子替换?
- 目录是否来自不可信输入?
- 代码是否应该支持内存文件系统测试?
- 写入失败时是否允许目标文件处于半截状态?
小文件使用 ReadFile 和 WriteFile,大文件采用流式处理;需要业务语义时封装结构体;需要测试替换时依赖 Reader、Writer 或 fs.FS;需要保证重要状态时使用临时文件和原子替换。