zhuts 6a81ce683a feat: 新增第十八课JSON编码解码例程与测试 hace 1 día
..
README.md 6a81ce683a feat: 新增第十八课JSON编码解码例程与测试 hace 1 día
main.go 6a81ce683a feat: 新增第十八课JSON编码解码例程与测试 hace 1 día
main_test.go 6a81ce683a feat: 新增第十八课JSON编码解码例程与测试 hace 1 día

README.md

第 18 课:JSON 编码与解码

学习目标

将 Go 结构体编码为 JSON,把 JSON 解码回结构体,并结合上一课保存和读取文件。先阅读 main.go,运行成功后再看 main_test.go。本课仅使用 Go 标准库,无需安装第三方依赖。

例程每次创建独立的系统临时目录,将 JSON 写到其中的 record.json,运行后保留文件,终端会打印路径。它不会覆盖仓库中的资料。多次运行会产生多个目录,学习完可在核对完整路径后自行删除。

1. JSON 是什么

JSON 是一种常用的文本数据格式,适合存储和交换数据。例如:

{
  "title": "学习 Go JSON",
  "minutes": 30,
  "finished": true,
  "tags": ["Go", "JSON"]
}

对象使用 {},键用双引号;数组使用 []。字符串也要用双引号,不能把 Go 的反引号当成 JSON 的字符串标记。JSON 本身不允许注释或尾随逗号。

2. 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

data, err := json.Marshal(record)

Marshal 返回 []byte 和 error;数据类型不支持或出现不支持的值时可能失败,应检查错误。

本课使用:

data, err := json.MarshalIndent(record, "", "  ")

它输出带缩进的 JSON:第二个参数是每行前缀,第三个参数是每层缩进。想在终端查看,可用 string(data);写文件则直接传 data。

编码不是加密,也不会自动隐藏敏感信息。不要把密码或令牌写进需要共享的 JSON。

4. 解码:JSON → 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

return fmt.Errorf("读取记录:%w", err)

%w 在添加上下文的同时保留原始错误,供调用方用 errors.Is / errors.As 检查。本课先知道它与只格式化文本的 %v 不同;错误链的详细用法以后再学。

decodeRecord 只检查语法和字段类型,不检查 Minutes 是否为负数、Title 是否为空等业务约束;真实项目需要另外做输入验证,也要根据场景限制文件和请求大小。

6. 文件安全

示例顺序:创建新临时目录 → MarshalIndent → WriteFile → ReadFile → Unmarshal。WriteFile 遇到同路径已有文件会覆盖,因此不要把示例路径改成重要资料。

本例用 0600 指定新文件权限;实际权限受系统与已有文件状态影响,Windows 上需结合 ACL 考虑。写入到文件不自动保证断电安全,重要数据需要进一步考虑临时文件、同步和原子替换。

编译并运行

在仓库根目录执行:

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

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

go run ./lessons/18_json

也可以从根目录进入本课,再运行:

Set-Location lessons/18_json
go run .

预期输出

临时路径每次不同,其余主要内容:

=== 编码为 JSON ===
{
  "title": "学习 Go JSON",
  "minutes": 30,
  "finished": true,
  "tags": [
    "Go",
    "JSON"
  ]
}
记录文件(运行后保留): <每次生成的临时路径>

=== 读取并解码 ===
标题:学习 Go JSON,时长:30 分钟,完成:true,标签:[Go JSON]

=== 错误示例 ===
字段类型不匹配,解码失败

测试与练习

在仓库根目录执行:

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 或同名类型。

下一课学习命令行输入与参数,让程序接收用户提供的数据。

上一课:文件读写 · 学习总览