Go JSON 解码细节:字段缺失、未知字段、流式读取和接口边界

本文深入讲解 Go JSON 解码的常见细节,包括字段缺失、零值、未知字段、Decoder、RawMessage 和接口请求体处理。

JSON 能解析成功,不代表业务就安全

前面学习 Go JSON 时,我们通常会写:

var req CreateUserRequest
err := json.NewDecoder(r.Body).Decode(&req)

这确实是 Go Web API 里很常见的代码。但真实接口里,JSON 解码有很多细节:字段缺失时是什么值?客户端多传字段会怎样?数字会不会溢出?请求体里有两个 JSON 对象怎么办?空字符串和字段不存在能不能区分?这些问题不处理,接口表面能跑,边界却不稳。

这篇文章专门讲 Go encoding/json 解码的入门细节。我们会用用户注册请求做例子,整理一套更可靠的请求体读取方式。目标不是把 JSON 包讲成手册,而是让你知道 API 边界上哪些地方需要显式处理。

字段缺失会保留零值

请求结构体:

type CreateUserRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
	Age      int    `json:"age"`
}

如果客户端发送:

{"email":"xiaolin@example.com"}

解码后:

var req CreateUserRequest
err := json.Unmarshal(data, &req)
fmt.Printf("%+v\n", req)

Password 是空字符串,Age 是 0。字段缺失不会自动报错。Go 的 JSON 包只负责把能匹配的字段填进去,不负责业务校验。

所以你必须在解码后校验:

func (r CreateUserRequest) Validate() error {
	if strings.TrimSpace(r.Email) == "" {
		return fmt.Errorf("email is required")
	}
	if strings.TrimSpace(r.Password) == "" {
		return fmt.Errorf("password is required")
	}
	if r.Age < 0 {
		return fmt.Errorf("age must be positive")
	}
	return nil
}

JSON 解码和业务校验是两件事。不要以为解码成功就代表请求合法。

区分字段缺失和零值

有时零值本身是合法值。比如更新用户年龄,0 可能表示婴儿年龄,也可能表示客户端没传字段。用普通 int 无法区分:

type UpdateUserRequest struct {
	Age int `json:"age"`
}

可以使用指针:

type UpdateUserRequest struct {
	Age *int `json:"age"`
}

解码后:

if req.Age != nil {
	fmt.Println("client wants to update age:", *req.Age)
}

如果字段不存在,Age 是 nil;如果字段存在且值为 0,Age 指向 0。这在 PATCH 类接口里很常见。

字符串也一样:

type UpdateProfileRequest struct {
	Nickname *string `json:"nickname"`
}

这样可以区分“没传 nickname”和“传了空字符串,希望清空 nickname”。是否允许清空,再交给业务校验。

默认允许未知字段

默认情况下,客户端多传字段不会报错:

{"email":"a@example.com","password":"secret","role":"admin"}

如果结构体没有 role 字段,encoding/json 会忽略它。对一些公开 API 来说,这可能太宽松。你可以使用 Decoder.DisallowUnknownFields()

decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()

var req CreateUserRequest
if err := decoder.Decode(&req); err != nil {
	return fmt.Errorf("invalid json: %w", err)
}

这样客户端传未知字段会解码失败。它能帮助你尽早发现拼错字段名的问题,比如 passwrod。但也要考虑兼容性:如果你的 API 希望允许客户端携带未来字段,就不要开启。

内部管理接口、严格配置文件、命令行工具配置,通常适合禁止未知字段。面向外部的宽松接口要按产品策略判断。

防止请求体过大

HTTP handler 里不要无限读取请求体。可以用 http.MaxBytesReader

func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
	defer r.Body.Close()

	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(dst); err != nil {
		return err
	}
	return nil
}

限制请求体大小能防止客户端发送超大 JSON 占用内存。具体大小要看接口需求。普通注册、登录、配置接口往往几十 KB 都够,上传文件不要走 JSON 请求体。

防止多个 JSON 值

Decode 只解一个 JSON 值。如果请求体是:

{"email":"a@example.com"}{"email":"b@example.com"}

第一次 Decode 可能成功,后面还剩数据。更严谨的做法是再解一次,确认已经到 EOF:

if err := decoder.Decode(dst); err != nil {
	return err
}

if err := decoder.Decode(&struct{}{}); err != io.EOF {
	return fmt.Errorf("body must contain only one JSON value")
}

这个细节很多入门代码不会写,但在严格 API 边界上很有用。它避免客户端悄悄发送多段 JSON,让服务端只处理第一段。

RawMessage 适合延迟解析

有时 JSON 里有一段字段结构取决于类型:

{
  "type": "email",
  "payload": {
    "to": "a@example.com",
    "subject": "hello"
  }
}

可以先把 payload 保留为 json.RawMessage

type Event struct {
	Type    string          `json:"type"`
	Payload json.RawMessage `json:"payload"`
}

根据类型再解析:

var event Event
if err := json.Unmarshal(data, &event); err != nil {
	return err
}

switch event.Type {
case "email":
	var payload EmailPayload
	if err := json.Unmarshal(event.Payload, &payload); err != nil {
		return err
	}
	fmt.Println(payload.To)
default:
	return fmt.Errorf("unknown event type: %s", event.Type)
}

RawMessage 适合处理 Webhook、事件流、插件配置这类“外层固定、内层按类型变化”的数据。

小结

Go 的 JSON 解码很方便,但 API 边界不能只靠解码成功判断合法性。字段缺失会变成零值,未知字段默认被忽略,请求体可能过大,也可能包含多个 JSON 值。你需要根据接口场景决定是否使用指针字段、DisallowUnknownFieldsMaxBytesReader 和额外 EOF 检查。

一个可靠的请求处理流程通常是:限制大小,创建 Decoder,按需要禁止未知字段,Decode 到结构体,确认没有多余 JSON,最后执行业务校验。这样写比最短示例多几行,但能挡住很多边界问题。

JSON 是后端接口最常见的入口之一。入口处理越清楚,后面的业务代码就越不用猜。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页