本文是「从 C++ 到 Go」系列的第六篇,承接上一篇对泛型的讨论,进入 Go 标准库中最核心的一块领域——IO 与数据序列化。C++ 程序员对 iostream 的复杂继承体系和 nlohmann/json 的手动映射不会陌生;Go 用两个极简接口和声明式的 struct tag 取代了这一切。本篇覆盖 io.Reader/io.Writer 接口、文件读写、JSON 序列化,以及用标准库写 HTTP 服务。
io.Reader 与 io.Writer
Go 标准库的 IO 设计围绕两个接口:
1 | type Reader interface { |
任何类型只要实现了这一个方法,就能参与 IO 管道。满足 Reader 的类型极多:os.File、strings.Reader、bytes.Buffer、http.Response.Body、gzip.Reader……Writer 也一样:os.File、bytes.Buffer、http.ResponseWriter、gzip.Writer……
对比 C++:iostream 体系也是类似思路,但基于类继承,层次深且复杂(std::ios_base → std::basic_ios → std::basic_istream → ……)。Go 的接口是隐式实现的——不需要声明「我实现了 io.Reader」,只要有 Read 方法就行。
这意味着你可以写一个函数接受 io.Reader,它同时能处理文件、网络流、内存缓冲区、压缩流——调用方决定数据从哪来,函数本身不关心:
1 | func countLines(r io.Reader) (int, error) { |
传文件进来就是读文件,传字符串进来就是读字符串:
1 | // 用字符串当 IO 源——测试时特别有用,不需要创建真实文件 |
strings.NewReader 把一个 string 包装成 io.Reader。这在单元测试中特别好用——不需要创建临时文件就能测试所有读取逻辑。
io.Copy
io.Copy(dst Writer, src Reader) 从 src 读取所有数据写入 dst,是 Go IO 管道的核心操作——类似 Unix 的管道 cat file | cmd:
1 | // 字符串 → 标准输出 |
os.Stdout 也实现了 io.Writer——所以它能作为 io.Copy 的目标。这种「一切皆接口」的设计让组合变得极其自然。
文件读写
Go 的文件操作围绕 os.File 展开,它同时实现了 io.Reader 和 io.Writer。
1 | // --- 写文件 --- |
对比 C++:C++ 用 std::ifstream / std::ofstream,打开模式通过 std::ios::in / std::ios::out 等标志控制。Go 用 os.Open(只读)/ os.Create(创建或截断)/ os.OpenFile(完整控制),API 更扁平。
defer f.Close() 与 RAII
资源清理是 C++ 程序员最关心的问题之一。C++ 有 RAII——ifstream 离开作用域自动关闭,不需要写任何清理代码。Go 没有析构函数,必须手动 defer f.Close():
1 | f, err := os.Open("data.txt") |
defer 保证函数返回前执行,但它是需要显式写出的——忘了写就会泄漏资源。Go 没有宏,没有 RAII,也没有语法糖能进一步简化这个模式。这是 Go「显式优于隐式」哲学的体现:每个资源的获取和释放都在代码中清晰可见,但代价是多写一行 defer。对于习惯了 RAII 的 C++ 程序员来说,这可能是最需要适应的地方。
JSON
Go 的 encoding/json 包通过 struct tag 来控制 JSON 字段映射。
序列化与反序列化
1 | type Book struct { |
struct tag 是写在字段声明后面的反引号字符串。对比 C++:C++ 没有内置的 JSON 支持,通常用 nlohmann/json 等第三方库,需要手写 to_json / from_json 函数或宏来映射字段。Go 的 struct tag 是声明式的——在类型定义里写一次,序列化和反序列化自动处理。
序列化用 json.Marshal,反序列化用 json.Unmarshal:
1 | // 序列化:struct → JSON |
反序列化需要传指针:
1 | jsonStr := `{"title":"Concurrency in Go","author":"Cox-Buday","price":45.5}` |
「Marshal」这个词来自通信领域,原意是「将数据按照协议格式编排好,准备传输」,比「serialize」更强调「为传输做准备」的语义。Go、Java(json.Marshal / json.Unmarshal)和一些 RPC 框架(Protocol Buffers)都沿用了这个术语。
序列化何时失败
json.Marshal 在大多数情况下都会成功,但有几种常见的失败场景:
- 循环引用:结构体中存在指针环(A 指向 B,B 指向 A),会导致无限递归,触发
json: unsupported value: encountered a cycle。 - 不可序列化的类型:
chan、func、complex64/complex128等类型无法表示为 JSON。 NaN和Inf:JSON 标准不支持NaN和正负无穷,json.Marshal会返回错误。
反序列化的宽容性
JSON 中存在但结构体中没有定义的字段会被静默忽略——不会报错。这在 API 演进时很有用:服务端新增字段不会导致旧版本的客户端解析失败。反过来,结构体中有但 JSON 中没有的字段保持零值。
复杂结构的 JSON
实际项目中的 JSON 往往包含嵌套结构、切片和 map。Go 的 encoding/json 能自然地处理这些情况:
1 | type Role struct { |
序列化出来的 JSON 结构和 Go 的结构体层次一一对应:
1 | { |
注意 Manager 字段必须是指针(*User)——因为 User 嵌套 User 会导致无限递归的类型定义,指针打破了这个循环。反序列化时,如果 JSON 中没有 manager 字段,指针保持 nil。
动态 JSON 与 map[string]any
当 JSON 结构不确定时,可以用 map[string]any 接收:
1 | dynamicJSON := `{"name":"Go","year":2009,"compiled":true}` |
一个重要的陷阱:JSON 的数字统一被解析为 float64,即使原始值是整数。这是因为 JSON 规范不区分整数和浮点数——所有数字都是 number 类型。如果需要 int,要手动转换:int(dynamic["year"].(float64))。
大整数的精度问题
float64 只有 53 位有效数字(IEEE 754),超过 2^53 的整数会丢失精度。如果你的 JSON 包含大整数(比如雪花 ID、纳秒时间戳),用 map[string]any 接收就会出问题。解决方案是用 json.Decoder 配合 UseNumber():
1 | decoder := json.NewDecoder(strings.NewReader(jsonStr)) |
用良定义的结构体接收就不存在这个问题——int64 字段会被正确解析为整数,不经过 float64 中转。所以 map[string]any 适合的场景通常是:接收结构完全未知的外部 JSON(如第三方 API 的透传字段),或者做格式探测和预处理。能定义结构体的场景,优先用结构体。
处理不规范的 JSON
当 JSON 来自大语言模型等不太规范的来源时,常常会遇到问题:被包裹在 Markdown 代码块里、尾部多余的逗号、字段名没有加引号、包含注释……Go 标准库的 json.Unmarshal 对格式极其严格,这些全部会报错。
最简单的预处理是剥离 Markdown 代码块标记:
1 | func sanitizeModelJSON(raw string) string { |
但靠字符串替换应对更复杂的语法错误(缺少引号、包含注释)是不靠谱的。Go 标准库没有提供宽松模式(如 AllowTrailingCommas)。如果需要处理严重不合法的 JSON 或 JSON5 变体,业界的做法是引入第三方库,比如支持注释、尾逗号和无引号 key 的 JSON5 解析器。
JSON 与 io.Writer 的结合
json.NewEncoder 接受 io.Writer,可以直接将 JSON 写入文件、网络连接或标准输出——不需要先序列化成 []byte 再手动写入:
1 | encoder := json.NewEncoder(os.Stdout) |
这在 HTTP handler 中特别实用,因为 http.ResponseWriter 也是 io.Writer。
HTTP 服务
Go 标准库的 net/http 可以直接写生产级的 HTTP 服务,不需要第三方框架。这是 Go 在服务端领域的杀手锏。
1 | func handleHello(w http.ResponseWriter, r *http.Request) { |
Go 的 HTTP 处理函数签名固定为 func(w http.ResponseWriter, r *http.Request)。w 实现了 io.Writer——所以之前学的 fmt.Fprintf、json.NewEncoder 都可以直接往 HTTP 响应里写。r.Body 是 io.Reader——用 io.ReadAll 或 json.NewDecoder 读取请求体。一切都回到了 Reader / Writer 这两个核心接口。
对比 C++:用 Boost.Beast 写同样的功能大约需要 100+ 行,还需要手动处理 TCP 连接、HTTP 解析、响应构建。Go 的 net/http 把这些全封装好了。
读取请求体
1 | func handleEcho(w http.ResponseWriter, r *http.Request) { |
如果想让 echo 更智能——当请求体是 JSON 时按 JSON 结构嵌入响应,否则作为字符串回显——可以检查 Content-Type 并尝试解析:
1 | contentType := r.Header.Get("Content-Type") |
注册路由与启动
1 | http.HandleFunc("/", handleRoot) |
ListenAndServe 阻塞运行,内部为每个连接启动一个 goroutine——handler 天然是并发执行的。如果 handler 访问共享状态,需要加锁或用 channel,这就回到了第三篇讨论的并发知识。
小结
Go 标准库的 IO 设计围绕 io.Reader 和 io.Writer 两个接口展开,这是整个标准库最核心的抽象。文件、网络、内存缓冲区、压缩流、HTTP 请求和响应——全部通过这两个接口统一起来。函数接受 io.Reader 参数,就自动获得了处理一切数据源的能力;函数接受 io.Writer 参数,就能往一切目标写数据。这种组合能力来自接口的隐式实现——不需要继承、不需要适配器,只要方法签名匹配就行。
C++ 的 iostream 试图做同样的事,但基于类继承的设计让它变得笨重:层次深、虚函数开销、格式化状态全局共享。Go 的接口没有这些包袱。JSON 处理也是类似的对比——C++ 需要第三方库和手动映射,Go 用 struct tag 声明式地完成。HTTP 服务更是如此——Go 标准库开箱即用的 net/http 对应着 C++ 需要 Boost.Beast 或其他库才能做到的事。
没有 RAII 是 Go 程序员需要付出的代价——每个资源都要显式 defer Close()。这不如 C++ 的析构函数优雅,但保持了「每一行代码都能被直接读懂」的透明性。Go 的设计哲学始终如此:用少量正交的概念(接口、defer、struct tag)覆盖大量场景,而不是为每个场景设计专用的语法。