读写整个文件
os.WriteFile 和 os.ReadFile 能满足大部分需求。它们一次调用就完成打开、写入或读取,以及关闭。
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "demo")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir) // clean up everything at the end
path := filepath.Join(dir, "greeting.txt")
if err := os.WriteFile(path, []byte("hello, file\nsecond line\n"), 0o644); err != nil {
fmt.Println("write:", err)
return
}
data, err := os.ReadFile(path)
if err != nil {
fmt.Println("read:", err)
return
}
fmt.Print(string(data))
fmt.Println("bytes:", len(data))
}
本页的示例都在 os.MkdirTemp 创建的临时目录里操作,并用 defer os.RemoveAll(dir) 删除它,所以不会留下任何东西。在你自己的代码中,像 "config.json" 这样的相对路径是相对于进程的工作目录解析的,而工作目录不一定是源文件或二进制文件所在的目录。
os.WriteFile 会在需要时创建文件,如果文件已存在则会清空它。第三个参数是新建文件的 Unix 权限:0o644 表示所有者可读可写,其他人只读。对已经存在的文件,这个参数会被忽略,而且进程的 umask 可能会去掉某些权限位。
os.ReadFile 会把所有内容读进内存。这对配置文件和小规模输入很合适,对几 GB 的日志就不合适了。
用 bufio.Scanner 逐行读取
对于大文件,或者你本来就想按行处理时,用 bufio.Scanner。它分块读取,每次交给你一行,不含换行符。
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
f, err := os.CreateTemp("", "log-*.txt")
if err != nil {
fmt.Println(err)
return
}
defer os.Remove(f.Name())
fmt.Fprintln(f, "INFO started")
fmt.Fprintln(f, "ERROR disk full")
fmt.Fprintln(f, "INFO retrying")
fmt.Fprintln(f, "ERROR disk still full")
f.Close()
in, err := os.Open(f.Name())
if err != nil {
fmt.Println(err)
return
}
defer in.Close()
sc := bufio.NewScanner(in)
lineNo, errorsSeen := 0, 0
for sc.Scan() {
lineNo++
line := sc.Text()
if strings.HasPrefix(line, "ERROR") {
errorsSeen++
fmt.Printf("line %d: %s\n", lineNo, line)
}
}
if err := sc.Err(); err != nil { // Scan returns false on errors too
fmt.Println("scan:", err)
return
}
fmt.Println("errors:", errorsSeen, "of", lineNo, "lines")
}
三个细节:
- 循环结束后检查
sc.Err()。 Scan 在文件结束和出错时都会返回 false,只有 Err 能区分这两种情况。
- 64 KB 的行长限制。 默认情况下,单行超过 64 KB 会让 scanner 以
bufio.Scanner: token too long 停止。对于有长行的文件(压缩过的 JSON、某些日志),在循环之前提高上限:sc.Buffer(make([]byte, 1024*1024), 10*1024*1024)。
- 其他切分单位。
sc.Split(bufio.ScanWords) 按单词切分;bufio.ScanRunes 按字符切分。
如果要按固定大小的块而不是按行读取数据流,可以在循环中使用 f.Read(buf),或者用 io.Copy 复制到另一个 writer。
写入:os.Create、os.OpenFile 与追加
os.Create(name) 以写入方式打开文件,文件不存在就创建,存在就清空。os.OpenFile 则通过标志位提供完全的控制:
| 标志 | 含义 |
|---|
os.O_RDONLY、os.O_WRONLY、os.O_RDWR | 以只读、只写或读写方式打开(三选一) |
os.O_CREATE | 文件不存在时创建它 |
os.O_TRUNC | 打开时清空文件 |
os.O_APPEND | 每次写入都写到末尾 |
os.O_EXCL | 与 O_CREATE 一起使用:文件已存在则失败 |
os.Open(name) 等同于 OpenFile(name, O_RDONLY, 0)。os.Create(name) 等同于 OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0o666)。
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
)
func appendLine(path, line string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
if _, err := fmt.Fprintln(f, line); err != nil {
f.Close()
return err
}
return f.Close() // Close can report a failed write: return its error
}
func main() {
dir, err := os.MkdirTemp("", "append")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "audit.log")
for _, msg := range []string{"login alice", "upload report.pdf", "logout alice"} {
if err := appendLine(path, msg); err != nil {
fmt.Println("append:", err)
return
}
}
// Buffered writing: many small writes, one syscall per buffer.
out, err := os.Create(filepath.Join(dir, "numbers.txt"))
if err != nil {
fmt.Println(err)
return
}
w := bufio.NewWriter(out)
for i := 1; i <= 1000; i++ {
fmt.Fprintln(w, i)
}
if err := w.Flush(); err != nil { // without Flush the tail of the data is lost
fmt.Println("flush:", err)
}
if err := out.Close(); err != nil {
fmt.Println("close:", err)
}
data, _ := os.ReadFile(path)
fmt.Print(string(data))
info, _ := os.Stat(filepath.Join(dir, "numbers.txt"))
fmt.Println("numbers.txt size:", info.Size(), "bytes")
}
写入时,Close 返回的错误很重要。有些文件系统要到关闭时才报告写入失败,所以只写 defer f.Close() 可能会掩盖数据丢失。对于你写入的文件,要像 appendLine 那样显式检查 Close。对于只读取的文件,defer f.Close() 就足够了。
fmt.Fprintln 以及其他 fmt 打印函数 都接受任意 io.Writer,文件也不例外。bufio.Writer 会先在内存中攒下零碎的小写入。忘记调用 w.Flush() 是一个经典 bug:程序正常退出,但最后几 KB 永远没有写进文件。
文件是否存在?
Go 没有 os.Exists。调用 os.Stat 并检查错误:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
)
func exists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, err // permission denied, broken mount, ...: unknown
}
func main() {
dir, err := os.MkdirTemp("", "exists")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir)
fmt.Println(exists(dir))
fmt.Println(exists(filepath.Join(dir, "nope.txt")))
_, err = os.ReadFile(filepath.Join(dir, "nope.txt"))
fmt.Println("not exist:", errors.Is(err, fs.ErrNotExist))
fmt.Println(err)
}
errors.Is(err, fs.ErrNotExist) 是现在的惯用写法。它取代了旧的 os.IsNotExist(err),后者看不穿被包装的错误。
在打开之前先检查往往没有必要,还会产生竞态:在检查和打开之间,文件可能出现也可能消失。通常直接打开它,再处理打开时返回的 fs.ErrNotExist 即可。如果只想在文件尚不存在时创建它,用 O_CREATE|O_EXCL,它把检查和创建合并成一个原子步骤。
目录
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
)
func main() {
root, err := os.MkdirTemp("", "tree")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(root)
// MkdirAll creates every missing parent, like mkdir -p.
if err := os.MkdirAll(filepath.Join(root, "src", "util"), 0o755); err != nil {
fmt.Println(err)
return
}
for _, name := range []string{"main.go", "src/app.go", "src/util/strings.go", "README.md"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil {
fmt.Println(err)
return
}
}
// One level: ReadDir returns entries sorted by name.
entries, err := os.ReadDir(root)
if err != nil {
fmt.Println(err)
return
}
for _, e := range entries {
fmt.Printf("%-10s dir=%v\n", e.Name(), e.IsDir())
}
// Recursive: WalkDir visits every file and directory in lexical order.
fmt.Println("Go files:")
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && filepath.Ext(path) == ".go" {
rel, _ := filepath.Rel(root, path)
fmt.Println(" ", filepath.ToSlash(rel))
}
return nil
})
if err != nil {
fmt.Println(err)
}
// Glob matches one directory level against a pattern.
matches, _ := filepath.Glob(filepath.Join(root, "*.md"))
fmt.Println("markdown files:", len(matches))
}
| 任务 | 函数 |
|---|
| 创建一个目录 | os.Mkdir(path, 0o755) |
| 连同父目录一起创建路径 | os.MkdirAll(path, 0o755) |
| 列出目录内容 | os.ReadDir(path) |
| 遍历目录树 | filepath.WalkDir(root, fn) |
| 删除文件或空目录 | os.Remove(path) |
| 删除整个目录树 | os.RemoveAll(path) |
| 重命名或移动 | os.Rename(old, new) |
| 临时文件或目录 | os.CreateTemp("", "prefix-*")、os.MkdirTemp("", "prefix") |
| 拼接路径 | filepath.Join(a, b, c) |
文件系统路径请用 path/filepath:它会使用操作系统对应的分隔符(Windows 上是 \)。path 包用于 URL 这类以斜杠分隔的路径。
Go 1.24 还新增了 os.Root(os.OpenRoot(dir)),它只允许打开某一个目录内的文件,并拒绝通过 .. 或符号链接逃出该目录的路径。文件名来自用户时就用它。
常见错误
- 不检查错误。 这些调用每一个都可能失败。打开失败的文件是
nil,之后对它的每次 Read、Write 或 Close 都会返回 invalid argument,从而掩盖真正的原因(文件不存在,或者权限被拒绝)。
- 扫描循环结束后忘了检查
sc.Err()。 读取错误看起来就像文件结束。
- 忘了对
bufio.Writer 调用 Flush。 文件末尾会缺一截。
- 写入后忽略
Close 返回的错误。 写入错误可能只会在这里暴露出来。
- 在遍历大量文件的循环里写
defer f.Close()。 文件要等到函数返回才会关闭,可能会耗尽文件描述符。把循环体移到一个函数里,让每个文件在每次迭代中关闭。
- 把权限写成十进制。
644 不是 0o644。Go 把 644 当作十进制数,也就是 0o1204,会设置一些奇怪的权限位。