Go 从 0 到精通 · 第 19 课:文件操作基础

学习定位:这是整套 Go 教程的第 19 课,也是阶段四(标准库与实战阶段)的第一课。
前置要求:已经完成第 18 课,掌握了包与模块管理。同时需要熟悉第 16 课的 error 处理和第 17 课的 defer。
本课目标:掌握文件的创建、打开、读取、写入、删除,以及目录操作和路径处理,能编写简单但完整的文件处理程序。


1. 本课你要解决的核心问题

到目前为止,你写的程序都是从控制台输入、往控制台输出。但真实世界的程序几乎都要和文件打交道——读配置、写日志、处理数据、导出报告……

你需要搞明白以下问题:

  • 怎么打开和关闭文件
  • 怎么读取文件的全部内容或逐行读取
  • 怎么写入文件、追加内容
  • 怎么创建和删除文件、目录
  • 怎么处理文件路径
  • os、io、bufio 这几个包各自干什么

学完这一课,你就能让程序和磁盘上的文件交互了。


2. 先搞清楚几个核心包

在 Go 中做文件操作,主要用到三个包:

包 职责 一句话概括
os 操作系统接口 打开、创建、删除文件和目录
io I/O 原语 定义 Reader、Writer 接口
bufio 带缓冲的 I/O 逐行读取、高效写入

还有一小部分功能来自 path/filepath(路径操作)和 io/ioutil(在 Go 1.16 前常用的便捷函数,现在推荐用 os 和 io 替代)。

先有个整体印象,下面逐个展开。


3. 打开和关闭文件

3.1 用 os.Open 打开文件(只读)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"os"
)

func main() {
// os.Open 以只读方式打开文件
file, err := os.Open("hello.txt")
if err != nil {
fmt.Println("打开文件失败:", err)
return
}
// 重要:用完必须关闭!
defer file.Close()

fmt.Println("文件打开成功!")
}

关键点:

  • os.Open 只能读,不能写
  • 返回两个值:*os.File 和 error
  • 一定要调用 file.Close(),否则文件句柄泄漏
  • 最佳实践:紧跟 open 之后写 defer file.Close()

3.2 用 os.OpenFile 打开文件(更灵活)

os.Open 其实是 os.OpenFile 的简化版。当你需要写入、追加、创建等操作时,用 os.OpenFile:

1
2
3
4
5
6
file, err := os.OpenFile("data.txt", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
fmt.Println("打开文件失败:", err)
return
}
defer file.Close()

os.OpenFile 接受三个参数:

  1. 文件路径
  2. 打开模式(flag)
  3. 权限(仅在创建文件时生效)

常用打开模式

模式 含义
os.O_RDONLY 只读
os.O_WRONLY 只写
os.O_RDWR 读写
os.O_CREATE 文件不存在则创建
os.O_TRUNC 打开时清空内容
os.O_APPEND 追加模式(写到文件末尾)

这些模式可以用 | 组合使用。常见的组合:

1
2
3
4
5
6
7
8
// 只写,不存在就创建,存在就清空
os.O_WRONLY | os.O_CREATE | os.O_TRUNC

// 追加写,不存在就创建
os.O_WRONLY | os.O_CREATE | os.O_APPEND

// 读写,不存在就创建
os.O_RDWR | os.O_CREATE

文件权限参数

第三个参数 0644 是 Unix 风格的权限:

  • 0644:所有者可读写,其他人只读(最常用)
  • 0755:所有者可读写执行,其他人可读执行
  • 0600:只有所有者可读写(最安全)

注意:在 Windows 上这个权限参数基本被忽略,但写上是好习惯,确保跨平台兼容。

3.3 完整的打开-关闭流程

1
2
3
4
5
6
7
8
9
10
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("无法打开文件 %s: %w", path, err)
}
defer file.Close()

// 在这里处理文件...
return nil
}

记住这个模式:检查错误 → defer 关闭 → 处理文件。后面几乎所有文件操作都遵循这个套路。


4. 读取文件

4.1 一次性读取全部内容

最简单的方式,适合小文件(几 MB 以内):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"os"
)

func main() {
// 方式一:os.ReadFile(推荐,Go 1.16+)
content, err := os.ReadFile("hello.txt")
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Println(string(content))
}

os.ReadFile 返回 []byte,需要转成 string 才能直接打印。

它内部做了:打开文件 → 读取全部内容 → 关闭文件。你不需要手动管理文件句柄。

一个小例子:读取并统计行数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"os"
)

func main() {
content, err := os.ReadFile("poem.txt")
if err != nil {
fmt.Println("读取失败:", err)
return
}

text := string(content)
lines := 0
for _, ch := range text {
if ch == '\n' {
lines++
}
}
// 最后一行如果没有换行符,也要算上
if len(text) > 0 && text[len(text)-1] != '\n' {
lines++
}

fmt.Printf("文件共有 %d 行\n", lines)
}

4.2 逐块读取(适合大文件)

如果文件很大(比如几个 GB),一次性读入内存会把程序撑爆。这时要分块读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"os"
)

func main() {
file, err := os.Open("bigfile.txt")
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close()

buf := make([]byte, 1024) // 每次读 1024 字节
for {
n, err := file.Read(buf)
if n > 0 {
// 处理 buf[:n] 中的数据
fmt.Printf("读取了 %d 字节\n", n)
}
if err != nil {
break // 读完了(io.EOF)或出错了
}
}
}

file.Read(buf) 的行为:

  • 返回两个值:实际读取的字节数 n 和 error
  • 读到文件末尾时,err 为 io.EOF
  • n 可能小于 len(buf)(最后一块通常不满)

这种手动分块读取的方式底层学习很有价值,但实际开发中我们更常用 bufio 来做,下面会讲。

4.3 用 bufio.Scanner 逐行读取(最常用)

处理文本文件时,最常见的需求是逐行读取。bufio.Scanner 是最佳选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"bufio"
"fmt"
"os"
)

func main() {
file, err := os.Open("data.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("第 %d 行: %s\n", lineNum, line)
}

if err := scanner.Err(); err != nil {
fmt.Println("读取出错:", err)
}
}

bufio.Scanner 的工作机制:

  • scanner.Scan():尝试读取下一行,成功返回 true,到达末尾或出错返回 false
  • scanner.Text():返回当前行的内容(string)
  • scanner.Bytes():返回当前行的内容([]byte)

一定要在循环结束后检查 scanner.Err(),它能告诉你循环是因为正常结束还是因为错误而退出的。

逐行读取的实际例子:过滤包含特定关键词的行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"bufio"
"fmt"
"os"
"strings"
)

func main() {
file, err := os.Open("server.log")
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close()

scanner := bufio.NewScanner(file)
lineNum := 0
found := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if strings.Contains(line, "ERROR") {
found++
fmt.Printf("第 %d 行: %s\n", lineNum, line)
}
}

if err := scanner.Err(); err != nil {
fmt.Println("读取出错:", err)
return
}

fmt.Printf("共找到 %d 行包含 ERROR\n", found)
}

4.4 用 bufio.Reader 逐行读取

bufio.Reader 比 Scanner 更底层,功能更丰富:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"bufio"
"fmt"
"io"
"os"
)

func main() {
file, err := os.Open("data.txt")
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close()

reader := bufio.NewReader(file)
lineNum := 0
for {
line, err := reader.ReadString('\n') // 读到换行符为止
lineNum++
fmt.Printf("第 %d 行: %s", lineNum, line)

if err == io.EOF {
break // 读完了
}
if err != nil {
fmt.Println("读取出错:", err)
return
}
}
}

Scanner vs Reader 怎么选:

bufio.Scanner bufio.Reader
适合场景 逐行读取文本 更灵活的读取(按分隔符、按字节等)
API 简洁度 更简洁 稍复杂
单行长度限制 默认 64KB(可调整) 无限制
推荐程度 日常首选 需要更多控制时使用

5. 写入文件

5.1 用 os.WriteFile 一次性写入

最简单的方式,一次性把内容写入文件(Go 1.16+):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"fmt"
"os"
)

func main() {
data := []byte("Hello, Go 文件操作!\n这是第二行。")

// 第三个参数是文件权限
err := os.WriteFile("output.txt", data, 0644)
if err != nil {
fmt.Println("写入失败:", err)
return
}

fmt.Println("写入成功!")
}

os.WriteFile 的行为:

  • 文件不存在 → 创建
  • 文件已存在 → 清空后写入(覆盖)
  • 内部自动打开和关闭文件

5.2 用 file.Write 和 file.WriteString 写入

当你需要分多次写入时,手动管理文件句柄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fmt"
"os"
)

func main() {
// 创建或清空文件
file, err := os.Create("output.txt")
if err != nil {
fmt.Println("创建失败:", err)
return
}
defer file.Close()

// 方式一:Write 写入字节切片
_, err = file.Write([]byte("第一行内容\n"))
if err != nil {
fmt.Println("写入失败:", err)
return
}

// 方式二:WriteString 写入字符串(更方便)
_, err = file.WriteString("第二行内容\n")
if err != nil {
fmt.Println("写入失败:", err)
return
}

fmt.Println("写入成功!")
}

os.Create 等价于 os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666),即:创建或截断文件。

5.3 追加写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"os"
)

func main() {
// O_APPEND 模式:追加到文件末尾
file, err := os.OpenFile("log.txt",
os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close()

// 每次写入都会追加到末尾
file.WriteString("2024-01-15 10:00:00 用户登录\n")
file.WriteString("2024-01-15 10:05:32 用户下单\n")
file.WriteString("2024-01-15 10:06:01 支付成功\n")

fmt.Println("日志写入成功!")
}

运行多次这个程序,你会发现每次的内容都追加在末尾,而不是覆盖。

5.4 用 bufio.Writer 带缓冲写入

频繁写入小数据时,每次都直接写磁盘效率很低。bufio.Writer 会先把数据攒在内存缓冲区里,攒够了一起写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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)

for i := 1; i <= 10000; i++ {
fmt.Fprintf(writer, "第 %d 行\n", i)
}

// 非常重要!必须 Flush,否则数据可能还在缓冲区里没写入磁盘
err = writer.Flush()
if err != nil {
fmt.Println("刷新缓冲区失败:", err)
return
}

fmt.Println("写入成功!")
}

writer.Flush() 是最容易忘记的一步! 不调用它,数据可能还在内存缓冲区里,没有真正写入磁盘。如果你发现文件内容不完整,第一个要检查的就是有没有忘记 Flush。

带缓冲写入的工作原理

1
2
3
4
5
6
写入操作                    缓冲区(默认4KB)          磁盘文件
───────── ───────────── ────────
WriteString("abc") → [abc ] (还没写)
WriteString("def") → [abcdef ] (还没写)
...攒到 4KB 了... → 自动刷入 → [abcdef...]
Flush() → 手动刷入 → [剩余内容]

缓冲区满了会自动写入磁盘,但调用 Flush() 可以确保缓冲区里剩余的数据也写入。


6. 文件和目录的创建与删除

6.1 创建文件

1
2
3
4
5
6
// os.Create 创建或截断文件(等价于 OpenFile + O_CREATE + O_TRUNC)
file, err := os.Create("newfile.txt")
if err != nil {
// 处理错误
}
defer file.Close()

6.2 创建目录

1
2
3
4
5
// 创建单层目录
err := os.Mkdir("mydir", 0755)

// 递归创建多层目录(类似 mkdir -p)
err := os.MkdirAll("path/to/deep/dir", 0755)

区别:

  • os.Mkdir:只能创建一层,父目录不存在就失败
  • os.MkdirAll:递归创建所有需要的父目录
1
2
3
4
5
// 这个会失败,因为 path/to 不存在
os.Mkdir("path/to/deep/dir", 0755) // 错误!

// 这个能成功,自动创建 path、path/to、path/to/deep
os.MkdirAll("path/to/deep/dir", 0755) // 正确

6.3 删除文件和目录

1
2
3
4
5
// 删除单个文件
err := os.Remove("oldfile.txt")

// 删除目录及其所有内容(类似 rm -r)
err := os.RemoveAll("mydir")

os.RemoveAll 会递归删除整个目录树,不可恢复,务必小心!

6.4 重命名和移动文件

1
2
// 重命名(也可以用来移动,如果在同一文件系统内)
err := os.Rename("old.txt", "new.txt")

6.5 综合示例:创建临时工作目录、写入文件、读取验证、清理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
// 1. 创建工作目录
workDir := "workspace"
err := os.MkdirAll(workDir, 0755)
if err != nil {
fmt.Println("创建目录失败:", err)
return
}
fmt.Println("目录创建成功:", workDir)

// 2. 在目录下创建文件
filePath := filepath.Join(workDir, "data.txt")
err = os.WriteFile(filePath, []byte("工作区数据\n"), 0644)
if err != nil {
fmt.Println("写入失败:", err)
return
}
fmt.Println("文件写入成功:", filePath)

// 3. 读回来验证
content, err := os.ReadFile(filePath)
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Println("文件内容:", string(content))

// 4. 清理
err = os.RemoveAll(workDir)
if err != nil {
fmt.Println("清理失败:", err)
return
}
fmt.Println("清理完成!")
}

7. 路径操作

处理文件路径时,不要手动拼接字符串(Windows 用 \,Linux 用 /)。用 path/filepath 包来保证跨平台兼容。

7.1 filepath.Join:安全拼接路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"fmt"
"path/filepath"
)

func main() {
// 自动用正确的分隔符拼接
path := filepath.Join("data", "users", "alice.json")
fmt.Println(path)
// Windows: data\users\alice.json
// Linux: data/users/alice.json
}

7.2 filepath.Dir 和 filepath.Base

1
2
3
4
5
path := "/home/user/documents/report.txt"

dir := filepath.Dir(path) // /home/user/documents
base := filepath.Base(path) // report.txt
ext := filepath.Ext(path) // .txt

7.3 filepath.Split

1
2
3
dir, file := filepath.Split("/home/user/report.txt")
// dir = "/home/user/"
// file = "report.txt"

7.4 filepath.Abs:获取绝对路径

1
2
3
4
5
6
7
8
abs, err := filepath.Abs("data/file.txt")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(abs)
// Windows: D:\your\project\data\file.txt
// Linux: /your/project/data/file.txt

7.5 filepath.Walk:遍历目录树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"os"
"path/filepath"
)

func main() {
// 遍历当前目录下的所有文件和子目录
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

if info.IsDir() {
fmt.Printf("[目录] %s\n", path)
} else {
fmt.Printf("[文件] %s (%d 字节)\n", path, info.Size())
}
return nil
})

if err != nil {
fmt.Println("遍历出错:", err)
}
}

filepath.Walk 的回调函数签名:

1
func(path string, info os.FileInfo, err error) error
  • path:当前遍历到的路径
  • info:文件信息(大小、修改时间、是否目录等)
  • err:如果访问这个路径出错,这里会有值
  • 返回值:返回非 nil 的 error 会终止遍历

实际例子:统计目录中所有 Go 文件的行数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)

func main() {
totalLines := 0
fileCount := 0

err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 跳过目录
if info.IsDir() {
return nil
}
// 只处理 .go 文件
if !strings.HasSuffix(path, ".go") {
return nil
}

file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()

scanner := bufio.NewScanner(file)
lines := 0
for scanner.Scan() {
lines++
}

if err := scanner.Err(); err != nil {
return err
}

fmt.Printf("%s: %d 行\n", path, lines)
totalLines += lines
fileCount++
return nil
})

if err != nil {
fmt.Println("出错:", err)
return
}

fmt.Printf("共 %d 个 Go 文件,%d 行代码\n", fileCount, totalLines)
}

7.6 常用路径函数速查

函数 用途 示例
filepath.Join(a, b) 拼接路径 filepath.Join("a", "b") → a/b
filepath.Dir(p) 获取目录部分 filepath.Dir("a/b/c.txt") → a/b
filepath.Base(p) 获取文件名部分 filepath.Base("a/b/c.txt") → c.txt
filepath.Ext(p) 获取扩展名 filepath.Ext("c.txt") → .txt
filepath.Split(p) 拆分目录和文件名 返回 (dir, file)
filepath.Abs(p) 转绝对路径 相对路径 → 绝对路径
filepath.Rel(base, targ) 计算相对路径 filepath.Rel("/a", "/a/b") → b
filepath.Clean(p) 清理路径 a//b/../c → a/c

8. 文件信息:os.Stat

在操作文件之前,经常需要先检查文件是否存在、大小是多少等信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"os"
)

func main() {
// 获取文件信息
info, err := os.Stat("data.txt")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("文件不存在")
} else {
fmt.Println("获取信息出错:", err)
}
return
}

fmt.Println("文件名:", info.Name())
fmt.Println("大小:", info.Size(), "字节")
fmt.Println("权限:", info.Mode())
fmt.Println("修改时间:", info.ModTime())
fmt.Println("是否目录:", info.IsDir())
}

输出示例:

1
2
3
4
5
文件名: data.txt
大小: 1024 字节
权限: -rw-r--r--
修改时间: 2024-01-15 10:30:00 +0800 CST
是否目录: false

8.1 判断文件是否存在

1
2
3
4
5
6
7
8
9
10
11
func fileExists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true // 文件存在
}
if os.IsNotExist(err) {
return false // 文件不存在
}
// 其他错误(权限不足等),当作不存在处理
return false
}

注意:os.Stat 返回错误时,不要简单地判断 err != nil 就说文件不存在。用 os.IsNotExist(err) 精确判断。因为错误也可能是"权限不足"——文件是存在的,但你没权限查看。

8.2 用 os.Stat 做"存在则跳过,不存在则创建"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func ensureFile(path string) error {
_, err := os.Stat(path)
if err == nil {
// 文件已存在,跳过
fmt.Println("文件已存在,跳过创建")
return nil
}
if !os.IsNotExist(err) {
// 发生了其他错误
return err
}

// 文件不存在,创建它
return os.WriteFile(path, []byte("默认内容\n"), 0644)
}

9. 读取目录

9.1 os.ReadDir:读取目录下的条目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"os"
)

func main() {
entries, err := os.ReadDir(".")
if err != nil {
fmt.Println("读取目录失败:", err)
return
}

for _, entry := range entries {
if entry.IsDir() {
fmt.Printf("[DIR] %s\n", entry.Name())
} else {
info, _ := entry.Info()
fmt.Printf("[FILE] %s (%d 字节)\n", entry.Name(), info.Size())
}
}
}

9.2 只列出特定扩展名的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
entries, err := os.ReadDir(".")
if err != nil {
return
}

for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) == ".txt" {
fmt.Println(entry.Name())
}
}

10. 综合示例:简易日志系统

把前面学到的知识综合起来,做一个简单的日志写入和查询程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
package main

import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

const logFile = "app.log"

// WriteLog 追加一条日志
func WriteLog(level, message string) error {
file, err := os.OpenFile(logFile,
os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
defer file.Close()

timestamp := time.Now().Format("2006-01-02 15:04:05")
logLine := fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, message)

_, err = file.WriteString(logLine)
return err
}

// ReadLogs 读取所有日志
func ReadLogs() ([]string, error) {
if _, err := os.Stat(logFile); os.IsNotExist(err) {
return nil, fmt.Errorf("日志文件不存在")
}

file, err := os.Open(logFile)
if err != nil {
return nil, err
}
defer file.Close()

var logs []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
logs = append(logs, scanner.Text())
}
return logs, scanner.Err()
}

// SearchLogs 搜索包含关键词的日志
func SearchLogs(keyword string) ([]string, error) {
logs, err := ReadLogs()
if err != nil {
return nil, err
}

var results []string
for _, line := range logs {
if strings.Contains(line, keyword) {
results = append(results, line)
}
}
return results, nil
}

func main() {
// 写入一些日志
WriteLog("INFO", "程序启动")
WriteLog("INFO", "用户 admin 登录成功")
WriteLog("ERROR", "数据库连接超时")
WriteLog("INFO", "用户 admin 下单成功")
WriteLog("WARN", "内存使用率超过 80%")
WriteLog("ERROR", "支付接口返回异常")

// 读取所有日志
fmt.Println("=== 全部日志 ===")
logs, err := ReadLogs()
if err != nil {
fmt.Println(err)
return
}
for _, line := range logs {
fmt.Println(line)
}

// 搜索 ERROR 日志
fmt.Println("\n=== ERROR 日志 ===")
errors, err := SearchLogs("ERROR")
if err != nil {
fmt.Println(err)
return
}
for _, line := range errors {
fmt.Println(line)
}

// 清理
fmt.Println("\n日志文件路径:")
abs, _ := filepath.Abs(logFile)
fmt.Println(abs)
}

输出示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=== 全部日志 ===
[2024-01-15 10:00:00] [INFO] 程序启动
[2024-01-15 10:00:01] [INFO] 用户 admin 登录成功
[2024-01-15 10:00:02] [ERROR] 数据库连接超时
[2024-01-15 10:00:03] [INFO] 用户 admin 下单成功
[2024-01-15 10:00:04] [WARN] 内存使用率超过 80%
[2024-01-15 10:00:05] [ERROR] 支付接口返回异常

=== ERROR 日志 ===
[2024-01-15 10:00:02] [ERROR] 数据库连接超时
[2024-01-15 10:00:05] [ERROR] 支付接口返回异常

日志文件路径:
D:\your\project\app.log

11. 常见坑总结

11.1 忘记 defer file.Close()

后果:文件句柄泄漏。操作系统对每个进程能打开的文件数量有限制(Linux 默认 1024),泄漏多了就打不开新文件了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 错误示范
func badRead(path string) {
file, _ := os.Open(path)
// 用完没关闭!
// 随着函数调用增多,文件句柄耗尽
}

// 正确做法
func goodRead(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 无论如何都会关闭
// ...
return nil
}

11.2 忘记 writer.Flush()

后果:数据还在缓冲区里,没有写入磁盘。程序正常结束时可能会丢失数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 错误示范
func badWrite(path string) {
file, _ := os.Create(path)
defer file.Close()

writer := bufio.NewWriter(file)
for i := 0; i < 1000; i++ {
fmt.Fprintln(writer, "line", i)
}
// 忘了 Flush!数据可能不完整
}

// 正确做法
func goodWrite(path string) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()

writer := bufio.NewWriter(file)
for i := 0; i < 1000; i++ {
fmt.Fprintln(writer, "line", i)
}

return writer.Flush() // 确保数据写入磁盘
}

11.3 手动拼接路径而不是用 filepath.Join

后果:在 Windows 上运行正常,部署到 Linux 上路径错误。

1
2
3
4
5
// 错误:Windows 下是 \,Linux 下是 /
path := "data" + "\\" + "file.txt" // Linux 上错了!

// 正确:跨平台兼容
path := filepath.Join("data", "file.txt")

11.4 用 os.Remove 删除非空目录

后果:报错 “directory not empty”。

1
2
3
4
5
// os.Remove 只能删除空目录或单个文件
os.Remove("mydir/") // 如果 mydir 不为空,失败!

// 用 os.RemoveAll 递归删除
os.RemoveAll("mydir/") // 删除 mydir 及其所有内容

11.5 忽略 os.Stat 的其他错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 错误:所有错误都认为是"不存在"
_, err := os.Stat(path)
if err != nil {
// 这可能是权限错误,不是不存在!
return false
}

// 正确:精确判断
_, err := os.Stat(path)
if os.IsNotExist(err) {
return false // 确实不存在
}
if err != nil {
// 其他错误:权限不足、路径太长等
return false
}
return true

11.6 Scanner 默认限制单行 64KB

bufio.Scanner 默认缓冲区是 64KB。如果文件中有超过 64KB 的单行,Scan() 会失败。

1
2
3
4
5
6
// 解决方案:增大缓冲区
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 最大 1MB
for scanner.Scan() {
// ...
}

11.7 在循环中 defer 文件关闭

defer 只在函数返回时执行。如果在循环里 defer file.Close(),所有文件句柄会堆积到函数结束才关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 错误:1000 个文件句柄要等到函数结束才关
func processAll(paths []string) error {
for _, path := range paths {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 堆积!
// 处理 file...
}
return nil
}

// 正确:用一个匿名函数包起来,让每次循环都关闭
func processAll(paths []string) error {
for _, path := range paths {
if err := func() error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // 每次循环结束都关闭
// 处理 file...
return nil
}(); err != nil {
return err
}
}
return nil
}

11.8 写入文件后立即读取

如果你用同一个文件句柄先写后读,必须先调用 file.Seek(0, 0) 把读写位置移回开头:

1
2
3
4
5
6
7
8
9
10
11
file, _ := os.OpenFile("data.txt", os.O_RDWR|os.O_CREATE, 0644)
defer file.Close()

file.WriteString("Hello")

// 不 Seek 的话,读的位置在 "Hello" 之后,读到的是空
file.Seek(0, 0) // 移到开头

reader := bufio.NewReader(file)
line, _ := reader.ReadString('\n')
fmt.Println(line) // "Hello"

12. io 包的核心接口(补充)

io 包定义了 Go 中最基础的 I/O 接口,理解这些能帮你更好地理解 os 和 bufio 的关系。

12.1 io.Reader 和 io.Writer

1
2
3
4
5
6
7
type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

*os.File 实现了 io.Reader 和 io.Writer,这就是为什么 bufio.NewReader(file) 能工作——bufio.NewReader 接受的是 io.Reader,不关心具体是什么类型。

12.2 用 io.Copy 高效复制数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fmt"
"io"
"os"
)

func main() {
src, err := os.Open("source.txt")
if err != nil {
fmt.Println("打开源文件失败:", err)
return
}
defer src.Close()

dst, err := os.Create("dest.txt")
if err != nil {
fmt.Println("创建目标文件失败:", err)
return
}
defer dst.Close()

// io.Copy 内部用 32KB 缓冲区高效复制
written, err := io.Copy(dst, src)
if err != nil {
fmt.Println("复制失败:", err)
return
}

fmt.Printf("复制完成,共 %d 字节\n", written)
}

io.Copy 是复制文件的推荐方式,内部自动分块读写,比手动循环效率高。


13. 本课练习

练习 1:创建和读取文件

要求:

  • 创建一个文件 students.txt,写入 5 个学生姓名(每行一个)
  • 读取这个文件,打印每个学生姓名及其行号

练习 2:文件内容统计

要求:

  • 读取一个文本文件
  • 统计:总字符数、总行数、非空行数
  • 提示:scanner.Scan() + strings.TrimSpace

练习 3:简易 CSV 读取器

要求:

  • 读取一个逗号分隔的文本文件(如 name,age,city)
  • 解析每行,以整齐的格式打印(提示:strings.Split)
  • 示例文件 data.csv:
    1
    2
    3
    name,age,city
    Alice,25,Beijing
    Bob,30,Shanghai

练习 4:目录遍历与文件过滤

要求:

  • 遍历指定目录及其子目录
  • 找出所有 .txt 文件,打印路径和大小
  • 统计所有 .txt 文件的总大小

练习 5:简易文件备份工具

要求:

  • 接收一个文件路径作为输入
  • 将文件复制一份,命名为 原文件名.bak
  • 用 io.Copy 实现复制

14. 自测题

14.1 概念题

  1. os.Open 和 os.OpenFile 有什么区别?
  2. os.ReadFile 适合什么场景?大文件应该怎么做?
  3. bufio.Scanner 和 bufio.Reader 各自适合什么场景?
  4. writer.Flush() 为什么必须调用?不调用会怎样?
  5. os.Mkdir 和 os.MkdirAll 有什么区别?
  6. filepath.Join 相比手动拼接字符串有什么优势?
  7. 怎么判断一个文件是否存在?
  8. io.Copy 的作用是什么?
  9. os.Remove 能删除非空目录吗?
  10. bufio.Scanner 默认能处理多大的单行?

14.2 代码阅读题

下面的代码有什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func countLines(path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}

scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
count++
}

return count, nil
}
点击查看答案

问题 1:缺少 defer file.Close(),文件句柄泄漏。

问题 2:没有检查 scanner.Err(),如果读取过程中出错,会当作正常读完处理。

修正后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func countLines(path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()

scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
count++
}

if err := scanner.Err(); err != nil {
return 0, err
}

return count, nil
}

15. 本课总结

这一课你学到了 Go 中文件操作的完整知识。

你现在应该已经理解:

操作 推荐方式
读取小文件 os.ReadFile
读取大文件 bufio.Scanner(逐行)或 file.Read(分块)
一次性写入 os.WriteFile
分次写入 file.WriteString + bufio.Writer
追加写入 os.OpenFile + O_APPEND
判断文件是否存在 os.Stat + os.IsNotExist
拼接路径 filepath.Join
遍历目录 filepath.Walk / os.ReadDir
复制文件 io.Copy

最重要的三件事:

  1. 打开文件后 defer file.Close()
  2. bufio.Writer 用完后 Flush()
  3. 路径操作用 filepath 包,不要手动拼接

16. 下一课预告

下一课我们学习时间处理。

会重点讲:

  • time.Now() 获取当前时间
  • 时间格式化(Go 独特的格式化方式)
  • 时间解析、计算、比较
  • 定时器和 time.Sleep

学完下一课,你就能在程序中处理各种和时间相关的任务了。