📚 Golang 教程系列

  1. 入门与基础类型
  2. 字符串、变量与常量
  3. 流程控制
  4. 集合类型
  5. 函数、指针与类型
  6. 结构体(本文)
  7. 接口
  8. 错误处理、并发与泛型
  9. 测试与工程实践
  10. 杂项:make 与 select
  11. 杂项:embed 与资源嵌入

本篇聚焦 Go 结构体–从定义、方法、嵌套组合到内存布局与 JSON 序列化,拆解这套值类型面向对象体系的底层机制(接口部分见下一篇)。

Go 没有类、继承、构造函数这些传统 OOP 概念,却用结构体(struct) + 方法(method)构建出简洁强大的数据抽象体系,核心理念是组合优于继承、值语义优先。本篇从内存布局到方法集规则,从嵌套组合到 JSON 序列化,力求让你既会写、又懂为什么。

结构体:定义与字面量初始化

结构体的本质

结构体是一组命名字段的集合,本质是一段连续内存,和 C 的 struct 同宗同源,但多了方法、标签、内嵌能力:

1
2
3
4
type Person struct {
Name string
Age int
}

关键认知:

  • type X struct{...} 定义全新命名类型,即便字段完全相同也是不同类型,不能直接互相赋值。
  • 字段顺序有意义:决定内存布局,也决定字面量能否按顺序初始化。
  • 每个字段有零值,声明后不会是 undefined/null,而是各自零值。

字面量初始化的三种形式

1
2
3
4
5
6
7
8
9
10
11
// 写法 A:按字段名初始化(推荐,生产代码默认用这种)
p1 := Person{
Name: "Linus",
Age: 54,
}

// 写法 B:按字段顺序初始化(省略字段名)
p2 := Person{"Linus", 54}

// 写法 C:零值初始化(不写字段)
var p3 Person // Name="", Age=0

写法 A(字段名)vs 写法 B(顺序)的核心差异

维度按字段名(A)按顺序(B)
字段重命名/新增字段仅需改赋值处,未涉及字段不受影响编译错误,必须同步修改所有调用点
可读性自文档化,一眼能看出每个值的含义需要回去查定义才知道第几个是什么
字段顺序变更不受影响必须改
适用场景公共 API、生产代码局部、临时、字段少且稳定的内部结构

⚠️ 注意:同一字面量不能混用字段名和顺序两种写法,Person{Name: "x", 54} 编译错误,以避免歧义。

部分字段名初始化,未列出字段自动取零值:

1
p4 := Person{Name: "Grace"} // Age=0

配置类结构体常用:只设关心的字段,其余走默认值。

结构体是值类型

结构体赋值、传参都是值拷贝,不是引用:

1
2
3
4
a := Person{Name: "Ada", Age: 36}
b := a // 整个结构体被拷贝一份
b.Age = 200
fmt.Println(a.Age) // 36 -- a 没被改

这和 Java/JS/Python 的对象(引用)截然不同。想跨函数修改结构体,要么返回新值,要么传指针(见方法一节)。

结构体的可比较性

所有字段都可比较,结构体也可比较(==/!=),可作 map key:

1
2
3
4
5
6
7
8
type Point struct{ X, Y int }

p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true

m := map[Point]string{}
m[p1] = "origin"

含 slice、map、function 等不可比较字段时,结构体不可比较,== 编译错误,需手写 Equal 或用 reflect.DeepEqual(走反射、较慢,会比较未导出字段,性能敏感场景应手写 Equal)。

结构体标签(Struct Tag)与 reflect 反射简介

Tag 是什么

字段后反引号包裹的字符串是 Tag,附加在字段上的元信息,不影响内存和逻辑,但可被反射读取,是 ORM、JSON 序列化、校验等能力的统一约定:

1
2
3
4
5
type User struct {
Name string `json:"name" db:"user_name" validate:"required,min=2"`
Age int `json:"age" db:"age" validate:"gte=0,lte=150"`
Mail string `json:"email,omitempty"`
}

语法本质是 key:"value" 形式若干段,空格分隔,用 reflect.StructTag.Get(key)/Lookup(key) 解析。

json 标签实战

encoding/json 是最经典的 Tag 消费者:

选项含义
json:"name"序列化时用 name 作为键名
json:"-"该字段完全不参与序列化/反序列化
json:"name,omitempty"字段为零值时省略输出
json:",string"把数值字段序列化成字符串(少用,常见于对接历史 API)
json:"name,omitempty"反序列化时仍按 name 匹配,零值省略只影响编码
1
2
3
4
5
6
7
8
9
10
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data []Item `json:"data,omitempty"` // data 为空切片时不输出该字段
Debug string `json:"-"` // 永远不序列化
}

r := Response{Code: 0, Message: "ok"}
b, _ := json.Marshal(r)
// 输出:{"code":0,"message":"ok"}

⚠️ 注意omitempty 对结构体字段判零值用「与零值结构体是否相等」。若结构体全是零值字段,它也会被省略–往往不是你想要的。生产中常给字段用指针(*Item)或显式包装。

validator 标签

github.com/go-playground/validator 用 Tag 做参数校验,是 gin/echo 标配:

1
2
3
4
5
6
7
8
9
10
11
12
type RegisterReq struct {
Username string `json:"username" validate:"required,min=3,max=32"`
Password string `json:"password" validate:"required,min=8"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=1,lte=150"`
}

validate := validator.New()
err := validate.Struct(req)
if err != nil {
// err 里包含所有违反约束的字段
}

Tag 把校验逻辑剥离到声明层,让结构体定义自带「契约」语义。

reflect 包简介

reflect 是反射入口。对结构体,核心是 Type 的字段遍历和 Tag 读取:

1
2
3
4
5
6
7
8
9
10
11
import "reflect"

t := reflect.TypeOf(User{})
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("字段 %s, 类型 %s, json tag = %q\n",
f.Name, f.Type, f.Tag.Get("json"))
}
// 字段 Name, 类型 string, json tag = "name"
// 字段 Age, 类型 int, json tag = "age"
// 字段 Mail, 类型 string, json tag = "email,omitempty"

reflect.Value 还能运行时读写字段值(导出字段才能 Set):

1
2
3
4
5
v := reflect.ValueOf(&u).Elem() // 传指针才能改
nameField := v.FieldByName("Name")
if nameField.CanSet() {
nameField.SetString("new name")
}

提示:反射很慢(慢一到两个数量级)且绕过编译期检查。只有 ORM、序列化这类「通用处理任意类型」的基础库才适合用,业务代码能用代码生成(easyjsonffjson)替代就别用反射。

匿名结构体与嵌套结构体的内存布局

匿名结构体

不通过 type 命名、直接在表达式位置写出的结构体叫匿名结构体,常用于临时数据、JSON 解析、测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在函数内临时构造一个结构
point := struct {
X, Y int
}{10, 20}

// 解析 JSON 时临时建模
var data struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
json.Unmarshal([]byte(`{"code":1,"msg":"ok"}`), &data)

// 切片里的匿名结构体
list := []struct {
ID int
Name string
}{
{1, "alpha"},
{2, "beta"},
}

真正的「用完即弃」,避免为一次性场景起类型名污染命名空间。

内存布局与字段对齐

结构体在内存中按声明顺序排布,但每个字段按其类型对齐到自身对齐边界,末尾还做整体对齐。所以「字段顺序不同,大小可能不同」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type A struct {
a bool // 1 字节,但 int 需要 8 字节对齐
b int64 // 8 字节
c bool // 1 字节
}

type B struct {
b int64
a bool
c bool
}

fmt.Println(unsafe.Sizeof(A{})) // 24:a(1) + 7字节填充 + b(8) + c(1) + 7字节填充
fmt.Println(unsafe.Sizeof(B{})) // 16:b(8) + a(1) + c(1) + 6字节填充

仅调换顺序,AB 多占 50%。实战建议:字段按「由大到小」排列(int64 在前、bool 在后),不改语义却能减少填充。单个结构体影响小,海量实例(缓存、内存数据库)收益显著,fieldalignment lint 可自动建议重排。

嵌套结构体内存布局

结构体作为另一个结构体的字段是直接内联展开的(不是指针):

1
2
3
4
5
6
7
8
type Inner struct {
X int
}

type Outer struct {
I Inner // 直接内联,Outer 内存里包含完整的 Inner
Y int
}

Outer 内存里 Inner.XOuter.Y 连续,无间接寻址,访问无指针跳转,拷贝 Outer 连同 Inner 一起深拷贝。

字段是指针则不同:

1
2
3
4
type OuterPtr struct {
I *Inner // 指针,8 字节,指向堆上的 Inner
Y int
}

直接嵌套 vs 指针嵌套

维度直接嵌套 Inner指针嵌套 *Inner
内存内联,无额外分配多一次堆分配 + 8 字节指针
拷贝深拷贝整块浅拷贝(只拷贝指针,共享底层)
访问直接寻址多一次指针解引用
可空不可空(必有零值)可为 nil
共享每个外层有独立内层多个外层可共享同一内层

树形、图等「需共享、需环」结构必须用指针;扁平数据记录用直接嵌套更高效。

方法:值接收者与指针接收者

方法是绑定到特定类型上的函数,通过**接收者(receiver)**声明,位于 func 和方法名之间,相当于其他语言的 this/self

1
2
3
func (p Person) Greet() string {
return "Hi, I'm " + p.Name
}

值接收者

(p Person)值接收者:方法拿到 p 的拷贝,修改不影响原对象:

1
2
3
4
5
6
7
func (p Person) SetAgeBroken(n int) {
p.Age = n // 改的是副本,调用方看不到
}

p := Person{Name: "Ada"}
p.SetAgeBroken(100)
fmt.Println(p.Age) // 0,没变

值接收者适合「只读」方法–不改状态、无副作用,并发场景天然安全。

指针接收者

(p *Person)指针接收者:操作原对象本身,可修改:

1
2
3
4
5
6
7
8
func (p *Person) SetAge(n int) {
p.Age = n // 改的是原对象
}

p := Person{Name: "Ada"}
(&p).SetAge(100) // 显式传地址
p.SetAge(100) // 语法糖:Go 自动取址
fmt.Println(p.Age) // 100

语法糖:p 可寻址时调用指针接收者方法可省略 &,编译器自动取址,使两种接收者调用语法一致。

何时用指针接收者

应该用指针接收者

  1. 方法需修改接收者状态SetAgeAddPush)。
  2. 接收者结构体很大,值接收者每次拷贝整块内存。
  3. 保持一致性–一旦某方法用指针接收者,为方法集一致通常所有方法都用。
  4. 类型语义上是「实体」*sync.Mutex*os.File*bytes.Buffer

可以用值接收者

  1. 类型小且语义是「值」time.TimePointMoney
  2. 方法只读、不改状态。
  3. 不可变「值对象」。

⚠️ 注意:不要混用值/指针接收者,会引发方法集不一致导致接口实现出错(下节详解)。原则上一个类型的所有方法接收者要统一

方法调用语法糖的边界

语法糖依赖「可寻址性」:

1
2
3
4
5
6
p := Person{}
p.Greet() // OK:p 可寻址,自动处理
Person{}.Greet() // OK:值接收者直接调用字面量

// 但:
Person{}.SetAge(1) // 编译错误!字面量不可寻址,无法自动取址给指针接收者

map 元素也不可寻址,m["key"].SetAge(1) 会编译失败–需先取出整个结构体再操作。

方法集(Method Set)规则

方法集是理解「接口实现」的钥匙,也是最容易踩坑的地方之一。

什么是方法集

  • 类型 T 的方法集 = 所有值接收者方法。
  • 类型 *T 的方法集 = 值接收者 + 指针接收者方法(全部)。

*T 拥有 T 的全部方法,再加自己的指针接收者方法。

方法集与接口实现

接口实现看方法集。给定:

1
2
3
4
5
6
7
8
9
10
type Speaker interface {
Speak() string
}
type Ager interface {
SetAge(int)
}

type Person struct{ Name string; Age int }
func (p Person) Speak() string { return p.Name } // 值接收者
func (p *Person) SetAge(n int) { p.Age = n } // 指针接收者

则:

  • Person(值)实现 Speaker,但不实现 AgerSetAge 是指针接收者,不在值类型方法集)。
  • *Person(指针)同时实现 SpeakerAger
1
2
3
4
var s Speaker = Person{Name: "Ada"}      // OK
var a Ager = Person{Name: "Ada"} // 编译错误:Person 没实现 SetAge
var s2 Speaker = &Person{Name: "Ada"} // OK:*Person 也实现了 Speaker
var a2 Ager = &Person{Name: "Ada"} // OK:*Person 实现了

底层逻辑:值接收者方法保证「不改原对象」,值或指针都能安全调用;指针接收者方法会改原对象,若允许用值调用,修改会丢失(值是拷贝),语义矛盾,所以 Go 不让值类型出现在它的方法集里。

混用限制与一致性

混用值/指针接收者会埋「看似实现接口、实际没有」的雷:

1
2
3
4
5
6
7
8
type Logger interface{ Log(string) }
type Service struct{ state int }

func (s Service) Name() string { return "svc" } // 值接收者
func (s *Service) Log(msg string) { s.state++ } // 指针接收者

var l Logger = Service{} // 编译错误:值类型没实现 Log
var l2 Logger = &Service{} // OK

某处用 Service(值)传给期望 Logger 的函数,编译期就报错–好事,但让人困惑。统一用指针接收者可避免。

提示:实际工程几乎可无脑用指针接收者,除非类型小且语义是值(time.Time)。time.Time 用值接收者(小且不可变),*bytes.Buffer*sync.WaitGroup 用指针(可变状态),就是这个原则。

嵌套组合(Embedding):字段提升与方法提升

Go 没有继承,但提供嵌套(embedding)–把一个类型作为另一个类型的匿名字段,这是「组合优于继承」的核心机制。

字段提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Address struct {
City string
ZipCode string
}

type Employee struct {
Name string
Address // 匿名嵌套,类型名即字段名
}

e := Employee{
Name: "Grace",
Address: Address{City: "NYC", ZipCode: "10001"},
}
// 字段被「提升」到外层,可直接访问
fmt.Println(e.City) // 等价于 e.Address.City
e.City = "Boston" // 等价于 e.Address.City = "Boston"

提升是编译期语法糖:写 e.City 时编译器改写成 e.Address.City,内存里 Address 仍独立。字段名就是类型名(去包名后),pkg.MyType 嵌套后字段叫 MyType

方法提升

嵌套类型的方法也提升到外层,外层「自动拥有」这些方法:

1
2
3
4
5
6
func (a Address) FullAddress() string {
return a.City + " " + a.ZipCode
}

e := Employee{Name: "Grace", Address: Address{City: "NYC", ZipCode: "10001"}}
fmt.Println(e.FullAddress()) // 调用的是 e.Address.FullAddress()

这让 Go 能模拟「继承」的复用效果,但本质是组合–内层是外层的字段,不是父类。

外层可 shadow 内层方法

1
2
3
4
5
func (e Employee) FullAddress() string {
return "[Employee] " + e.Address.FullAddress()
}
e.FullAddress() // 调用外层版本
e.Address.FullAddress() // 显式调用内层版本

注意是 shadow(遮蔽)不是 override(重写):Go 没有动态分派,方法调用在编译期按静态类型决定。这是 Go 和 OOP 继承最本质的区别。

组合优于继承:哲学层面的差异

Go 刻意拒绝继承,三点理由:

1. 继承造成强耦合。子类与父类实现深度绑定,父类改方法子类行为就变,层级越深「脆弱基类问题」越严重。组合把内层当黑盒字段,耦合低得多。

2. is-a 关系僵硬Dog extends Animal 就绑死 Animal 全部接口。Go 倾向 has-aDog Animal 的某些行为,但不绑定全部。

3. 继承破坏封装。子类能访问父类 protected 字段,暴露内部细节。Go 嵌套只能访问内层导出字段,封装完整。

对比示例:

1
2
3
4
5
// Java 风格
class Animal { void breathe() {...} }
class Dog extends Animal { void bark() {...} }
class Puppy extends Dog { void whimper() {...} }
// Puppy 自动拥有 breathe + bark,深度耦合三层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Breather struct{}
func (Breather) Breathe() {}

type Dog struct {
Breather // Dog 组合了 Breather 的能力
}
func (Dog) Bark() {}

type Puppy struct {
Dog // Puppy 组合了 Dog
}
func (Puppy) Whimper() {}

p := Puppy{}
p.Breathe() // 提升自 Breather
p.Bark() // 提升自 Dog
p.Whimper()

形式像继承,本质是组合:Puppy 内有 DogDog 内有 Breather,是嵌套字段不是父类链。

嵌套接口

还能嵌套接口–依赖注入和 mock 测试的常用手法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Logger interface {
Log(string)
}
type Store interface {
Get(key string) (string, bool)
}

// Service 依赖两个接口,通过嵌套字段声明依赖
type Service struct {
Logger
Store
}

func (s *Service) DoSomething(key string) {
val, ok := s.Get(key) // 调用提升的 Store 方法
if !ok {
s.Log("key not found") // 调用提升的 Logger 方法
return
}
_ = val
}

Service 内嵌 LoggerStore 两个接口字段(零值是 nil 接口),构造时传具体实现,测试时传 mock。比显式写字段更简洁。

⚠️ 注意:嵌套接口字段忘初始化就是 nil,调用其方法会 panic。构造函数应强制校验依赖非 nil。

字段名冲突规则

  • 同名方法只出现在一层,直接提升,无歧义。
  • 出现在多层且距离相同(都是直接嵌套),外层提升,必须显式写 e.A.Foo(),否则编译错误。
  • 一层比另一层,浅层遮蔽深层,无歧义。

重构时加嵌套字段可能引入冲突导致编译失败,需留意。

空结构体 struct{} 深度解析

struct{} 是没有字段、大小为 0 的结构体,承担几类独特用途。

0 字节的奥秘

1
2
var empty struct{}
fmt.Println(unsafe.Sizeof(empty)) // 0

占用 0 字节是 Go 规范保证的,但地址合法(不同变量地址可能相同)。0 字节特性让它在「需类型不需数据」的场景成为完美选择。

地址的统一性:runtime.zerobase

struct{} 大小为 0,「放在内存哪里」?Go runtime 用全局变量 runtime.zerobase 充当所有空结构体变量的地址:

1
2
3
var a, b, c struct{}
fmt.Println(&a == &b) // 通常为 true:都指向 zerobase
fmt.Println(&a == &c) // 通常为 true

这是 Go 规范的明确约定:

Two distinct zero-size variables may have the same address in memory.

措辞是 “may”(可以)非 “must”(必须)–规范只允许共享地址,不保证共享。绝不能依赖空结构体指针的唯一性做判断,&a == &b 的结果因 Go 版本、是否逃逸到堆、变量是否同时存活而异。

空结构体数组与切片:0 字节的容器

零大小特性「传染」给容器:任意多个 struct{} 元素的数组/切片整体仍占 0 字节。

1
2
3
4
5
6
7
8
var arr [1 << 20]struct{} // 100 万个元素
fmt.Println(unsafe.Sizeof(arr)) // 0
fmt.Println(len(arr)) // 1048576

// 用作"循环 N 次"的零开销计数器
for range [3]struct{}{} {
fmt.Println("tick") // 打印 3 次
}

len/cap 仍有效,for range 仍迭代 N 次,但底层数据不占一字节,所有 &arr[i] 地址相同。最常见用法是零开销循环计数for range make([]struct{}, N)for i := 0; i < N; i++ 更简洁地表达「重复 N 次」,且零分配。

用作 Set(集合)

Go 没 set 类型,惯例用 map[T]struct{} 模拟:key 存元素,value 用 struct{} 占位(0 字节)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Set[T comparable] struct {
m map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
return &Set[T]{m: make(map[T]struct{})}
}

func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Remove(v T) { delete(s.m, v) }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Len() int { return len(s.m) }

// 使用
s := NewSet[int]()
s.Add(1)
s.Add(2)
s.Add(1) // 重复添加无效果
fmt.Println(s.Has(1)) // true
fmt.Println(s.Len()) // 2

提示struct{}{} 是空结构体字面量–外层 struct{} 是类型,内层 {} 是值。

不用 map[T]bool 是因为 bool 占 1 字节,海量元素时 struct{} 的 0 字节更省。

参见make 初始化 map、nil map 陷阱见 杂项:make 与 select

channel 信号通知

chan struct{}事件通知的标准惯用法:channel 只传「事件发生」信号,不传数据。

1
2
3
4
5
6
7
8
9
10
11
12
func worker(done chan struct{}) {
defer func() { done <- struct{}{} }() // 完成时发信号
time.Sleep(100 * time.Millisecond)
fmt.Println("working done")
}

func main() {
done := make(chan struct{})
go worker(done)
<-done // 阻塞等待信号,不关心值
fmt.Println("all finished")
}

chan struct{} 而非 chan int/chan bool:① 语义清晰,明确「只传信号」;② 0 字节;③ 防误用(chan bool 可能误传 false 表失败,struct{} 无值无歧义)。context.Done() 返回的就是 <-chan struct{}

close 一个 chan struct{} 还能一对多广播:关闭后所有阻塞在 <-done 的接收方同时收到零值返回。这是 context.Context.Done() 的核心机制:

1
2
3
4
5
6
7
8
9
done := make(chan struct{})
for i := 0; i < 3; i++ {
go func(id int) {
<-done // 阻塞,直到 done 被 close
fmt.Println("worker", id, "exiting")
}(i)
}
close(done) // 一次 close,三个 worker 同时被唤醒
time.Sleep(time.Millisecond)

对比 chan bool:通知 N 个 worker 要么发 N 次要么开 N 容量缓冲;close 一次唤醒所有接收者,天然支持多消费者。所以「取消/停止」信号一律用 chan struct{} + close

无状态方法接收者

方法不依赖任何状态(纯工具方法)时,可用 struct{} 作接收者,做成「无状态命名空间」:

1
2
3
4
5
6
7
8
9
10
11
12
type MathUtil struct{}

func (MathUtil) Max(a, b int) int {
if a > b {
return a
}
return b
}

// 使用
var math MathUtil
fmt.Println(math.Max(3, 5))

但这在 Go 里不算主流–社区更倾向直接用包级函数。struct{} 接收者主要用于需实现某接口、又确实无状态的场景。

null object 与接口占位

struct{} 没字段,方法集满足就能实现任意接口,是最「轻」的接口实现者,适合做 null object–所有方法 no-op 的占位实例,替代 nil、消除调用方判空负担:

1
2
3
4
5
6
type noOpReader struct{}
func (noOpReader) Read(p []byte) (int, error) { return 0, io.EOF }

// 用 noOpReader 替代 nil,调用方无需判空
var r io.Reader = noOpReader{}
io.Copy(io.Discard, r)

io.Discard 就是标准库的 null object。测试中也常用 struct{} 快速实现 mock 接口。

空结构体的一个陷阱

承接 runtime.zerobase,最经典陷阱是误以为每个 &struct{}{} 是唯一标识

1
2
var a, b struct{}
fmt.Println(&a == &b) // 规范未定义:可能 true 也可能 false

更隐蔽的是 slice 元素:所有 &s[i] 地址可能相同,用元素指针做 map key 会塌缩成一个键:

1
2
3
4
5
6
s := make([]struct{}, 3)
m := map[*struct{}]int{}
for i := range s {
m[&s[i]] = i
}
fmt.Println(len(m)) // 可能是 1,而非 3

结论:永远别用空结构体指针充当唯一标识,用自增计数器、uintptr 或专门 ID 生成器。

结构体与 JSON 序列化

结构体最常见的外部用途是和 JSON 互转。encoding/json 把「字段 ↔ 键值」映射收敛到 Tag + 反射 一套约定,理解它就能解释 90% 的序列化行为,剩下 10% 边界情况是本节的陷阱。

核心模型:Marshal / Unmarshal 与 Encoder / Decoder

两套 API:

API签名适用场景
json.Marshal(v)([]byte, error)一次性把整个值编码成 []byte
json.Unmarshal(data, &v)error一次性把 []byte 解码进 v
json.NewEncoder(w).Encode(v)error流式编码到 io.Writer末尾会追加 \n
json.NewDecoder(r).Decode(&v)error流式从 io.Reader 解码,可连续调用读取多个 JSON 值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 一次性 API:适合整块数据
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}

b, _ := json.Marshal(User{ID: 1, Name: "Alice"})
// b = {"id":1,"name":"Alice"}

var u User
json.Unmarshal(b, &u)

// 流式 API:适合 HTTP body、大文件、流式协议
dec := json.NewDecoder(req.Body)
for {
var msg Message
if err := dec.Decode(&msg); err == io.EOF {
break
} else if err != nil {
log.Fatal(err)
}
handle(msg)
}

提示NewEncoder.Encode 末尾加换行符(适配 NewDecoder 流式读取)。要把 JSON 嵌进别的结构(如一行日志)时这个 \n 是坑,需 bytes.TrimRight(b, "\n") 或改用 Marshal

标签的完整语义

标签编码(Marshal)解码(Unmarshal)
json:"name"name 作键name 匹配键
json:"-"完全跳过完全跳过
json:"-,"键名是字面量 -按字面量 - 匹配(极少用)
json:"name,omitempty"字段为零值时省略输出不影响匹配
json:",string"数值编成字符串字符串解析成数值
json:",omitempty"键名沿用字段名,空值省略不影响匹配
(无标签)用导出字段名作键按字段名匹配
1
2
3
4
5
6
7
8
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price,omitempty"` // 0 时省略
Internal string `json:"-"` // 永不序列化(如内部缓存)
Notes string `json:",omitempty"` // 键名沿用字段名 Notes,空值省略
Dash int `json:"-,"` // 键名是字面量 "-"
}

⚠️ 注意json:"-" 是「跳过字段」,json:"-," 是「键名是 -」。差一个逗号,最易踩的笔误。

Marshal 的行为细节

编码规则:

  1. 只有导出字段参与编码,小写字段忽略–最常见的「字段没出现在 JSON 里」原因。
  2. 字段按声明顺序输出(非按名排序)。
  3. map 键顺序随机,需稳定输出用结构体或排序后的 []struct{K,V}
  4. 指针自动解引用nil 指针编成 null
  5. time.Time 编成 RFC3339 字符串
  6. []byte 编成 base64 字符串
  7. HTML 转义:默认把 </>/& 转义成 < 等,防 XSS。
1
2
3
4
5
6
7
8
9
10
11
12
13
type Resp struct {
Time time.Time
Data []byte
HTML string
}

r := Resp{
Time: time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC),
Data: []byte("hi"),
HTML: "<b>",
}
b, _ := json.Marshal(r)
// {"Time":"2026-07-24T10:00:00Z","Data":"aGk=","HTML":"<b>"}

关闭 HTML 转义:

1
2
3
4
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode(r)

nil vs 空集合

1
2
3
4
5
6
7
8
9
10
type S struct {
Items []int
M map[string]int
}

b1, _ := json.Marshal(S{Items: nil, M: nil})
// {"Items":null,"M":null}

b2, _ := json.Marshal(S{Items: []int{}, M: map[string]int{}})
// {"Items":[],"M":{}}

nil 切片/map 编成 null,非 nil 空集合编成 []/{},API 设计要明确选哪个。

Unmarshal 的行为细节

解码规则:

  1. 字段名匹配:先精确匹配,再大小写不敏感匹配。
  2. 多余字段忽略(除非 Decoder.DisallowUnknownFields())。
  3. 缺失字段保留零值
  4. 类型不兼容报错,但不部分回滚(已解码字段保留)。
  5. any 字段:数字变 float64,大整数(超 2^53)丢精度。
1
2
3
4
5
6
7
var u struct {
ID int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
err := json.Unmarshal([]byte(`{"id":1,"name":"Alice","extra":"ignored","age":"oops"}`), &u)
// 报错:json: cannot unmarshal string into Go struct field .age of type int

大整数精度陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var v struct {
ID any `json:"id"`
}
json.Unmarshal([]byte(`{"id":9223372036854775807}`), &v)
// v.ID 的类型是 float64,值 = 9.223372036854776e+18,精度已丢失!
fmt.Println(v.ID) // 9.223372036854776e+18

// 正确做法:用 json.Number 保留原始数字
dec := json.NewDecoder(strings.NewReader(`{"id":9223372036854775807}`))
dec.UseNumber()
var v2 struct {
ID any `json:"id"`
}
dec.Decode(&v2)
// v2.ID 是 json.Number(底层 string),原样保留

动态 JSON:map[string]any、RawMessage 与 Token 模式

JSON 结构不固定时,三种手段(结构化程度从高到低):

1. map[string]any:完全动态,适合「根本不知道里面有什么」。

1
2
3
4
var m map[string]any
json.Unmarshal([]byte(`{"a":1,"b":[2,3],"c":{"d":true}}`), &m)
// m = map[string]any{"a":float64(1), "b":[]any{float64(2),float64(3)}, "c":map[string]any{"d":true}}
// 注意:数字全是 float64,嵌套切片是 []any,嵌套对象是 map[string]any

代价是丢类型,每次取值要类型断言。

2. json.RawMessage:延迟解码,适合「部分结构未知」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"` // 原始字节,先不解码
}

var env Envelope
json.Unmarshal([]byte(`{"type":"user","data":{"id":1,"name":"A"}}`), &env)

switch env.Type {
case "user":
var u User
json.Unmarshal(env.Data, &u) // 现在才按 User 解码
case "order":
var o Order
json.Unmarshal(env.Data, &o)
}

RawMessage 本质是 type RawMessage []byte,实现了 Marshaler/Unmarshaler,能原样塞进/取出字节流,是处理多态消息的标准手法。

3. Decoder.Token():流式 token 模式,适合超大 JSON 或只提取少量字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dec := json.NewDecoder(r)
for {
t, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
switch tok := t.(type) {
case json.Delim:
// { } [ ] 的边界
case string:
// 对象的键
default:
// 值(float64、bool、nil)
}
}

自定义序列化:Marshaler / Unmarshaler 接口

默认规则不够用时(如时间格式化成 "2026-07-24"、枚举编成字符串),实现这两个接口(剧透:只要类型有这个方法 json 包就会调用,无需 implements 声明,详见下一篇):

1
2
3
4
5
6
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}

实战一:自定义时间格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type DateOnly time.Time

func (d DateOnly) MarshalJSON() ([]byte, error) {
t := time.Time(d)
if t.IsZero() {
return []byte("null"), nil
}
return []byte(`"` + t.Format("2006-01-02") + `"`), nil
}

func (d *DateOnly) UnmarshalJSON(data []byte) error {
// data 形如 "2026-07-24"(带引号)
s := strings.Trim(string(data), `"`)
if s == "" || s == "null" {
return nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return err
}
*d = DateOnly(t)
return nil
}

type Event struct {
Name string `json:"name"`
Day DateOnly `json:"day"`
}
// 编码:{"name":"launch","day":"2026-07-24"}

实战二:枚举字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Status int

const (
StatusPending Status = iota
StatusActive
StatusClosed
)

var statusNames = map[Status]string{
StatusPending: "pending",
StatusActive: "active",
StatusClosed: "closed",
}

func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(statusNames[s]) // 输出 "active" 而非 1
}

func (s *Status) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err != nil {
return err
}
for k, v := range statusNames {
if v == name {
*s = k
return nil
}
}
return fmt.Errorf("unknown status: %s", name)
}

提示UnmarshalJSON 通常定义在指针接收者上(要改字段值);MarshalJSON 用值接收者即可(类型大或有 nil 语义也用指针)。返回值必须是合法 JSON 字节,字符串要自带引号。

常见陷阱

陷阱一:omitempty 无法区分「零值」和「缺失」

1
2
3
4
type Config struct {
Timeout int `json:"timeout,omitempty"`
}
// {Timeout: 0} 编码成 {},和「没设置 timeout」无法区分

omitempty 只能表达「有非零值」,不能表达「显式设零值」。三态用指针:

1
2
3
type Config struct {
Timeout *int `json:"timeout,omitempty"` // nil=未设置,&0=显式零值
}

陷阱二:空切片是 null 还是 []

nil 切片 -> null,非 nil 空切片 -> []。前端常期望空列表是 [],初始化养成习惯:

1
2
3
4
type ListResp struct {
Items []Item `json:"items"` // 不要 omitempty,且初始化成 []Item{}
}
r := ListResp{Items: []Item{}} // 保证空时输出 [] 而非 null

陷阱三:嵌套匿名结构体的字段提升

1
2
3
4
5
6
7
8
9
type Base struct {
ID int `json:"id"`
Name string `json:"name"`
}
type User struct {
Base // 匿名嵌入,字段会被提升
Email string `json:"email"`
}
// 编码:{"id":1,"name":"A","email":"x@y.com"} -- 提升的字段平铺到顶层

外层有同名 tag 会 shadow 内层。给嵌入字段明确字段名可避免意外。

陷阱四:不可导出字段

1
2
3
4
type User struct {
Name string
pass string // 小写,json 直接忽略,不报错也不编码
}

加 tag 也不能让小写字段序列化,json 只认导出字段。

陷阱五:循环引用会 panic

1
2
3
4
5
6
type Node struct {
Self *Node `json:"self"`
}
n := &Node{}
n.Self = n
b, _ := json.Marshal(n) // 栈溢出 panic: goroutine ... stack overflow

json 不处理循环引用。有环结构须先用 visited map 展开成树,或自定义 MarshalJSON 打断环。

性能优化

encoding/json 用反射,标准库里出名慢。热路径手段:

手段原理适用
easyjson代码生成,为每个类型生成免反射的 Marshal/Unmarshal类型固定、性能敏感
sonicJIT/汇编,字节对齐解析字节跳动开源,极致性能
jsoniterdrop-in 替换 encoding/jsonConfigCompatibleWithStandardLibrary渐进迁移,改动小
sync.Pool 复用 Encoder/Buffer减少 GC 压力高 QPS 服务
1
2
3
4
5
// jsoniter:几乎零成本替换
import jsoniter "github.com/json-iterator/go"

var json = jsoniter.ConfigCompatibleWithStandardLibrary
// 之后所有 json.Marshal/Unmarshal 调用不变,底层换成 jsoniter

原则:先测后优。多数服务 JSON 开销远小于网络和数据库,profiler 明确指向 json.Marshal 时才换库。

encoding/json/v2:下一代 JSON 包

encoding/json/v2 重新设计 v1,修正语义歧义和不安全默认值。截至 Go 1.25 仍实验性,需 GOEXPERIMENT=jsonv2,不受 Go 1 兼容承诺保护,生产应谨慎,但代表官方未来方向。

旁路:不依赖 GOEXPERIMENT 试用 v2 API,可用镜像模块 github.com/go-json-experiment/json,API 同步,用法一致。

为什么需要 v2? v1 设计追溯至 Go 1.0(2009),问题包括大小写不敏感匹配的安全歧义(曾有认证绕过案例)、null vs [] 不可控、重复键静默覆盖、错误缺定位。v2 用更严格默认显式选项 API修正,职责拆两层:jsontext(语法层,token 级读写)和 v2(语义层,结构体 ↔ JSON 映射、Tag、选项),共享同一套 Options

函数签名:基础用法和 v1 几乎一样,多了可变 Options

1
2
3
4
5
6
7
// v2(实验性,需 GOEXPERIMENT=jsonv2)
import "encoding/json/v2" // 包名仍是 json

b, err := json.Marshal(u) // 兼容 v1 习惯
err = json.Unmarshal(b, &u)
err = json.MarshalWrite(w, u) // 对应 v1 的 NewEncoder(w).Encode
err = json.UnmarshalRead(r, &u) // 对应 v1 的 NewDecoder(r).Decode

行为差异(更安全默认)

行为v1 默认v2 默认还原 v1 行为
字段名匹配大小写不敏感大小写敏感MatchCaseInsensitiveNames(true)case:ignore
nil 切片null[]FormatNilSliceAsNull(true)
nil mapnull{}FormatNilMapAsNull(true)
重复对象成员名静默覆盖报错jsontext.AllowDuplicateNames(true)
无效 UTF-8替换成 U+FFFD报错jsontext.AllowInvalidUTF8(true)

新标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type V2 struct {
// omitzero:零值省略,且支持 IsZero() 方法
// 对 time.Time 等类型,比 v1 的 omitempty(靠「与零值结构体相等」)准确得多
Created time.Time `json:",omitzero"`

// case:控制大小写匹配(因为 v2 默认大小写敏感)
Name string `json:"name,case:ignore"`

// format:字段级格式化,无需再写 MarshalJSON
Hex [8]byte `json:",format:hex"` // 十六进制
Bytes []byte `json:",format:base64"` // base64
NonFin float64 `json:",format:nonfinite"` // 支持 NaN/Inf
Day time.Time `json:",format:'2006-01-02'"` // 自定义时间格式
Ts time.Time `json:",format:unix"` // Unix 时间戳
Dur time.Duration `json:",format:iso8601"` // ISO 8601 时长

// unknown:收集未知成员,而非丢弃
Extra jsontext.Value `json:",unknown"`

// inline:把字段内容平铺到父对象(v2 还支持 map 和 jsontext.Value)
Inner struct{ A, B int } `json:",inline"`
}

format 是 v2 亮点–v1 自定义时间格式须手写 MarshalJSON,v2 一个 format:'2006-01-02' 就够。omitzero 解决 v1 omitempty 对结构体判零不准:调用字段 IsZero() bool 方法,time.Time 等都能正确判零。

新接口 MarshalerTo/UnmarshalerFrom:v1 的 MarshalJSON() ([]byte, error) 产生中间 []byte 分配,v2 提供基于 jsontext.Encoder 的流式版本,零分配:

1
2
3
4
5
6
type MarshalerTo interface {
MarshalJSONTo(*jsontext.Encoder) error
}
type UnmarshalerFrom interface {
UnmarshalJSONFrom(*jsontext.Decoder) error
}

外部类型注册 Marshalers/Unmarshalers:v1 为第三方类型自定义序列化只能包装或 monkey-patch,v2 用 *Marshalers 不改原类型就注册处理函数:

1
2
3
4
5
6
ms := json.JoinMarshalers(
json.MarshalToFunc(func(enc *jsontext.Encoder, t time.Time) error {
return enc.WriteToken(jsontext.String(t.Format("2006-01-02")))
}),
)
b, _ := json.Marshal(u, json.WithMarshalers(ms))

精确错误定位SemanticErrorJSONPointer(如 /data/items/2/name)、ByteOffsetGoType 等直接定位出错字段,远胜 v1 粗糙的 ...field .age of type int

迁移要点:导入 encoding/json/v2,包名仍 json,基础调用兼容。注意:① 默认大小写敏感,键大小写不一致会匹配失败,加 MatchCaseInsensitiveNames(true);② nil 切片/map 从 null[]/{},前端依赖 null 判空要加 FormatNilSliceAsNull(true)。迁移前跑 v1/v2 对拍测试。

现状建议:新项目可学其设计思想(严格默认、显式选项、format 标签),生产仍以 encoding/json 为主。

本篇小结

  1. 结构体是值类型,赋值传参都拷贝;跨函数改状态就传指针。字段顺序影响内存布局,大字段在前省填充。
  2. 方法绑定类型,值接收者只读、指针接收者可写,所有方法接收者应统一,避免方法集混乱致接口实现失败。
  3. 方法集*T 拥有 T 全部方法加指针接收者方法;T 只有值接收者方法。这决定接口能否被实现。
  4. 嵌套组合代替继承:字段方法会提升,外层可 shadow 内层,但无动态分派。组合松耦合,Go 选了它。
  5. 空结构体 struct{} 是 0 字节,是 Set、信号 channel、无状态接收者的惯用类型。
  6. 结构体与 JSON 靠 Tag + 反射:只有导出字段参与,omitempty 无法区分零值与缺失(用指针表达三态),nil 切片编 null、空切片编 []encoding/json/v2 用更严格默认(大小写敏感、nil->[])和 format/omitzero/unknown 标签修正 v1 包袱,但截至 Go 1.25 仍实验性。

掌握这些,就能写出地道高效的 Go 结构体代码。下一篇进入接口–隐式实现、鸭子类型与 nil 陷阱。