GO [ 文件 ]

文件

前面我们已经学习了 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 应该紧跟在成功打开之后。这样代码的生命周期很清楚:

  1. 打开失败,直接返回;
  2. 打开成功,安排关闭;
  3. 继续读取;
  4. 函数返回时关闭文件。

如果 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 的正确结构是:

  1. 循环调用 Scan;
  2. 在循环中处理 Text;
  3. 循环结束后检查 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 覆盖重要配置文件时,如果进程在写入中途退出,目标文件可能只剩下一半内容。

一种常见策略是:

  1. 在目标目录创建临时文件;
  2. 把完整内容写入临时文件;
  3. Flush 或 Sync;
  4. 关闭临时文件;
  5. 使用 Rename 替换目标文件;
  6. 失败时删除临时文件。

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 的文件操作:

  1. 路径描述文件位置,*os.File 表示打开后的文件句柄;
  2. os.ReadFile 和 os.WriteFile 适合小文件;
  3. os.OpenFile 可以组合 O_CREATE、O_TRUNC、O_APPEND 等标志;
  4. 打开成功后应立即 defer Close;
  5. File.Read 需要先处理 n,再处理 err;
  6. io.ReadAll 适合把 Reader 全部读取到内存;
  7. io.Copy 适合在两个 Reader 和 Writer 之间流式传输;
  8. bufio.Scanner 适合逐行读取,但要检查 Err 和 token 限制;
  9. bufio.Writer 使用后必须 Flush;
  10. os.MkdirAll 可以创建多层目录;
  11. os.ReadDir 和 filepath.WalkDir 用于遍历目录;
  12. filepath.Join 比字符串拼接更适合跨平台路径;
  13. errors.Is 可以判断 os.ErrNotExist;
  14. errors.As 可以提取 *os.PathError;
  15. os.CreateTemp 适合创建安全的临时文件;
  16. 临时文件加 Rename 可以实现原子写入;
  17. io.Reader、io.Writer 和 fs.FS 让文件代码更容易测试;
  18. LineFile 和 JSONFile 可以把底层文件操作封装成业务数据结构;
  19. 文件并发写入需要额外的同步策略;
  20. Write 成功、Sync 成功和数据真正持久化是不同层次的保证。

实际选择文件 API 时,可以先问自己:

  • 文件是否足够小,可以一次性加载?
  • 是否需要追加、覆盖还是独占创建?
  • 是否需要逐行处理?
  • 是否可能出现超长行?
  • 文件是否需要原子替换?
  • 目录是否来自不可信输入?
  • 代码是否应该支持内存文件系统测试?
  • 写入失败时是否允许目标文件处于半截状态?

小文件使用 ReadFile 和 WriteFile,大文件采用流式处理;需要业务语义时封装结构体;需要测试替换时依赖 Reader、Writer 或 fs.FS;需要保证重要状态时使用临时文件和原子替换。

官方资料

相关推荐
xbzb1 小时前
Linux iSCSI 存储部署与 CHAP 认证完全指南
linux·服务器·iscsi·chap·共享硬盘
奕鼎竜瑆1 小时前
MCP Connectors by Databox 新手部署与调用指南
服务器·人工智能
闭包不眠2 小时前
端侧AI能省多少服务器钱:把账换成字节算
运维·服务器·图像处理·人工智能·计算机视觉
꯭自꯭闭꯭2 小时前
达梦守护集群手工切换及故障切换
linux·运维·服务器·数据库
哈__2 小时前
日志散在多台服务器怎么查?用 Promtail + Loki + Grafana 搭一套集中检索平台
运维·服务器·grafana
北京中科新远科技2 小时前
AI集群交换网络容量怎么算:端口、收敛比与Leaf-Spine验收
服务器·网络·人工智能
wdfk_prog2 小时前
Wi-Fi Direct 教程 04:Interface 协议子系统初始化——WPA/EAPOL、WPS、DPP/NAN、GAS 与 P2P callback
android·运维·服务器·ubuntu·p2p·wps·wifi-direct
云飞云共享云桌面2 小时前
有个10人的SolidWorks设计团队,服务器什么配置合适,如何落地
运维·服务器·3d·自动化·电脑·制造
爱吃香菜的初学者2 小时前
十二.Linux——管道
linux·运维·服务器