在 Go 中使用 encoding/json 读写 JSON
1. 概述
JSON 是服务之间最常见的数据交换格式。Go 在标准库中通过 encoding/json 包原生支持 JSON,无需安装任何额外的库。
本文将帮助你将 struct 转换为 JSON 反之亦然,通过 struct tag 控制字段名称,处理动态 JSON 和流式传输大型数据。你还将学会如何编写自定义类型并对其进行测试。本文最后会介绍常见的错误,帮助你在将代码上线到生产环境时避免这些问题。
本文中的示例使用 Go 1.24 或更高版本。
2. 使用 json.Marshal 写入 JSON
我们从一个包含客户信息的订单开始:
package main
import (
"encoding/json"
"fmt"
"log"
)
type Customer struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"`
Items []string `json:"items"`
Total float64 `json:"total"`
Note string `json:"-"`
createdBy string
}
func main() {
order := Order{
ID: 1001,
Customer: Customer{Name: "Lan"},
Items: []string{"咖啡", "面包"},
Total: 45000,
Note: "9点前送达",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
结果:
{"id":1001,"customer":{"name":"Lan"},"items":["咖啡","面包"],"total":45000}
上面的代码有几个需要注意的地方:
json.Marshal返回[]byte和error。你必须始终检查error。json:"id"tag 将字段名改为 JSON 中的id。如果没有 tag,Go 使用原始字段名ID。- 空的
Email被省略了,这归功于omitempty。 Note有json:"-"tag,所以总是被跳过。createdBy以小写字母开头,这意味着它是 unexported。encoding/json看不到这个字段。- 中文字符保持 UTF-8 形式,不会被转义。
如果需要易读的 JSON,比如用于日志或配置文件,你可以使用 json.MarshalIndent:
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tag:细粒度控制输出
json tag 的格式是 json:"名称,选项,...". 常用的选项有:
| Tag | 说明 |
|---|---|
json:"name" |
更改字段名称 |
json:",omitempty" |
如果字段是零值则省略:""、0、false、nil、空 slice 或空 map |
json:",omitzero" |
如果字段是零值则省略,包括像 time.Time 这样的 struct(Go 1.24+) |
json:",string" |
将数字或布尔值写成字符串 |
json:"-" |
始终跳过 |
omitempty 对 struct 没有作用。下面的例子展示了区别:
type Shipment struct {
OrderID int64 `json:"order_id,string"`
ShippedAt time.Time `json:"shipped_at,omitempty"`
PaidAt time.Time `json:"paid_at,omitzero"`
}
data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}
ShippedAt 仍然出现,因为 omitempty 不认为空的 time.Time struct 是“empty”。PaidAt 使用 omitzero,所以按预期被省略了。OrderID 由于 string 选项被写成了字符串 "1001"。这种方法在客户端是 JavaScript 且无法准确读取 64 位整数时很有用。
4. 使用 json.Unmarshal 读取 JSON
反向操作时,你需要传入指向目标变量的指针:
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["茶叶"],
"total": 20000,
"coupon": "SALE10"
}`)
var order Order
if err := json.Unmarshal(input, &order); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[茶叶] Total:20000 Note: createdBy:}
你会注意到两个默认行为:
- 键匹配是不区分大小写的。
"Customer"仍然会匹配 tagcustomer。 - 未知的键如
"coupon"被默默忽略。JSON 中没有的字段保留其当前值。
注意: 如果你传递的是值而不是指针,比如
json.Unmarshal(input, order),你会收到错误json: Unmarshal(non-pointer main.Order)。数据根本不会被写入任何地方。
4.1. 拒绝未知字段
对于接收来自客户端请求的 API,忽略未知键通常会掩盖字段名拼写错误。你可以通过 json.Decoder 启用严格模式:
dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()
var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"
DisallowUnknownFields 只在 Decoder 上可用,json.Unmarshal 没有等效的选项。
5. 动态 JSON 和 map[string]any
当不知道 JSON 的结构时,比如来自第三方的 webhook,你可以将其 decode 到 map[string]any:
var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)
fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992
所有数字都被 decode 为 float64。因此 order_id 失去了精度:原始值的最后一位是 3,但结果的最后一位是 2。这是一个很难发现的错误。
解决办法是调用 UseNumber,这样数字会以 json.Number 字符串形式保留:
dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()
var payload map[string]any
_ = dec.Decode(&payload)
id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993
如果只有部分 payload 是动态的,你可以使用 json.RawMessage 延迟该部分的 decode:
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
读取 Type 后,你调用 json.Unmarshal(e.Data, &order) 与合适的 struct。相比在 map 中逐个查找键,这种方法的类型安全性要高得多。
6. 使用 Encoder 和 Decoder 流式传输数据
Marshal 和 Unmarshal 处理 []byte,所以所有数据都必须在内存中。json.Encoder 和 json.Decoder 直接与 io.Writer 和 io.Reader 交互。它们适合 HTTP body、文件和 NDJSON 数据(每行一个 JSON)。
func main() {
input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)
dec := json.NewDecoder(input)
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
sum := 0.0
for {
var o Order
err := dec.Decode(&o)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
sum += o.Total
}
_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}
循环调用 Decode 直到遇到 io.EOF,每次读取一个对象。Encode 在每个值后自动添加换行符。SetEscapeHTML(false) 防止 Go 将 <、>、& 转换为 \u003c、\u003e、\u0026。默认情况下,Go 会转义这些字符以在将 JSON 嵌入 HTML 时保证安全。
在 HTTP 处理器中,你使用相同的模式:
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 最多 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
http.MaxBytesReader 限制了 body 的大小。这样客户端就无法发送巨大的 payload 来耗尽服务器内存。
7. 自定义类型
假设订单状态在代码中存储为 int,但必须在 JSON 中显示为字符串。你实现 encoding.TextMarshaler 和 encoding.TextUnmarshaler:
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusShipped
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusPaid: "paid",
StatusShipped: "shipped",
}
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("invalid status: %d", s)
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for k, v := range statusNames {
if v == string(b) {
*s = k
return nil
}
}
return fmt.Errorf("invalid status: %q", b)
}
在 struct 中使用时:
type OrderStatus struct {
ID int `json:"id"`
Status Status `json:"status"`
}
data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}
MarshalText 使用值接收器,所以既适用于 Status 也适用于 *Status。UnmarshalText 必须使用指针接收器,因为它需要修改值。使用 TextMarshaler 而不是 json.Marshaler 还有额外的好处:Status 类型可以用作 map 的键,比如 map[Status]int。
当需要控制整个对象时,你编写 MarshalJSON。要小心无限递归的错误:如果在内部调用 json.Marshal(o) 且传入同一类型,函数会无限递归调用。处理方法是使用别名类型:
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // 别名没有 MarshalJSON 方法
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias 有和 Order 相同的字段但没有方法,所以 json.Marshal 使用默认行为。嵌入字段被“展平”到外部对象,加上额外的 item_count。
8. 编写测试
编写往返测试,即写入然后读回,能帮助你捕捉当有人修改 tag 或自定义逻辑时的错误。文件 status_test.go:
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestStatusRoundTrip(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusPending, `{"id":1,"status":"pending"}`},
{StatusPaid, `{"id":1,"status":"paid"}`},
{StatusShipped, `{"id":1,"status":"shipped"}`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
if err != nil {
t.Fatalf("marshal error: %v", err)
}
if string(data) != tt.want {
t.Errorf("got %s, want %s", data, tt.want)
}
var back OrderStatus
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if back.Status != tt.status {
t.Errorf("got %v, want %v", back.Status, tt.status)
}
})
}
}
func TestRejectUnknownField(t *testing.T) {
dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
dec.DisallowUnknownFields()
var o OrderStatus
if err := dec.Decode(&o); err == nil {
t.Fatal("expected unknown field error, got nil")
}
}
go test ./...
ok example.com/shop 0.003s
表驱动的测试是 Go 中的常见写法。每个 case 在自己的 t.Run 中运行,所以当一个 case 失败时,你能立即看到是哪个 case。
9. 常见错误
- 字段没有出现在 JSON 中。 几乎总是因为字段名以小写字母开头(unexported)。请改为大写字母并使用 tag 设置 JSON 名称。
null而不是[]。 nil slice 被写成null。如果客户端需要空数组,请初始化Items: []string{}。- 大数字精度丢失。 在 decode 到
any时发生。请使用有具体类型的 struct(int64)、UseNumber或 tag,string。 - 数据类型错误被吞掉。 当
Unmarshal遇到*json.UnmarshalTypeError时,它仍然会填充其他字段。如果err != nil,就不要使用这个 struct。 []byte变成 base64 字符串。 这是有意的行为。如果你想要普通文本,请使用string。time.Time按 RFC 3339 格式化。 如果要用dd/MM/yyyy这样的其他格式,你必须编写自定义类型。- 在
MarshalJSON中调用json.Marshal处理同一类型。 结果是栈溢出。请像第 7 部分那样使用别名类型。
注意:
encoding/json使用 reflection,所以性能不是最快的。对于大多数 API,它的速度完全足够。只有当 profiling 显示 JSON 是性能瓶颈时,你才应该考虑代码生成库。Go 1.25 还有encoding/json/v2的实验版本,通过GOEXPERIMENT=jsonv2启用。这个版本还不稳定,不适合生产环境。
10. 总结
你已经学过了 encoding/json 的主要操作:
- 对小数据使用
Marshal和Unmarshal。 - 用 struct tag(
omitempty、omitzero、string、-)控制输出。 - 用
Decoder配合DisallowUnknownFields和UseNumber更安全地读取数据。 - 用
Encoder和Decoder流式传输数据。 - 用
TextMarshaler或MarshalJSON处理自定义类型。
通用原则是优先使用有明确类型的 struct 而不是 map[string]any,并始终检查 error。你还应该为自定义类型编写往返测试。这三个习惯能帮助你避免 Go 服务中的大多数 JSON 错误。