文件 I/O:Go 的文件操作全攻略
几乎所有程序都需要和文件打交道——读配置文件、写日志、处理用户上传的图片、导出数据到 CSV……文件操作是编程中最基础也最实用的技能之一。
Go 语言在文件 I/O 方面设计得非常优雅。它基于 Unix 哲学——“一切皆文件”,用统一的接口(io.Reader 和 io.Writer)处理各种 I/O 操作。这意味着你学会了文件读写,也就学会了网络通信、内存缓冲等等。
今天我们就来全面学习 Go 的文件操作,从最简单的读写开始,一直到高级的缓冲、临时文件、文件监控等话题。
读取文件
一次性读取整个文件
对于小文件,最简单的方式是一次性读取所有内容:
package main
import (
"fmt"
"os"
)
func main() {
// ReadFile 一次性读取整个文件(Go 1.16+)
data, err := os.ReadFile("example.txt")
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Println(string(data))
}
注意:os.ReadFile 会把整个文件加载到内存中。对于大文件(比如几个 GB 的日志文件),这种方式会让内存爆炸。
逐行读取
处理大文件时,更常见的方式是逐行读取:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("example.txt")
if err != nil {
fmt.Println("打开文件失败:", err)
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
fmt.Printf("%4d: %s\n", lineNum, line)
}
if err := scanner.Err(); err != nil {
fmt.Println("读取错误:", err)
}
}
bufio.Scanner 是一个行扫描器,默认按换行符分割。它内部使用缓冲区,性能很好。
按字节块读取
对于非常大的文件或者需要精细控制的场景,可以按块读取:
package main
import (
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("example.txt")
if err != nil {
fmt.Println("打开文件失败:", err)
return
}
defer file.Close()
buf := make([]byte, 1024) // 1KB 缓冲区
totalBytes := 0
for {
n, err := file.Read(buf)
if err != nil {
if err == io.EOF {
break // 文件结束
}
fmt.Println("读取错误:", err)
return
}
totalBytes += n
// 处理 buf[:n] 中的数据
}
fmt.Printf("总共读取了 %d 字节\n", totalBytes)
}
随机读取(Seek)
有时候你需要跳到文件的特定位置读取:
file, _ := os.Open("example.txt")
defer file.Close()
// 跳到文件开头后 100 字节的位置
file.Seek(100, io.SeekStart)
// 从当前位置向后跳 50 字节
file.Seek(50, io.SeekCurrent)
// 从文件末尾向前 20 字节
file.Seek(-20, io.SeekEnd)
buf := make([]byte, 10)
file.Read(buf)
Seek 的三个常量:
io.SeekStart:从文件开头算起io.SeekCurrent:从当前位置算起io.SeekEnd:从文件末尾算起
写入文件
一次性写入
package main
import (
"fmt"
"os"
)
func main() {
content := []byte("Hello, Go!\n这是第二行\n")
// WriteFile 一次性写入,权限 0644
err := os.WriteFile("output.txt", content, 0644)
if err != nil {
fmt.Println("写入失败:", err)
}
}
注意:os.WriteFile 会覆盖已有文件的内容。
追加写入
file, err := os.OpenFile("output.txt", os.O_APPEND|os.O_CREATE, 0644)
if err != nil {
fmt.Println("打开文件失败:", err)
return
}
defer file.Close()
file.WriteString("追加的内容\n")
文件打开模式
os.OpenFile 的第二个参数是标志位,可以用 | 组合:
| 标志 | 说明 |
|---|---|
os.O_RDONLY | 只读 |
os.O_WRONLY | 只写 |
os.O_RDWR | 读写 |
os.O_CREATE | 如果文件不存在则创建 |
os.O_APPEND | 追加模式 |
os.O_TRUNC | 打开时截断文件 |
os.O_EXCL | 和 O_CREATE 一起使用,文件存在则报错 |
使用缓冲写入
频繁的磁盘写入性能很差。用 bufio.Writer 可以缓冲写入,提升性能:
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Create("output.txt")
if err != nil {
fmt.Println("创建文件失败:", err)
return
}
defer file.Close()
writer := bufio.NewWriter(file)
defer writer.Flush() // 重要!确保缓冲区内容写入磁盘
for i := 0; i < 1000; i++ {
fmt.Fprintf(writer, "第 %d 行\n", i+1)
}
// Flush 在 defer 中调用,确保所有数据都写入
}
重要:使用 bufio.Writer 时,一定要在合适的时候调用 Flush(),否则缓冲区中的数据可能不会被写入磁盘。
文件信息
package main
import (
"fmt"
"os"
"time"
)
func main() {
info, err := os.Stat("example.txt")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("文件不存在")
} else {
fmt.Println("获取信息失败:", err)
}
return
}
fmt.Printf("文件名: %s\n", info.Name())
fmt.Printf("大小: %d 字节\n", info.Size())
fmt.Printf("权限: %v\n", info.Mode())
fmt.Printf("修改时间: %v\n", info.ModTime().Format(time.RFC3339))
fmt.Printf("是否目录: %v\n", info.IsDir())
}
检查文件是否存在
Go 没有直接的 exists() 函数,但可以通过 os.Stat 判断:
func fileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
func isDir(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
目录操作
创建目录
// 创建单个目录
os.Mkdir("mydir", 0755)
// 递归创建目录(包括所有不存在的父目录)
os.MkdirAll("a/b/c/d", 0755)
列出目录内容
package main
import (
"fmt"
"os"
)
func main() {
entries, err := os.ReadDir(".")
if err != nil {
fmt.Println("读取目录失败:", err)
return
}
for _, entry := range entries {
info, _ := entry.Info()
if entry.IsDir() {
fmt.Printf("DIR %s\n", entry.Name())
} else {
fmt.Printf("FILE %s (%d bytes)\n", entry.Name(), info.Size())
}
}
}
遍历目录
filepath.Walk 和 filepath.WalkDir(Go 1.16+)可以递归遍历目录:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
root := "./myproject"
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
fmt.Printf("DIR %s\n", path)
} else {
info, _ := d.Info()
fmt.Printf("FILE %s (%d bytes)\n", path, info.Size())
}
return nil
})
if err != nil {
fmt.Println("遍历失败:", err)
}
}
路径处理
path/filepath 包提供了跨平台的路径处理工具:
package main
import (
"fmt"
"path/filepath"
)
func main() {
// 拼接路径(跨平台)
path := filepath.Join("home", "user", "documents", "file.txt")
fmt.Println(path) // home/user/documents/file.txt(Linux/macOS)
// home\user\documents\file.txt(Windows)
// 拆分路径
dir, file := filepath.Split("/home/user/file.txt")
fmt.Printf("dir: %s, file: %s\n", dir, file)
// 获取扩展名
ext := filepath.Ext("image.jpg")
fmt.Println(ext) // .jpg
// 获取绝对路径
abs, _ := filepath.Abs("./file.txt")
fmt.Println(abs)
// 匹配通配符
matched, _ := filepath.Match("*.go", "main.go")
fmt.Println(matched) // true
// 清理路径
cleaned := filepath.Clean("./a/../b/./c")
fmt.Println(cleaned) // b/c
}
小贴士:始终使用 filepath.Join 来拼接路径,而不是用字符串拼接。这样可以保证跨平台兼容性。
复制、移动和删除文件
复制文件
func copyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
移动/重命名文件
os.Rename("old.txt", "new.txt")
删除文件
// 删除单个文件
os.Remove("file.txt")
// 递归删除目录及其内容
os.RemoveAll("mydir")
临时文件
os 包和 io/ioutil 包提供了创建临时文件的便捷方式:
package main
import (
"fmt"
"os"
)
func main() {
// 创建临时文件
tmpFile, err := os.CreateTemp("", "example-*.txt")
if err != nil {
fmt.Println("创建临时文件失败:", err)
return
}
defer os.Remove(tmpFile.Name()) // 用完删除
defer tmpFile.Close()
fmt.Println("临时文件:", tmpFile.Name())
tmpFile.WriteString("一些临时数据")
// 创建临时目录
tmpDir, err := os.MkdirTemp("", "example-*")
if err != nil {
fmt.Println("创建临时目录失败:", err)
return
}
defer os.RemoveAll(tmpDir)
fmt.Println("临时目录:", tmpDir)
}
实战:文本文件分析器
让我们把所有知识综合起来,写一个文本文件分析工具:
package main
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
"unicode"
)
// FileStats 文件统计信息
type FileStats struct {
Lines int
Words int
Chars int
Bytes int
TopWords []WordCount
}
type WordCount struct {
Word string
Count int
}
func analyzeFile(path string) (*FileStats, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
stats := &FileStats{
Bytes: int(info.Size()),
}
wordCounts := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
stats.Lines++
line := scanner.Text()
stats.Chars += len([]rune(line))
// 分词(简单版)
words := strings.FieldsFunc(line, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
for _, word := range words {
stats.Words++
word = strings.ToLower(word)
wordCounts[word]++
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// 统计 Top 10 单词
var wc []WordCount
for word, count := range wordCounts {
wc = append(wc, WordCount{word, count})
}
sort.Slice(wc, func(i, j int) bool {
return wc[i].Count > wc[j].Count
})
limit := 10
if len(wc) < limit {
limit = len(wc)
}
stats.TopWords = wc[:limit]
return stats, nil
}
func main() {
if len(os.Args) < 2 {
fmt.Println("用法: analyzer <文件路径>")
os.Exit(1)
}
path := os.Args[1]
stats, err := analyzeFile(path)
if err != nil {
fmt.Println("分析失败:", err)
os.Exit(1)
}
fmt.Printf("\n文件分析: %s\n", path)
fmt.Println(strings.Repeat("-", 40))
fmt.Printf("字节数: %d\n", stats.Bytes)
fmt.Printf("字符数: %d\n", stats.Chars)
fmt.Printf("单词数: %d\n", stats.Words)
fmt.Printf("行数: %d\n", stats.Lines)
fmt.Printf("\n出现最多的单词:\n")
for i, wc := range stats.TopWords {
fmt.Printf("%2d. %-20s %d 次\n", i+1, wc.Word, wc.Count)
}
}
小结
今天我们全面学习了 Go 的文件 I/O 操作:
- 读取:
ReadFile、Scanner、Read、Seek - 写入:
WriteFile、WriteString、缓冲写入 - 文件信息:
Stat、权限、修改时间 - 目录操作:创建、列出、遍历
- 路径处理:
filepath包的跨平台工具 - 临时文件:
CreateTemp、MkdirTemp
Go 的文件 I/O 基于统一的 io.Reader 和 io.Writer 接口,这让文件操作和网络操作、内存操作等使用同样的模式,学习曲线很平滑。
练习时间
- 日志文件分析器:统计日志文件中各级别(INFO/WARN/ERROR)的数量
- 文件备份工具:实现一个工具,把指定目录下的所有
.go文件打包备份 - CSV 处理:读取 CSV 文件,统计某一列的数据分布
- 目录树打印:用递归打印一个漂亮的目录树(类似
tree命令) - 文件比较器:比较两个文件是否内容相同,如果不同,输出差异位置
进阶概念
文件描述符
Go 中 os.File 封装了底层的文件描述符(file descriptor, fd)。每个打开的文件在操作系统中都有一个唯一的 fd 号。理解 fd 有助于定位资源泄漏问题。
import "syscall"
func showFD(file *os.File) {
// 获取底层文件描述符
rawConn, _ := file.SyscallConn()
rawConn.Control(func(fd syscall.RawConn) {
// fd 可用于高级操作
})
}
文件描述符在 Linux 系统上有数量限制(ulimit -n),大规模高并发服务要注意避免 fd 泄漏。每个 open/create 操作都会占用一个 fd,必须对应 Close 或让 GC 回收。
缓冲 IO 原理
bufio.Reader/Writer 的核心思想是减少系统调用次数:
// bufio.Reader 内部结构
type Reader struct {
buf []byte // 内部缓冲区
rd io.Reader // 底层 reader
r, w int // buf[r:w] 是当前有效数据
err error
lastByte int
lastRuneSize int
}
读取时不是每次向内核请求 1 字节,而是一次性读取 4KB(默认),后续读操作从内存缓冲区获取。这让连续读取小块数据的性能提升数十倍。
// 对比有无缓冲的性能
func benchmarkBuffered() {
file, _ := os.Open("1mb.txt")
defer file.Close()
// 无缓冲:每次读 1 字节
buf := make([]byte, 1)
for {
_, err := file.Read(buf)
if err == io.EOF { break }
}
// 有缓冲:内部 4KB 缓存
reader := bufio.NewReader(file)
for {
_, err := reader.Read(buf)
if err == io.EOF { break }
}
}
零拷贝(sendfile)
零拷贝技术避免了数据在用户空间和内核空间之间不必要的复制。Go 中的 io.Copy 在某些情况下(源和目标都是 TCP 连接或文件时会使用 sendfile 系统调用)可以利用零拷贝:
func zeroCopyExample() {
src, _ := os.Open("big.file")
defer src.Close()
// 如果 dst 是 net.TCPConn,io.Copy 内部可能使用 sendfile
dst, _ := net.Dial("tcp", "server:80")
defer dst.Close()
written, _ := io.Copy(dst, src)
fmt.Println(written)
}
零拷贝的原理:内核直接把数据从文件缓存传递给 socket 缓冲区,不经过用户空间。这在传输大文件时能显著减少 CPU 使用和内存带宽占用。
内存映射 mmap
syscall 包和第三方库(如 golang.org/x/exp/mmap)提供内存映射功能,让文件内容像内存一样访问:
import "golang.org/x/exp/mmap"
func mmapExample() error {
reader, err := mmap.Open("data.bin")
if err != nil {
return err
}
defer reader.Close()
// 直接通过内存地址访问文件内容
data := make([]byte, 1024)
_, err = reader.ReadAt(data, 0)
return err
}
mmap 将文件映射到进程的虚拟地址空间。操作系统只在真正访问某页时才从磁盘加载(按需分页)。对于超大文件和随机访问场景,mmap 比普通 read/write 更高效。
OS 页缓存
操作系统会自动将最近访问的文件数据缓存在内存中(Page Cache)。这解释了为什么同一文件第二次读取快很多:直接从内存页缓存获取,无需磁盘 I/O。
// 第一次读取(冷缓存,从磁盘装载)
data1, _ := os.ReadFile("big.log")
// 第二次读取(热缓存,从内存读取)
data2, _ := os.ReadFile("big.log")
// 第二次通常快 10-100 倍
了解页缓存的存在,有助于理解为什么"清除缓存后测试"(echo 3 > /proc/sys/vm/drop_caches)对 I/O 基准测试很重要。
实战案例
大文件分块读取
对于日志分析、数据导入等场景,分块读取是标准策略:
package main
import (
"bufio"
"fmt"
"os"
)
func processLargeFile(path string, chunkSize int64) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
chunkNum := 0
for {
chunkNum++
buf := make([]byte, chunkSize)
n, err := file.Read(buf)
if n == 0 {
break
}
// 处理当前块
fmt.Printf("处理块 %d, 大小 %d bytes\n", chunkNum, n)
processChunk(buf[:n])
if err != nil {
break
}
}
return nil
}
func processChunk(data []byte) {
// 实际业务处理逻辑
}
func streamProcess(path string, handler func([]byte) error) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
// 增加扫描缓冲区以支持大段内容
const maxCapacity = 64 * 1024
buf := make([]byte, maxCapacity)
scanner.Buffer(buf, maxCapacity)
for scanner.Scan() {
if err := handler(scanner.Bytes()); err != nil {
return err
}
}
return scanner.Err()
}
日志轮转实现
生产环境中日志文件需要定期轮换,避免单个文件过大:
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type RotateWriter struct {
mu sync.Mutex
file *os.File
path string
maxSize int64
maxAge time.Duration
currSize int64
}
func NewRotateWriter(path string, maxSize int64, maxAge time.Duration) (*RotateWriter, error) {
w := &RotateWriter{
path: path,
maxSize: maxSize,
maxAge: maxAge,
}
if err := w.rotate(); err != nil {
return nil, err
}
return w, nil
}
func (w *RotateWriter) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.currSize+int64(len(p)) > w.maxSize {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err = w.file.Write(p)
w.currSize += int64(n)
return n, err
}
func (w *RotateWriter) rotate() error {
if w.file != nil {
w.file.Close()
// 重命名为带时间戳的旧文件
backup := fmt.Sprintf("%s.%s", w.path, time.Now().Format("20060102_150405"))
os.Rename(w.path, backup)
}
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return err
}
info, _ := file.Stat()
w.file = file
w.currSize = info.Size()
return nil
}
func main() {
writer, _ := NewRotateWriter("app.log", 1024*1024, 7*24*time.Hour)
for i := 0; i < 100000; i++ {
fmt.Fprintf(writer, "[%s] 日志记录 %d\n",
time.Now().Format(time.RFC3339), i)
}
}
CSV 大批量解析
package main
import (
"encoding/csv"
"fmt"
"os"
)
func parseCSVBatch(path string, batchSize int) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
reader := csv.NewReader(file)
// 读取 header
headers, err := reader.Read()
if err != nil {
return err
}
fmt.Printf("列: %v\n", headers)
var batch [][]string
for {
record, err := reader.Read()
if err != nil {
if err.Error() == "EOF" {
break
}
// 跳过错误行继续处理
continue
}
batch = append(batch, record)
if len(batch) >= batchSize {
processBatch(batch)
batch = batch[:0]
}
}
// 处理剩余
if len(batch) > 0 {
processBatch(batch)
}
return nil
}
func processBatch(records [][]string) {
fmt.Printf("处理批次, 数量: %d\n", len(records))
// 实际入库等操作
}
性能对比:各 IO 方式吞吐量对比表
| I/O 方式 | 适合场景 | 吞吐量 | 内存占用 | 代码复杂度 |
|---|---|---|---|---|
| os.ReadFile | 小文件(< 10MB) | 高(直接读取) | 高(全载内存) | 低 |
| bufio.Scanner | 文本文件逐行读取 | 中高 | 低(固定缓冲区) | 低 |
| bufio.Reader(手动块读) | 大文件流式处理 | 高 | 可控 | 中 |
| mmap(内存映射) | 超大文件随机访问 | 极高 | 低(按需分页) | 中高 |
| sendfile(零拷贝) | 文件转网络 | 极高 | 几乎为零 | 低(io.Copy自动) |
| io.ReadAll | 不确定大小的读取 | 中等 | 高(动态扩容) | 低 |
FAQ
Q1: os.ReadFile 和 ioutil.ReadFile 有什么区别?
A: Go 1.16 将 ioutil.ReadFile 标记为弃用,推荐使用 os.ReadFile。两者功能完全相同,但 os.ReadFile 位于更合理的包中。
Q2: defer file.Close() 放在哪里最好?
A: 应该在 os.Open() 后立即关闭,避免后续代码有提前 return 路径时漏关。但要先检查 err,如果打开失败就不需要关闭:
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 成功打开后才 defer
Q3: bufio.Writer 的 Flush 何时调用最合适?
A: 最佳实践是立即 defer,或者在固定写入周期后主动 Flush。对于长时间运行的程序,不能只依赖 defer,因为 defer 只在函数返回时执行:
writer := bufio.NewWriter(file)
defer writer.Flush()
// 重要:长循环中定期 flush
for i, line := range lines {
writer.WriteString(line)
if i % 1000 == 0 {
writer.Flush()
}
}
Q4: 如何处理 GB 级超大文件?
A: 永远不要一次性读取。推荐采用流式处理策略:按行用 Scanner,按块用 Read(buf)。如果数据格式允许,还可以考虑并发分片处理(每个 goroutine 处理文件的不同段)。
Q5: Scanner 的默认 64KB 行限制怎么解决?
A: 使用 scanner.Buffer() 方法增大缓冲区:
scanner := bufio.NewScanner(file)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 允许最大 1MB 行
Q6: 如何判断文件正在被其他进程写入?
A: 没有跨平台的原子方法。常见方案是用文件锁(syscall.Flock 在 Unix 上)或约定机制(写临时文件完成后重命名)。
Q7: filepath.Walk 和 WalkDir 有什么区别?
A: Go 1.16 引入的 WalkDir 更高效。Walk 对每个文件都调用 os.Lstat 获取文件信息。WalkDir 改用 os.Stat,在需要文件信息时才获取。对于目录树遍历,WalkDir 通常更快。
Q8: 跨平台路径分隔符怎么处理?
A: 始终使用 path/filepath 包的各种函数(Join、Split、Dir、Base),不要手动拼接字符串或用 / 硬编码路径。filepath 会自动处理 Windows 的 \ 和 Unix 的 /。
最佳实践
defer close 顺序
defer 遵循 LIFO(后进先出)顺序。如果有多个资源需要关闭,要注意依赖关系:
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
reader := bufio.NewReader(file)
// 不需要 defer reader,它只持有 file 引用
// 如果创建了解码器
gz, err := gzip.NewReader(reader)
if err != nil {
return err
}
defer gz.Close() // 先关闭 gz,再关闭 file
// 读取并处理
_, err = io.ReadAll(gz)
return err
} // defer 执行顺序:gz.Close() -> file.Close()
大文件处理策略
- 流式处理:始终优先使用 Reader/Scanner,避免 ReadAll
- 分片并行:对于无关联性的记录(如日志行),可以按文件位置切片,多 goroutine 并行处理
- 内存映射:超大文件需要快速随机访问时,使用 mmap
- 定期 Flush:写入时根据写入频率决定 Flush 策略,平衡安全性和性能
- 不要忽略错误:scanner.Err() 在文件损坏或有编码问题时可能非 nil
延伸阅读
我们下篇见!
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。