0%

从 C++ 到 Go(六):IO 与标准库

本文是「从 C++ 到 Go」系列的第六篇,承接上一篇对泛型的讨论,进入 Go 标准库中最核心的一块领域——IO 与数据序列化。C++ 程序员对 iostream 的复杂继承体系和 nlohmann/json 的手动映射不会陌生;Go 用两个极简接口和声明式的 struct tag 取代了这一切。本篇覆盖 io.Reader/io.Writer 接口、文件读写、JSON 序列化,以及用标准库写 HTTP 服务。

io.Readerio.Writer

Go 标准库的 IO 设计围绕两个接口:

1
2
3
4
5
6
7
type Reader interface {
Read(p []byte) (n int, err error)
}

type Writer interface {
Write(p []byte) (n int, err error)
}

任何类型只要实现了这一个方法,就能参与 IO 管道。满足 Reader 的类型极多:os.Filestrings.Readerbytes.Bufferhttp.Response.Bodygzip.Reader……Writer 也一样:os.Filebytes.Bufferhttp.ResponseWritergzip.Writer……

对比 C++:iostream 体系也是类似思路,但基于类继承,层次深且复杂(std::ios_basestd::basic_iosstd::basic_istream → ……)。Go 的接口是隐式实现的——不需要声明「我实现了 io.Reader」,只要有 Read 方法就行。

这意味着你可以写一个函数接受 io.Reader,它同时能处理文件、网络流、内存缓冲区、压缩流——调用方决定数据从哪来,函数本身不关心:

1
2
3
4
5
6
7
8
func countLines(r io.Reader) (int, error) {
scanner := bufio.NewScanner(r)
count := 0
for scanner.Scan() {
count++
}
return count, scanner.Err()
}

传文件进来就是读文件,传字符串进来就是读字符串:

1
2
3
4
5
6
7
8
// 用字符串当 IO 源——测试时特别有用,不需要创建真实文件
r := strings.NewReader("line 1\nline 2\nline 3\n")
lines, _ := countLines(r) // 3

// 用文件当 IO 源
f, _ := os.Open("data.txt")
defer f.Close()
lines, _ = countLines(f)

strings.NewReader 把一个 string 包装成 io.Reader。这在单元测试中特别好用——不需要创建临时文件就能测试所有读取逻辑。

io.Copy

io.Copy(dst Writer, src Reader)src 读取所有数据写入 dst,是 Go IO 管道的核心操作——类似 Unix 的管道 cat file | cmd

1
2
3
4
5
6
7
// 字符串 → 标准输出
src := strings.NewReader("Hello from io.Copy!\n")
io.Copy(os.Stdout, src)

// 还可以:
// 文件 → 标准输出:io.Copy(os.Stdout, file)
// 网络 → 文件: io.Copy(file, response.Body)

os.Stdout 也实现了 io.Writer——所以它能作为 io.Copy 的目标。这种「一切皆接口」的设计让组合变得极其自然。

文件读写

Go 的文件操作围绕 os.File 展开,它同时实现了 io.Readerio.Writer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// --- 写文件 ---
// os.Create:文件已存在则截断为空,不存在则创建
f, err := os.Create("demo.txt")
if err != nil {
fmt.Println("创建文件失败:", err)
return
}
defer f.Close()

// f 实现了 io.Writer,可以直接用 fmt.Fprintln 写入
fmt.Fprintln(f, "第一行:Hello, Go!")
fmt.Fprintln(f, "第二行:文件 IO 演示")

// --- 读取整个文件 ---
// os.ReadFile:一次读完,返回 []byte。适合小文件。
data, err := os.ReadFile("demo.txt")
fmt.Println(string(data))

// --- 流式读取 ---
// 大文件应该用 bufio.Scanner 逐行处理,而不是一次性加载到内存
f2, _ := os.Open("demo.txt") // os.Open = 只读模式
defer f2.Close()
lines, _ := countLines(f2) // f2 满足 io.Reader

对比 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
2
3
4
5
f, err := os.Open("data.txt")
if err != nil {
return err
}
defer f.Close() // 不管后续如何 return,文件都会被关闭

defer 保证函数返回前执行,但它是需要显式写出的——忘了写就会泄漏资源。Go 没有宏,没有 RAII,也没有语法糖能进一步简化这个模式。这是 Go「显式优于隐式」哲学的体现:每个资源的获取和释放都在代码中清晰可见,但代价是多写一行 defer。对于习惯了 RAII 的 C++ 程序员来说,这可能是最需要适应的地方。

JSON

Go 的 encoding/json 包通过 struct tag 来控制 JSON 字段映射。

序列化与反序列化

1
2
3
4
5
6
7
type Book struct {
Title string `json:"title"` // JSON 字段名为 "title"
Author string `json:"author"`
Price float64 `json:"price"`
ISBN string `json:"isbn,omitempty"` // omitempty:空值时省略该字段
Notes string `json:"-"` // "-":永远不序列化
}

struct tag 是写在字段声明后面的反引号字符串。对比 C++:C++ 没有内置的 JSON 支持,通常用 nlohmann/json 等第三方库,需要手写 to_json / from_json 函数或宏来映射字段。Go 的 struct tag 是声明式的——在类型定义里写一次,序列化和反序列化自动处理。

序列化用 json.Marshal,反序列化用 json.Unmarshal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 序列化:struct → JSON
book := Book{
Title: "The Go Programming Language",
Author: "Donovan & Kernighan",
Price: 79.0,
ISBN: "978-0134190440",
Notes: "内部备注,不会出现在 JSON 中",
}
jsonBytes, _ := json.Marshal(book)
// {"title":"The Go Programming Language","author":"Donovan & Kernighan","price":79,"isbn":"978-0134190440"}

// MarshalIndent:带缩进的格式化输出,适合调试
prettyJSON, _ := json.MarshalIndent(book, "", " ")

// omitempty 效果:ISBN 为空时不出现在 JSON 中
book2 := Book{Title: "Go in Action", Author: "Kennedy", Price: 55.0}
json.Marshal(book2)
// {"title":"Go in Action","author":"Kennedy","price":55}

反序列化需要传指针:

1
2
3
jsonStr := `{"title":"Concurrency in Go","author":"Cox-Buday","price":45.5}`
var parsed Book
err := json.Unmarshal([]byte(jsonStr), &parsed)

「Marshal」这个词来自通信领域,原意是「将数据按照协议格式编排好,准备传输」,比「serialize」更强调「为传输做准备」的语义。Go、Java(json.Marshal / json.Unmarshal)和一些 RPC 框架(Protocol Buffers)都沿用了这个术语。

序列化何时失败

json.Marshal 在大多数情况下都会成功,但有几种常见的失败场景:

  • 循环引用:结构体中存在指针环(A 指向 B,B 指向 A),会导致无限递归,触发 json: unsupported value: encountered a cycle
  • 不可序列化的类型chanfunccomplex64/complex128 等类型无法表示为 JSON。
  • NaNInf:JSON 标准不支持 NaN 和正负无穷,json.Marshal 会返回错误。

反序列化的宽容性

JSON 中存在但结构体中没有定义的字段会被静默忽略——不会报错。这在 API 演进时很有用:服务端新增字段不会导致旧版本的客户端解析失败。反过来,结构体中有但 JSON 中没有的字段保持零值。

复杂结构的 JSON

实际项目中的 JSON 往往包含嵌套结构、切片和 map。Go 的 encoding/json 能自然地处理这些情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Role struct {
Name string `json:"name"`
Permissions []string `json:"permissions"`
}

type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsActive bool `json:"is_active"`
Roles []Role `json:"roles"` // 嵌套的结构体切片
Metadata map[string]string `json:"metadata,omitempty"` // map 类型
Manager *User `json:"manager,omitempty"` // 递归嵌套(必须是指针)
Tags []string `json:"tags,omitempty"`
}

序列化出来的 JSON 结构和 Go 的结构体层次一一对应:

1
2
3
4
5
6
7
8
9
10
11
12
{
"id": "U002",
"username": "liam",
"is_active": true,
"roles": [
{"name": "admin", "permissions": ["read", "write", "delete"]},
{"name": "user", "permissions": ["read"]}
],
"metadata": {"department": "engineering", "location": "shanghai"},
"manager": {"id": "U001", "username": "boss", "is_active": true},
"tags": ["golang", "cpp"]
}

注意 Manager 字段必须是指针(*User)——因为 User 嵌套 User 会导致无限递归的类型定义,指针打破了这个循环。反序列化时,如果 JSON 中没有 manager 字段,指针保持 nil

动态 JSON 与 map[string]any

当 JSON 结构不确定时,可以用 map[string]any 接收:

1
2
3
4
5
6
7
8
9
10
dynamicJSON := `{"name":"Go","year":2009,"compiled":true}`
var dynamic map[string]any
json.Unmarshal([]byte(dynamicJSON), &dynamic)

for k, v := range dynamic {
fmt.Printf("%s: %v (%T)\n", k, v, v)
}
// name: Go (string)
// year: 2009 (float64) ← 注意:不是 int!
// compiled: true (bool)

一个重要的陷阱:JSON 的数字统一被解析为 float64,即使原始值是整数。这是因为 JSON 规范不区分整数和浮点数——所有数字都是 number 类型。如果需要 int,要手动转换:int(dynamic["year"].(float64))

大整数的精度问题

float64 只有 53 位有效数字(IEEE 754),超过 2^53 的整数会丢失精度。如果你的 JSON 包含大整数(比如雪花 ID、纳秒时间戳),用 map[string]any 接收就会出问题。解决方案是用 json.Decoder 配合 UseNumber()

1
2
3
4
5
6
7
decoder := json.NewDecoder(strings.NewReader(jsonStr))
decoder.UseNumber()
decoder.Decode(&dynamic)

// 此时数字类型是 json.Number(本质是字符串),不会丢失精度
// 需要时再手动转换:
n, _ := dynamic["big_id"].(json.Number).Int64()

用良定义的结构体接收就不存在这个问题——int64 字段会被正确解析为整数,不经过 float64 中转。所以 map[string]any 适合的场景通常是:接收结构完全未知的外部 JSON(如第三方 API 的透传字段),或者做格式探测和预处理。能定义结构体的场景,优先用结构体。

处理不规范的 JSON

当 JSON 来自大语言模型等不太规范的来源时,常常会遇到问题:被包裹在 Markdown 代码块里、尾部多余的逗号、字段名没有加引号、包含注释……Go 标准库的 json.Unmarshal 对格式极其严格,这些全部会报错。

最简单的预处理是剥离 Markdown 代码块标记:

1
2
3
4
5
6
7
func sanitizeModelJSON(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}

但靠字符串替换应对更复杂的语法错误(缺少引号、包含注释)是不靠谱的。Go 标准库没有提供宽松模式(如 AllowTrailingCommas)。如果需要处理严重不合法的 JSON 或 JSON5 变体,业界的做法是引入第三方库,比如支持注释、尾逗号和无引号 key 的 JSON5 解析器。

JSON 与 io.Writer 的结合

json.NewEncoder 接受 io.Writer,可以直接将 JSON 写入文件、网络连接或标准输出——不需要先序列化成 []byte 再手动写入:

1
2
3
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
encoder.Encode(book)

这在 HTTP handler 中特别实用,因为 http.ResponseWriter 也是 io.Writer

HTTP 服务

Go 标准库的 net/http 可以直接写生产级的 HTTP 服务,不需要第三方框架。这是 Go 在服务端领域的杀手锏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func handleHello(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "世界"
}
fmt.Fprintf(w, "你好, %s!\n", name)
}

func handleJSON(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := map[string]any{
"language": "Go",
"features": []string{"goroutine", "channel", "generics"},
}
json.NewEncoder(w).Encode(data)
}

Go 的 HTTP 处理函数签名固定为 func(w http.ResponseWriter, r *http.Request)w 实现了 io.Writer——所以之前学的 fmt.Fprintfjson.NewEncoder 都可以直接往 HTTP 响应里写。r.Bodyio.Reader——用 io.ReadAlljson.NewDecoder 读取请求体。一切都回到了 Reader / Writer 这两个核心接口。

对比 C++:用 Boost.Beast 写同样的功能大约需要 100+ 行,还需要手动处理 TCP 连接、HTTP 解析、响应构建。Go 的 net/http 把这些全封装好了。

读取请求体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func handleEcho(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "只接受 POST 请求", http.StatusMethodNotAllowed)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "读取请求体失败", http.StatusInternalServerError)
return
}
defer r.Body.Close()

w.Header().Set("Content-Type", "application/json")
resp := map[string]string{
"echo": string(body),
"method": r.Method,
}
json.NewEncoder(w).Encode(resp)
}

如果想让 echo 更智能——当请求体是 JSON 时按 JSON 结构嵌入响应,否则作为字符串回显——可以检查 Content-Type 并尝试解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contentType := r.Header.Get("Content-Type")
resp := map[string]any{"method": r.Method, "path": r.URL.Path}

if strings.Contains(contentType, "application/json") {
var jsonBody map[string]any
if err := json.Unmarshal(body, &jsonBody); err == nil {
resp["echo"] = jsonBody // 作为 JSON 对象嵌入
} else {
resp["echo"] = string(body) // 解析失败,回退为字符串
resp["note"] = "Invalid JSON payload"
}
} else {
resp["echo"] = string(body)
}

注册路由与启动

1
2
3
4
5
6
http.HandleFunc("/", handleRoot)
http.HandleFunc("/hello", handleHello)
http.HandleFunc("/json", handleJSON)
http.HandleFunc("/echo", handleEcho)

log.Fatal(http.ListenAndServe(":8080", nil))

ListenAndServe 阻塞运行,内部为每个连接启动一个 goroutine——handler 天然是并发执行的。如果 handler 访问共享状态,需要加锁或用 channel,这就回到了第三篇讨论的并发知识。

小结

Go 标准库的 IO 设计围绕 io.Readerio.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)覆盖大量场景,而不是为每个场景设计专用的语法。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。