README.md 7.1 KB

第 17 课:文件读写与路径操作

学习顺序

先阅读 main.go 中 run 的流程,再分别理解 appendLine 和 readLines,最后运行 main_test.go。本课共享根目录的 go.mod,仅用标准库,不安装第三方依赖。

本例每次在系统临时目录下新建独立的 go-lesson17-... 文件夹,只操作自己创建的 notes.txt,不覆盖仓库或用户已有文件。运行结束保留目录,方便你按终端显示的路径查看。多次运行会产生多个目录,学习完可以人工确认路径后删除;示例不自动递归删除。

1. 路径和当前工作目录

path := filepath.Join(directory, "notes.txt")

filepath 处理当前操作系统的文件路径:

  • Join:拼接路径,使用系统适合的分隔符。
  • Base:取最后一段文件名。
  • Ext:取扩展名,例如 .txt。
  • Dir:取所在目录。
  • Abs:将相对路径转成绝对路径,返回路径和 error。

相对路径是相对于进程当前工作目录,不一定是源码目录或 exe 所在目录。比如从仓库根目录运行时,notes.txt 默认指向根目录下的文件。需要确认时可调用 os.Getwd()。

本课使用 os.MkdirTemp 返回的目录避免上述歧义。Join 只是拼路径,不创建目录,也不是防止不可信路径逃逸的安全校验;不要把用户任意输入直接作为敏感文件路径。

2. 整体写入与读取

err := os.WriteFile(path, []byte(content), 0600)
data, err := os.ReadFile(path)
text := string(data)
  • []byte(content):将字符串转换为字节切片,不是数组声明。
  • WriteFile:打开或创建文件、写入内容并关闭,调用方不需要再 Close。
  • 文件已存在时,WriteFile 会截断并覆盖原内容,它不是追加。
  • ReadFile:一次读取整个文件并关闭,返回字节切片和错误。
  • string(data):将字节转换为字符串显示;不会自动检测或转换 GBK 等编码,本课文本使用 UTF-8。

整体读取适合较小文件,大文件应考虑流式处理。WriteFile 不会自动创建缺失的父目录;需要时先用 os.MkdirAll 创建目录。

0600 是以 0 开头的八进制权限写法,在 Unix 中表示仅所有者可读写;它不是十进制 600。权限会受系统机制影响,Windows 上不能用它代替完整 ACL 配置。文件已存在时,此参数也不会自动修改已有权限。

3. 打开文件并追加

file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)

这里 file 是 *os.File,包含对打开文件的操作方法。

标志 含义
O_WRONLY 只写
O_CREATE 文件不存在则创建
O_APPEND 写到末尾,不清空原内容

| 是按位或,用来组合标志,不是布尔运算的 ||。

WriteString 返回写入的字节数和 error。本课用 _ 忽略字节数,但保留并检查错误。写入后必须关闭;即使写入失败也要尝试 Close。

appendLine 显式保存 writeErr 和 closeErr,优先报告写入错误,没有写入错误时再返回关闭错误。这是简单的错误优先策略,不是同时保留两个错误;后续可学习 errors.Join。

关闭并不等于持久化到物理磁盘的全部保证,涉及重要数据时还要考虑 Sync、原子替换等问题,本课不展开。

4. 逐行读取与 defer

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

Open 默认以只读方式打开,成功后才登记关闭操作。不能在检查打开错误之前就 defer 一个可能无效的对象。

这里是只读示例,简化为忽略关闭错误;写文件时不能照搬成“一律不检查关闭错误”。defer 在 readLines 返回前执行,不会等整个程序结束。

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
    return nil, err
}
  • Scan:读取下一个 token,本例默认按行分割。
  • Text:取得本行文本,不包含结尾换行符。
  • Scan 返回 false:可能是正常读完,也可能发生错误,所以循环后要检查 Err。
  • 默认 Scanner 有 token 大小限制,约 64 KiB,极长行可能报错。可以在开始扫描前用 Buffer 调整,或用 bufio.Reader 处理;逐行读取不等于没有长度限制。
  • 本函数最后仍把所有行存到切片,因此总内存随文件内容增长,不是恒定内存的大文件处理方案。

5. 文件信息与错误

os.Stat 获取文件信息,Name 是名称,IsDir 判断是否目录,Size 是字节数,不是中文字数。

不存在、无权限、路径指向目录等情况都可能返回错误,不要仅凭错误文字判断类别。本课先传播错误,后续可用 errors.Is 配合 os.ErrNotExist 等检查类别。

main 调用 run,由 run 返回错误。这样辅助函数有机会先执行 defer,再由 main 输出到 os.Stderr 并用 os.Exit(1) 表示失败。不要在需要依赖当前函数 defer 的位置直接 os.Exit,因为它不会执行 defer。

编译并运行

在仓库根目录执行:

New-Item -ItemType Directory -Force bin/17_files | Out-Null
go build -o ./bin/17_files/files.exe ./lessons/17_files
.\bin\17_files\files.exe

修改源码后需要重新编译。快速编译并运行:

go run ./lessons/17_files

运行后打开终端打印的“示例目录”,查看 notes.txt。目录路径每次不同,其余主要输出如下:

文件名:notes.txt,扩展名:.txt
=== 整体读取 ===
第17课:文件读写
复习 defer
=== 追加后逐行读取 ===
第 1 行:第17课:文件读写
第 2 行:复习 defer
第 3 行:学习逐行读取
文件名:notes.txt,是否目录:false,行数:3

测试

在仓库根目录执行:

go test -count=1 -v ./lessons/17_files

测试使用 t.TempDir 创建隔离目录,测试结束后由框架清理,避免固定文件名冲突或污染项目。它与普通程序刻意保留的演示目录是两回事。

覆盖场景:不存在文件的追加创建、两次追加、中文文本、空文件和缺失文件错误。

动手练习

  • 修改初始文本,查看整体读取和逐行读取的区别。
  • 多调用一次 appendLine,核对最终行数。
  • 输出 info.Size,比较字节数与中文字数。
  • 调用 os.Getwd,确认当前工作目录。
  • 只在本例自己的临时文件上再次调用 WriteFile,验证旧内容被覆盖。
  • 为“一行内容没有结尾换行符”的文件增加测试,观察 Scanner 是否能读取。

不要把覆盖或删除练习指向自己的重要资料;任何删除前先核对完整绝对路径。若另行手敲,放到本课的 practice 子目录,用 go run ./lessons/17_files/practice 运行,不要在同一个包中重复声明 main 或同名函数。

下一课学习 JSON 编码与解码,把结构体保存为易交换的文本数据。

上一课:接口 · 学习总览