Przeglądaj źródła

feat: 新增第十七课文件读写例程与隔离测试

zhuts 3 dni temu
rodzic
commit
881d292752
4 zmienionych plików z 323 dodań i 4 usunięć
  1. 9 4
      README.md
  2. 160 0
      lessons/17_files/README.md
  3. 101 0
      lessons/17_files/main.go
  4. 53 0
      lessons/17_files/main_test.go

+ 9 - 4
README.md

@@ -62,10 +62,14 @@
     │   └── score/
     │       ├── score.go    # 成绩转换与校验包
     │       └── score_test.go # 外部测试包
-    └── 16_interfaces/      # 第 16 课:接口
-        ├── README.md       # 隐式实现、方法集、nil 与测试替身
-        ├── main.go         # 不同类型通过统一接口自我介绍
-        └── main_test.go    # 接口实现及测试替身验证
+    ├── 16_interfaces/      # 第 16 课:接口
+    │   ├── README.md       # 隐式实现、方法集、nil 与测试替身
+    │   ├── main.go         # 不同类型通过统一接口自我介绍
+    │   └── main_test.go    # 接口实现及测试替身验证
+    └── 17_files/           # 第 17 课:文件读写
+        ├── README.md       # 路径、读写、追加、关闭与逐行扫描
+        ├── main.go         # 在独立临时目录操作学习记录
+        └── main_test.go    # 使用临时目录隔离的文件测试
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -90,6 +94,7 @@
 | 14 | [单元测试专题](./lessons/14_testing/README.md) | testing.T、表驱动、t.Run、错误断言、测试命令 | 已建立 |
 | 15 | [包与模块](./lessons/15_packages/README.md) | package、import、module、导出、跨包测试 | 已建立 |
 | 16 | [接口](./lessons/16_interfaces/README.md) | 隐式实现、接口参数、方法集、nil、测试替身 | 已建立 |
+| 17 | [文件读写](./lessons/17_files/README.md) | filepath、整体读写、追加、Scanner、Close | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 160 - 0
lessons/17_files/README.md

@@ -0,0 +1,160 @@
+# 第 17 课:文件读写与路径操作
+
+## 学习顺序
+
+先阅读 [main.go](./main.go) 中 run 的流程,再分别理解 appendLine 和 readLines,最后运行 [main_test.go](./main_test.go)。本课共享根目录的 go.mod,仅用标准库,不安装第三方依赖。
+
+本例每次在系统临时目录下新建独立的 `go-lesson17-...` 文件夹,只操作自己创建的 notes.txt,不覆盖仓库或用户已有文件。**运行结束保留目录**,方便你按终端显示的路径查看。多次运行会产生多个目录,学习完可以人工确认路径后删除;示例不自动递归删除。
+
+## 1. 路径和当前工作目录
+
+```go
+path := filepath.Join(directory, "notes.txt")
+```
+
+filepath 处理当前操作系统的文件路径:
+
+- Join:拼接路径,使用系统适合的分隔符。
+- Base:取最后一段文件名。
+- Ext:取扩展名,例如 .txt。
+- Dir:取所在目录。
+- Abs:将相对路径转成绝对路径,返回路径和 error。
+
+相对路径是相对于**进程当前工作目录**,不一定是源码目录或 exe 所在目录。比如从仓库根目录运行时,`notes.txt` 默认指向根目录下的文件。需要确认时可调用 `os.Getwd()`。
+
+本课使用 os.MkdirTemp 返回的目录避免上述歧义。Join 只是拼路径,不创建目录,也不是防止不可信路径逃逸的安全校验;不要把用户任意输入直接作为敏感文件路径。
+
+## 2. 整体写入与读取
+
+```go
+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. 打开文件并追加
+
+```go
+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
+
+```go
+file, err := os.Open(path)
+if err != nil {
+    return nil, err
+}
+defer file.Close()
+```
+
+Open 默认以只读方式打开,成功后才登记关闭操作。不能在检查打开错误之前就 defer 一个可能无效的对象。
+
+这里是只读示例,简化为忽略关闭错误;写文件时不能照搬成“一律不检查关闭错误”。defer 在 readLines 返回前执行,不会等整个程序结束。
+
+```go
+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。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+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
+```
+
+修改源码后需要重新编译。快速编译并运行:
+
+```powershell
+go run ./lessons/17_files
+```
+
+运行后打开终端打印的“示例目录”,查看 notes.txt。目录路径每次不同,其余主要输出如下:
+
+```text
+文件名:notes.txt,扩展名:.txt
+=== 整体读取 ===
+第17课:文件读写
+复习 defer
+=== 追加后逐行读取 ===
+第 1 行:第17课:文件读写
+第 2 行:复习 defer
+第 3 行:学习逐行读取
+文件名:notes.txt,是否目录:false,行数:3
+```
+
+## 测试
+
+在仓库根目录执行:
+
+```powershell
+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 编码与解码,把结构体保存为易交换的文本数据。
+
+[上一课:接口](../16_interfaces/README.md) · [学习总览](../../README.md)

+ 101 - 0
lessons/17_files/main.go

@@ -0,0 +1,101 @@
+package main
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// appendLine 以追加方式写入一行,写完后关闭文件。
+func appendLine(path, text string) error {
+	// | 是按位或:把多个打开标志组合起来。
+	file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
+	if err != nil {
+		return err
+	}
+	_, writeErr := file.WriteString(text + "\n")
+	closeErr := file.Close() // 即使写入失败,也必须尝试关闭。
+	if writeErr != nil {
+		return writeErr
+	}
+	return closeErr
+}
+
+// readLines 使用 Scanner 逐行读取,并演示 defer 关闭只读文件。
+func readLines(path string) ([]string, error) {
+	file, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	// 本例只读文件,不依赖 Close 完成写入,因此简化为忽略关闭错误。
+	defer file.Close()
+
+	var lines []string
+	scanner := bufio.NewScanner(file)
+	for scanner.Scan() {
+		lines = append(lines, scanner.Text())
+	}
+	// Scan 返回 false,既可能是正常结束,也可能是读取失败。
+	if err := scanner.Err(); err != nil {
+		return nil, err
+	}
+	return lines, nil
+}
+
+func run() error {
+	// 1. 每次创建独立临时目录,不覆盖已有文件。
+	// 示例保留目录供你查看,不执行自动删除。
+	directory, err := os.MkdirTemp("", "go-lesson17-")
+	if err != nil {
+		return err
+	}
+	fmt.Println("示例目录(运行后保留):", directory)
+
+	// 2. Join 按当前系统规则拼接路径,避免手工拼反斜杠。
+	path := filepath.Join(directory, "notes.txt")
+	fmt.Printf("文件名:%s,扩展名:%s\n", filepath.Base(path), filepath.Ext(path))
+
+	// 3. WriteFile 写入全部内容;已有文件会被截断并覆盖,务必确认路径。
+	content := "第17课:文件读写\n复习 defer\n"
+	if err := os.WriteFile(path, []byte(content), 0600); err != nil {
+		return err
+	}
+
+	// 4. ReadFile 返回 []byte,转成 string 后按文本显示。
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+	fmt.Println("=== 整体读取 ===")
+	fmt.Print(string(data))
+
+	// 5. 追加不会清空已有内容。
+	if err := appendLine(path, "学习逐行读取"); err != nil {
+		return err
+	}
+	lines, err := readLines(path)
+	if err != nil {
+		return err
+	}
+	fmt.Println("=== 追加后逐行读取 ===")
+	for index, line := range lines {
+		fmt.Printf("第 %d 行:%s\n", index+1, line)
+	}
+
+	// 6. Stat 获取信息。Size 是字节数,不是中文字符数。
+	info, err := os.Stat(path)
+	if err != nil {
+		return err
+	}
+	fmt.Printf("文件名:%s,是否目录:%t,行数:%d\n", info.Name(), info.IsDir(), len(lines))
+	return nil
+}
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, "文件操作失败:", err)
+		// run 已返回,其内部的 defer 已执行;这里才退出进程。
+		os.Exit(1)
+	}
+}

+ 53 - 0
lessons/17_files/main_test.go

@@ -0,0 +1,53 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestAppendAndReadLines(t *testing.T) {
+	// TempDir 每次提供隔离目录,测试结束后由框架清理。
+	path := filepath.Join(t.TempDir(), "notes.txt")
+	if err := appendLine(path, "学习 Go"); err != nil {
+		t.Fatal(err)
+	}
+	if err := appendLine(path, "学习文件"); err != nil {
+		t.Fatal(err)
+	}
+	lines, err := readLines(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := []string{"学习 Go", "学习文件"}
+	if len(lines) != len(want) {
+		t.Fatalf("行数=%d,期望=%d", len(lines), len(want))
+	}
+	for index := range want {
+		if lines[index] != want[index] {
+			t.Errorf("第 %d 行=%q,期望=%q", index+1, lines[index], want[index])
+		}
+	}
+}
+
+func TestReadEmptyFile(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "empty.txt")
+	if err := os.WriteFile(path, nil, 0600); err != nil {
+		t.Fatal(err)
+	}
+	lines, err := readLines(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(lines) != 0 {
+		t.Fatalf("空文件行数=%d,期望=0", len(lines))
+	}
+}
+
+func TestReadMissingFile(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "missing.txt")
+	_, err := readLines(path)
+	if err == nil {
+		t.Fatal("读取不存在的文件应返回错误")
+	}
+}