起因:一段看似合理的代码学习 Go 的 encoding/json 时,我想自定义一个日期类型,让它序列化时只输出 2006-01-02 这种日期格式,而不是 time.Time 默认的 RFC3339 全时间戳。于是写了下面这段:
package main
import (
"encoding/json"
"fmt"
"time"
)
type JSONDate time.Time
func (d JSONDate) MarshalJSON() ([]byte, error) {
s := fmt.Sprintf("\"%s\"", time.Time(d).Format(time.DateOnly))
return []byte(s), nil
}
func (d *JSONDate) UnmarshalJSON(data []byte) error {
t, err := time.ParseInLocation(time.DateOnly, string(data), time.Local)
if err != nil {
return err
}
*d = JSONDate(t)
return nil
}
type Person struct {
Name string
Age int `json:"age"`
Height float64
Birth JSONDate
}
func main() {
p := Person{
Name: "alice",
Age: 25,
Height: 1.68,
Birth: JSONDate(time.Now()),
}
bytes, err := json.Marshal(p)
if err != nil {
return
}
fmt.Println(string(bytes))
var dst Person
err = json.Unmarshal(bytes, &dst)
if err != nil {
return
}
fmt.Printf("dst: %#v\n", dst)
}
运行结果符合预期,Birth 字段被序列化成了 "2026-08-13" 这种纯日期字符串。
但当我把 MarshalJSON 改成指针接收者后,事情就不对了:
func (d *JSONDate) MarshalJSON() ([]byte, error) {
s := fmt.Sprintf("\"%s\"", time.Time(*d).Format(time.DateOnly))
return []byte(s), nil
}
输出变成了:
{"Name":"alice","age":25,"Height":1.68,"Birth":{}}
Birth 字段变成了空对象 {},我的 MarshalJSON 像是完全没被调用。这篇文章记录我排查这个问题的全过程,顺带把 Go 的方法集规则和 encoding/json 的接口识别机制讲清楚。
一、encoding/json 如何识别自定义序列化方法
Go 标准库 encoding/json 定义了两个接口:
// encoding/json/marshal.go
type Marshaler interface {
MarshalJSON() ([]byte, error)
}
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}
json.Marshal 在序列化每个值时,会通过反射检查这个值的类型是否实现了 Marshaler 接口:
- 实现了 → 优先调用
MarshalJSON(),把返回的[]byte原样拼进 JSON 输出 - 没实现 → 走默认反射逻辑(struct 按 field 逐个序列化、基础类型直接转字符串等)
json.Unmarshal 同理,检查 Unmarshaler 接口。
关键点:两个接口各管一头,不会交叉。只实现 MarshalJSON 不实现 UnmarshalJSON,反序列化时就会走默认逻辑;反之亦然。
另外,Marshaler 接口的优先级高于 encoding.TextMarshaler 和默认反射逻辑,一旦实现就一定会被调用。
二、为什么改用指针接收者就失效了
把 MarshalJSON 从值接收者改成指针接收者后:
func (d *JSONDate) MarshalJSON() ([]byte, error) // 指针接收者
json.Marshal(p) 的执行链路变成了这样:
p是Person值,p.Birth是JSONDate值(非指针)json.Marshal反射拿到的是这个值,检查的是JSONDate类型的方法集- 指针接收者的方法只挂在
*JSONDate上,不在JSONDate的方法集里 - 判定
JSONDate不实现Marshaler→ 跳过自定义方法 - 走默认 struct 反射逻辑
那为什么输出是 {} 而不是 RFC3339 时间?因为 JSONDate 是 time.Time 的命名类型,而 Go 的类型定义(type A B)不继承原类型的方法集。time.Time 自带的 MarshalJSON 不会被 JSONDate 继承,所以 json 包对 JSONDate 走纯 struct 反射——而 time.Time 内部字段 wall/ext/loc 都是私有字段,反射不可见 → 输出 {}。
三、Go 方法集规则:问题的根因
这是 Go 里最经典也最容易踩的坑。规则只有一句话:
*T的方法集 ⊇T的方法集
展开说就是:
- 类型
T的方法集:所有接收者为T的方法 - 类型
*T的方法集:所有接收者为T或*T的方法
用表格更直观:
| 接收者写法 | T 值的方法集 | *T 指针的方法集 |
|---|---|---|
func (t T) M() | ✅ 有 M | ✅ 有 M |
func (t *T) M() | ❌ 没有 M | ✅ 有 M |
换句话说:
- 指针变量能访问值方法 ✅
- 值变量不能访问指针方法 ❌
所以回到原问题:p.Birth 是 JSONDate 值,它的方法集里没有指针接收者实现的 MarshalJSON,json 包判定它不满足 Marshaler 接口,自然就跳过了。
四、方法调用 ≠ 接口满足
这里有个更隐蔽的误区。看下面这段代码:
d := JSONDate{}
d.MarshalJSON() // ✅ 能编译通过!编译器自动取址 (&d).MarshalJSON()
var m Marshaler = d // ❌ 编译失败!接口检查不自动取址
var m Marshaler = &d // ✅ 通过
第一行 d.MarshalJSON() 能编译,是因为 d 是可寻址的,编译器帮你做了 (&d).MarshalJSON()。这会让人产生错觉:"值也能调用指针方法啊"。
但接口满足的判断严格按类型的方法集来,不会自动取址。JSONDate 类型的方法集不含 MarshalJSON,所以 var m Marshaler = d 编译失败。
而 encoding/json 内部用的是 reflect.Type.Implements(marshalerType),纯类型层面的检查,跟你 value 可不可寻址完全无关。这就是为什么直接调用方法能成功,但 json 序列化却识别不到——它们走的是两条完全不同的路径。
五、为什么 UnmarshalJSON 用指针接收者却没问题
原代码里 UnmarshalJSON 用的是指针接收者,但反序列化工作正常:
func (d *JSONDate) UnmarshalJSON(data []byte) error // 指针接收者
原因在于调用方式不同:
var dst Person
json.Unmarshal(bytes, &dst) // 传的是 &dst 指针
json.Unmarshal 接收的是 &dst,通过这个指针可以寻址到 dst.Birth 字段。检查接口时看的是 *JSONDate 的方法集——*JSONDate 同时包含值方法和指针方法,所以 UnmarshalJSON 能被识别并调用。
更重要的是,反序列化必须修改 *d,用值接收者根本写不回去。所以 UnmarshalJSON 用指针接收者既是必须的,也是能工作的。
六、最佳实践
经过这次踩坑,总结出 Go 自定义 JSON 序列化的标准写法:
type JSONDate time.Time
// 序列化:用值接收者
// 原因:json.Marshal 检查的是值类型的方法集,值接收者让值和指针都满足接口
func (d JSONDate) MarshalJSON() ([]byte, error) {
s := fmt.Sprintf("\"%s\"", time.Time(d).Format(time.DateOnly))
return []byte(s), nil
}
// 反序列化:用指针接收者
// 原因1:必须修改 *d,值接收者写不回去
// 原因2:json.Unmarshal 传的是指针,检查 *T 方法集,能识别指针接收者方法
func (d *JSONDate) UnmarshalJSON(data []byte) error {
t, err := time.ParseInLocation(time.DateOnly, string(data), time.Local)
if err != nil {
return err
}
*d = JSONDate(t)
return nil
}
一句话原则:
MarshalJSON用值接收者,UnmarshalJSON用指针接收者。
这不是随便写的,是 Go 方法集规则和 encoding/json 接口检查机制共同决定的必然结果。
七、验证:几种写法的对照实验
为了彻底确认理解,做了几组对照实验:
实验 1:MarshalJSON 用指针接收者 + 字段是值类型
type Person struct {
Birth JSONDate // 值类型
}
func (d *JSONDate) MarshalJSON() ([]byte, error) { ... }
结果:Birth 输出 {},方法未被调用。❌
实验 2:MarshalJSON 用指针接收者 + 字段是指针类型
type Person struct {
Birth *JSONDate // 指针类型
}
func (d *JSONDate) MarshalJSON() ([]byte, error) { ... }
结果:Birth 输出正确的日期字符串。✅
因为现在字段本身就是 *JSONDate,json 包检查 *JSONDate 的方法集,能找到 MarshalJSON。
实验 3:MarshalJSON 用值接收者 + 字段是值类型
type Person struct {
Birth JSONDate // 值类型
}
func (d JSONDate) MarshalJSON() ([]byte, error) { ... }
结果:Birth 输出正确的日期字符串。✅
这是最省心的写法,值和指针字段都能识别。
实验 4:json.Marshal 传指针
json.Marshal(&p) // 传 Person 指针
这个不能让指针接收者的 MarshalJSON 生效。因为 json 包检查的是字段类型的方法集,Birth 字段类型还是 JSONDate(值类型),而不是 *JSONDate。传 &p 只影响 p 本身可寻址,不影响字段类型的接口判断。
八、常见误区汇总
| 误区 | 真相 |
|---|---|
| "值和指针都能调用对方的方法" | 只有指针能调用值方法,值不能调用指针方法(接口层面) |
"类型定义 type A B 会继承 B 的方法" | 不会。type A B 是新类型,方法集为空;type A = B(别名)才共享方法集 |
"传 &p 能让指针接收者的 MarshalJSON 生效" | 不能。json 检查的是字段类型的方法集,跟传不传指针无关 |
| "MarshalJSON 不实现就会报错" | 不会报错,走默认逻辑(struct 反射),私有字段不可见就输出 {} |
| "方法调用能编译通过 = 接口就满足" | 不等于。方法调用编译器会自动取址,接口判断不会 |
九、延伸:time.Time 是怎么被序列化的
time.Time 能直接被 json 序列化成 RFC3339 字符串,正是因为它自己实现了 MarshalJSON:
// time/time.go
func (t Time) MarshalJSON() ([]byte, error) { ... } // 值接收者
注意它用的也是值接收者。这就是为什么 time.Time 字段无论声明成值还是指针,都能正确序列化。
而我们的 JSONDate 是 type JSONDate time.Time 定义的新类型,不继承 time.Time 的 MarshalJSON,方法集是空的,必须自己实现。这就是为什么需要手写 MarshalJSON 的根本原因。
总结
这次踩坑把几个 Go 核心概念串了起来:
encoding/json通过reflect.Type.Implements()检查接口,纯类型层面,与 value 是否可寻址无关- Go 方法集规则:
*T方法集 ⊇T方法集,指针能调值方法,值不能调指针方法 - 方法调用 ≠ 接口满足:编译器对方法调用会自动取址,接口判断不会
- 类型定义不继承方法集:
type A B是新类型,type A = B才是别名 - 标准写法:
MarshalJSON用值接收者,UnmarshalJSON用指针接收者
记住一句话就够了:
序列化看值方法集,反序列化看指针方法集。
理解了这条规则,Go 里大量跟接口、反射相关的"为什么没被调用"的问题都能自己解释了。