Эх сурвалжийг харах

feat: 新增第十八课JSON编码解码例程与测试

zhuts 1 өдөр өмнө
parent
commit
6a81ce683a

+ 9 - 4
README.md

@@ -66,10 +66,14 @@
     │   ├── README.md       # 隐式实现、方法集、nil 与测试替身
     │   ├── main.go         # 不同类型通过统一接口自我介绍
     │   └── main_test.go    # 接口实现及测试替身验证
-    └── 17_files/           # 第 17 课:文件读写
-        ├── README.md       # 路径、读写、追加、关闭与逐行扫描
-        ├── main.go         # 在独立临时目录操作学习记录
-        └── main_test.go    # 使用临时目录隔离的文件测试
+    ├── 17_files/           # 第 17 课:文件读写
+    │   ├── README.md       # 路径、读写、追加、关闭与逐行扫描
+    │   ├── main.go         # 在独立临时目录操作学习记录
+    │   └── main_test.go    # 使用临时目录隔离的文件测试
+    └── 18_json/            # 第 18 课:JSON 编码与解码
+        ├── README.md       # 标签、编码、解码和文件操作
+        ├── main.go         # 学习记录的 JSON 往返示例
+        └── main_test.go    # 往返、字段省略和非法输入测试
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -95,6 +99,7 @@
 | 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 | 已建立 |
+| 18 | [JSON 编码与解码](./lessons/18_json/README.md) | 结构体标签、Marshal、Unmarshal、文件往返 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 163 - 0
lessons/18_json/README.md

@@ -0,0 +1,163 @@
+# 第 18 课:JSON 编码与解码
+
+## 学习目标
+
+将 Go 结构体编码为 JSON,把 JSON 解码回结构体,并结合上一课保存和读取文件。先阅读 [main.go](./main.go),运行成功后再看 [main_test.go](./main_test.go)。本课仅使用 Go 标准库,无需安装第三方依赖。
+
+例程每次创建独立的系统临时目录,将 JSON 写到其中的 `record.json`,**运行后保留文件**,终端会打印路径。它不会覆盖仓库中的资料。多次运行会产生多个目录,学习完可在核对完整路径后自行删除。
+
+## 1. JSON 是什么
+
+JSON 是一种常用的文本数据格式,适合存储和交换数据。例如:
+
+```json
+{
+  "title": "学习 Go JSON",
+  "minutes": 30,
+  "finished": true,
+  "tags": ["Go", "JSON"]
+}
+```
+
+对象使用 `{}`,键用双引号;数组使用 `[]`。字符串也要用双引号,不能把 Go 的反引号当成 JSON 的字符串标记。JSON 本身不允许注释或尾随逗号。
+
+## 2. Go 结构体与标签
+
+```go
+type StudyRecord struct {
+    Title    string   `json:"title"`
+    Minutes  int      `json:"minutes"`
+    Finished bool     `json:"finished"`
+    Tags     []string `json:"tags"`
+    Note     string   `json:"note,omitempty"`
+}
+```
+
+- 大写开头的字段可以导出,encoding/json 才能访问;小写字段不会作为普通 JSON 字段编码或解码。
+- 反引号内的是**结构体标签**,`json:"title"` 指定 JSON 中使用小写的 `title` 键,Go 代码中仍写 `record.Title`。
+- `omitempty` 表示**编码时**该字段为空值就省略。这里 Note 为 `""` 时省略;它不代表解码时这个字段必填或做校验。
+- 本例的 omitempty 是 encoding/json 对指定类型的空值规则,不能笼统理解为“所有类型的零值一定都会省略”。
+- 编码时空的非 nil 切片是 `[]`,nil 切片是 `null`;本课 Tags 有明确元素。
+
+## 3. 编码:Go 值 → JSON
+
+```go
+data, err := json.Marshal(record)
+```
+
+`Marshal` 返回 `[]byte` 和 `error`;数据类型不支持或出现不支持的值时可能失败,应检查错误。
+
+本课使用:
+
+```go
+data, err := json.MarshalIndent(record, "", "  ")
+```
+
+它输出带缩进的 JSON:第二个参数是每行前缀,第三个参数是每层缩进。想在终端查看,可用 `string(data)`;写文件则直接传 data。
+
+编码不是加密,也不会自动隐藏敏感信息。不要把密码或令牌写进需要共享的 JSON。
+
+## 4. 解码:JSON → Go 值
+
+```go
+var record StudyRecord
+err := json.Unmarshal(data, &record)
+```
+
+第二个参数传 `&record`,让 Unmarshal 修改这份结构体。传值而不是可用指针会返回错误。
+
+- JSON 语法错误、字段类型不匹配等会返回 error;**出现错误时不要当作完整成功结果使用**。
+- `decodeRecord` 在出错时返回零值和 error,避免调用方误用部分解码结果。
+- 默认情况下未知字段会被忽略;缺失字段在新建的零值结构体中保持零值。这不等于验证了必填项或业务规则。
+- 如果复用已有的非零结构体,JSON 中缺失的字段可能保留旧值;本例每次创建新变量再解码。
+- JSON `null` 解码到非指针整数、字符串等字段时通常不改变该字段且不报错;如需区分缺失、null 和零值,要另行设计。
+
+需要严格拒绝未知字段时,可用 `json.Decoder` 的 `DisallowUnknownFields`;若还要求只接受一个 JSON 值,需要进一步检查输入末尾。本课先掌握基本操作。
+
+## 5. 错误包装 `%w`
+
+```go
+return fmt.Errorf("读取记录:%w", err)
+```
+
+`%w` 在添加上下文的同时保留原始错误,供调用方用 `errors.Is` / `errors.As` 检查。本课先知道它与只格式化文本的 `%v` 不同;错误链的详细用法以后再学。
+
+`decodeRecord` 只检查语法和字段类型,不检查 Minutes 是否为负数、Title 是否为空等业务约束;真实项目需要另外做输入验证,也要根据场景限制文件和请求大小。
+
+## 6. 文件安全
+
+示例顺序:创建新临时目录 → MarshalIndent → WriteFile → ReadFile → Unmarshal。`WriteFile` 遇到同路径已有文件会覆盖,因此不要把示例路径改成重要资料。
+
+本例用 `0600` 指定新文件权限;实际权限受系统与已有文件状态影响,Windows 上需结合 ACL 考虑。写入到文件不自动保证断电安全,重要数据需要进一步考虑临时文件、同步和原子替换。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/18_json | Out-Null
+go build -o ./bin/18_json/json_demo.exe ./lessons/18_json
+.\bin\18_json\json_demo.exe
+```
+
+修改源码后需要重新编译。快速编译并运行:
+
+```powershell
+go run ./lessons/18_json
+```
+
+也可以从根目录进入本课,再运行:
+
+```powershell
+Set-Location lessons/18_json
+go run .
+```
+
+## 预期输出
+
+临时路径每次不同,其余主要内容:
+
+```text
+=== 编码为 JSON ===
+{
+  "title": "学习 Go JSON",
+  "minutes": 30,
+  "finished": true,
+  "tags": [
+    "Go",
+    "JSON"
+  ]
+}
+记录文件(运行后保留): <每次生成的临时路径>
+
+=== 读取并解码 ===
+标题:学习 Go JSON,时长:30 分钟,完成:true,标签:[Go JSON]
+
+=== 错误示例 ===
+字段类型不匹配,解码失败
+```
+
+## 测试与练习
+
+在仓库根目录执行:
+
+```powershell
+go test -count=1 -v ./lessons/18_json
+```
+
+测试覆盖结构体→JSON→临时文件→结构体的往返、空 Note 的省略、语法错误和类型错误。`t.TempDir()` 的目录会由测试框架清理,与普通程序保留的演示目录不同。
+
+测试使用 `reflect.DeepEqual` 比较含切片的结构体:切片不能直接用 `==`,所以该结构体也不能直接用 `==`。本课暂把它当作比较整体内容的工具,反射原理留待后续;DeepEqual 会区分 nil 切片与非 nil 空切片。
+
+- [ ] 给 Note 填入文字,观察 JSON 中出现 `note`。
+- [ ] 修改 Tags 内容,再解码检查。
+- [ ] 在 JSON 里加一个未知字段,观察默认是否报错。
+- [ ] 缺少 minutes 时观察新结构体的值;再思考这是否代表输入合法。
+- [ ] 增加一个 `Minutes < 0` 的业务校验,并为其添加测试。
+- [ ] 把格式化编码改成 `json.Marshal`,比较输出,不依赖 JSON 对象键顺序判断语义。
+
+若另行手敲,放到本课的 practice 子目录,用 `go run ./lessons/18_json/practice` 运行,不要在同一个包中重复声明 main 或同名类型。
+
+下一课学习命令行输入与参数,让程序接收用户提供的数据。
+
+[上一课:文件读写](../17_files/README.md) · [学习总览](../../README.md)

+ 90 - 0
lessons/18_json/main.go

@@ -0,0 +1,90 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// 字段需要导出(大写开头)才能由 encoding/json 读写。
+// 反引号中的内容是结构体标签,不是字段的值。
+type StudyRecord struct {
+	Title    string   `json:"title"`
+	Minutes  int      `json:"minutes"`
+	Finished bool     `json:"finished"`
+	Tags     []string `json:"tags"`
+	Note     string   `json:"note,omitempty"`
+}
+
+// encodeRecord 将结构体转换为缩进后的 JSON 字节。
+func encodeRecord(record StudyRecord) ([]byte, error) {
+	return json.MarshalIndent(record, "", "  ")
+}
+
+// decodeRecord 将 JSON 字节解析到新结构体。
+// 本例只检查 JSON 语法和字段类型,不验证学习时长等业务规则。
+func decodeRecord(data []byte) (StudyRecord, error) {
+	var record StudyRecord
+	if err := json.Unmarshal(data, &record); err != nil {
+		return StudyRecord{}, err
+	}
+	return record, nil
+}
+
+func run() error {
+	record := StudyRecord{
+		Title:    "学习 Go JSON",
+		Minutes:  30,
+		Finished: true,
+		Tags:     []string{"Go", "JSON"},
+		// Note 为空字符串,会因 omitempty 在编码时省略。
+	}
+
+	// 1. 编码:Go 值 -> JSON 字节。
+	data, err := encodeRecord(record)
+	if err != nil {
+		return fmt.Errorf("编码记录:%w", err)
+	}
+	fmt.Println("=== 编码为 JSON ===")
+	fmt.Println(string(data))
+
+	// 2. 每次创建独立临时目录,不覆盖已有文件;运行结束保留供查看。
+	directory, err := os.MkdirTemp("", "go-lesson18-")
+	if err != nil {
+		return fmt.Errorf("创建临时目录:%w", err)
+	}
+	path := filepath.Join(directory, "record.json")
+	// WriteFile 会覆盖已有文件;此处使用新建目录中的文件。
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		return fmt.Errorf("保存记录:%w", err)
+	}
+	fmt.Println("记录文件(运行后保留):", path)
+
+	// 3. 读取文件,解码:JSON 字节 -> Go 值。
+	saved, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("读取记录:%w", err)
+	}
+	decoded, err := decodeRecord(saved)
+	if err != nil {
+		return fmt.Errorf("解码记录:%w", err)
+	}
+	fmt.Println("\n=== 读取并解码 ===")
+	fmt.Printf("标题:%s,时长:%d 分钟,完成:%t,标签:%v\n", decoded.Title, decoded.Minutes, decoded.Finished, decoded.Tags)
+
+	// 4. 非法 JSON 会返回错误;这里只演示,不让它中断整个程序。
+	_, err = decodeRecord([]byte(`{"minutes": "不是数字"}`))
+	if err != nil {
+		fmt.Println("\n=== 错误示例 ===")
+		fmt.Println("字段类型不匹配,解码失败")
+	}
+	return nil
+}
+
+func main() {
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, "程序失败:", err)
+		os.Exit(1)
+	}
+}

+ 69 - 0
lessons/18_json/main_test.go

@@ -0,0 +1,69 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+)
+
+func TestRecordRoundTrip(t *testing.T) {
+	want := StudyRecord{Title: "学习 Go", Minutes: 30, Finished: true, Tags: []string{"Go", "JSON"}, Note: "复习"}
+	data, err := encodeRecord(want)
+	if err != nil {
+		t.Fatalf("编码失败:%v", err)
+	}
+
+	// 测试使用隔离目录,结束后由测试框架清理。
+	path := filepath.Join(t.TempDir(), "record.json")
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		t.Fatalf("写文件失败:%v", err)
+	}
+	saved, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("读文件失败:%v", err)
+	}
+	got, err := decodeRecord(saved)
+	if err != nil {
+		t.Fatalf("解码失败:%v", err)
+	}
+	// 结构体含切片,不能直接用 == 比较;这里用 DeepEqual 检查内容。
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("往返结果=%+v,期望=%+v", got, want)
+	}
+}
+
+func TestOmitEmptyNote(t *testing.T) {
+	data, err := encodeRecord(StudyRecord{Title: "练习"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	var fields map[string]json.RawMessage
+	if err := json.Unmarshal(data, &fields); err != nil {
+		t.Fatal(err)
+	}
+	if _, ok := fields["note"]; ok {
+		t.Error("空 note 应当在编码时被省略")
+	}
+	if _, ok := fields["title"]; !ok {
+		t.Error("应包含 title 字段")
+	}
+}
+
+func TestDecodeInvalid(t *testing.T) {
+	tests := []struct {
+		name string
+		data string
+	}{
+		{name: "malformed", data: `{"title":`},
+		{name: "wrong_type", data: `{"minutes":"三十"}`},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if _, err := decodeRecord([]byte(tt.data)); err == nil {
+				t.Error("非法输入应返回错误")
+			}
+		})
+	}
+}