《Go 语言编程入门》13.2 请求解析、JSON 与响应

上一节把路由挂上了,本节处理路由背后的数据进出。讲清 json.Decoder 与 Unmarshal 的区别、用 MaxBytesReader 限制请求体、DisallowUnknownFields 开启严格解析、查询参数分页,以及写响应时 Encoder 自动转义、自动换行、WriteHeader 只能调一次等容易踩的坑。

13.2 请求解析、JSON 与响应

13.1 把路由搭好了,但每个处理器内部都是「收字符串、回字符串」的骨架。真实服务的两端是结构化数据:客户端 POST 一段 JSON,服务端解析成结构体;服务端再编码成 JSON 回写。本节把这两条数据通道讲透,重点放在标准库那些「默认行为会咬人」的细节上。

本节把 TaskAPI 推进到:为 /tasks 的每个端点补上严格的请求体解析、体积上限、查询参数分页,以及统一的 JSON 响应封装,让 API 的输入输出变得可预测。

13.2.1 json.Unmarshal 还是 json.Decoder

把一个 JSON 请求体解析成结构体,有两种常见写法。第一种是先把整个 body 读进内存再 Unmarshal:

data, err := io.ReadAll(r.Body)
if err != nil {
	writeErr(w, http.StatusBadRequest, "read body failed")
	return
}
var in CreateInput
if err := json.Unmarshal(data, &in); err != nil {
	writeErr(w, http.StatusBadRequest, "invalid json")
	return
}

第二种是直接把 r.Body 交给 json.Decoder:

var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
	writeErr(w, http.StatusBadRequest, "invalid json")
	return
}

两者的取舍用一张表说清:

维度Unmarshal(ReadAll(body))Decoder.Decode(body)
内存占用先整块读入,峰值高边读边解,峰值低
是否需要 []byte需要,便于再读一次不需要
拒绝多余内容天然拒绝尾部垃圾默认忽略,需自己查 More()
额外能力无可流式解多条、可 DisallowUnknownFields

服务端请求体推荐用 Decoder:它是流式的,不会因为一个超大 body 就先把内存打满,而且能挂上严格解析选项。Unmarshal 更适合你手上已经有一份 []byte 的场景,比如读配置文件。

13.2.2 用 MaxBytesReader 封顶请求体

Decoder 流式读取省内存,但省不了「无限大」——如果客户端一直发,你就一直读。标准库给了 http.MaxBytesReader,超限时读取返回错误:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB 上限

超限的错误是一个专门的类型,可以精确判别并回 413:

_, err := io.ReadAll(r.Body)
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
	fmt.Println("limit:", mbe.Limit) // 1048576
}

实测确认:errors.As 能把它取出来,Limit 字段就是设定的字节数。注意它必须包在 Decoder 之前——先设上限,再解 JSON,否则 Decoder 会把超限当成普通的解析错误。

13.2.3 DisallowUnknownFields:把拼错的字段挡在门外

默认情况下,JSON 里多出来的字段会被静默忽略:

var t T
_ = json.Unmarshal([]byte(`{"title":"a","extra":1}`), &t)
// t.Title == "a","extra" 被丢掉,没有任何提示

对客户端来说是宽容,对调试却是灾难:客户端把 titel 拼错,服务端照单全收,字段却是空的,双方都不知道哪里错了。开启严格模式即可让这种请求直接失败:

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(&in)
// err: json: unknown field "extra"

实测结果就是上面这行错误文本。对外 API 建议开启,把字段拼写错误变成 400,而不是一个「悄悄没生效」的 bug。代价是它对客户端更严格,字段一旦废弃就要走版本化流程,不能随便删。

13.2.4 查询参数与分页

路径参数用 r.PathValue,而 ?limit=10&offset=20 这类查询参数走 r.URL.Query():

q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
done := q.Get("done") == "true"

q.Get 返回的永远是字符串,缺省时是空串(不是错误)。解析数字要自己 strconv.Atoi,并对非法值做兜底。TaskAPI 的分页处理器:

func (a *API) list(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	limit := 20
	if v, err := strconv.Atoi(q.Get("limit")); err == nil && v > 0 && v <= 100 {
		limit = v
	}
	all := a.sorted()
	if len(all) > limit {
		all = all[:limit]
	}
	writeJSON(w, http.StatusOK, all)
}

这里刻意给 limit 设了 1..100 的边界:永远不要相信客户端传来的上限,否则一个 ?limit=100000000 就能让服务端去构造一个巨大切片。这和 13.2.2 的体积上限是同一个思路——防御要落在每一个入口。

13.2.5 写响应:三个容易踩的坑

写响应用 json.NewEncoder(w).Encode(v) 最省事,但它的默认行为里有三个坑。

坑一:自动转义 HTML。 Encoder 默认把 <、>、& 转成 \u003c 这类 Unicode 转义:

enc := json.NewEncoder(&buf)
_ = enc.Encode(T{Title: "<b>&</b>"})
// {"title":"\u003cb\u003e\u0026\u003c/b\u003e"}

实测确认。这对 HTML 场景是防 XSS 的好意,但纯 API 里会让客户端看到一堆转义符。关掉它:

enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
// {"title":"<b>&</b>"}

坑二:Encode 会自动追加换行。 Encode 在写完 JSON 后补一个 \n(实测 strings.HasSuffix(out, "\n") 为 true)。大多数客户端不在乎,但如果你在断言响应体字节数,别忘了这个换行。想完全控制字节,用 json.Marshal 再 w.Write。

坑三:WriteHeader 只能调用一次。 一旦写入了状态码或响应体,再次 WriteHeader 会打一条 superfluous response.WriteHeader call 日志,并且被忽略:

w.WriteHeader(201)
_, _ = w.Write([]byte("x"))
w.WriteHeader(500) // 无效,状态码仍是 201

所以正确顺序永远是:先设 Header → 再 WriteHeader → 最后写 body。这也是为什么 writeJSON 里 w.Header().Set(...) 一定在 w.WriteHeader(code) 之前。

13.2.6 统一的响应封装

散落各处的 map[string]string{"error": msg} 很快会失控。TaskAPI 统一成一个响应结构:

type errorResponse struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	enc := json.NewEncoder(w)
	enc.SetEscapeHTML(false)
	_ = enc.Encode(v)
}

func writeErr(w http.ResponseWriter, code int, msg string) {
	writeJSON(w, code, errorResponse{Code: http.StatusText(code), Message: msg})
}

把编码细节全部收进这两个函数,处理器里就只剩业务逻辑。第 15.3 节会把错误响应扩展成「领域错误 → HTTP 状态码」的完整映射,这里先埋下 Code 字段的伏笔。

13.2.7 完整示例:一个严格的 create 处理器

把本节的所有要点组装起来,这就是 TaskAPI 的 POST /tasks:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

type createInput struct {
	Title string `json:"title"`
}

func handleCreate(w http.ResponseWriter, r *http.Request) {
	if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		writeErr(w, http.StatusUnsupportedMediaType, "expected application/json")
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

	var in createInput
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil {
		var mbe *http.MaxBytesError
		if errors.As(err, &mbe) {
			writeErr(w, http.StatusRequestEntityTooLarge, "body too large")
			return
		}
		writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
		return
	}
	if strings.TrimSpace(in.Title) == "" {
		writeErr(w, http.StatusUnprocessableEntity, "title required")
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{"title": in.Title})
}

用 httptest 实测四种输入:

{"title":"写书"}            -> 201
{"title":"x","bogus":1}    -> 400  unknown field "bogus"
Content-Type: text/plain   -> 415
{"title":""}               -> 422

这四种状态码的分工值得记住:415 是格式类型不对(Content-Type),400 是解析失败(JSON 语法或未知字段),422 是语义校验失败(能解析但值不合法)。把三者分开,客户端才能写出准确的错误提示。

13.2.8 小结

  • 请求体用 json.Decoder(流式、可严格),Unmarshal 留给手头已有 []byte 的场景。
  • http.MaxBytesReader 给体积封顶,用 errors.As 取 *http.MaxBytesError 回 413。
  • DisallowUnknownFields 让拼错的字段变成 400,而不是静默丢失。
  • 查询参数一律当字符串,数字自己转,并且对上限做边界检查。
  • 写响应用 Encoder 注意三点:默认转义 HTML(可 SetEscapeHTML(false))、自动加换行、WriteHeader 只能一次。
  • 状态码语义分层:415 类型错、400 解析错、422 校验错。

下一节处理所有请求的公共部分:日志、恢复、请求 ID。把这些横切关注点从每个处理器里抽出来,就是中间件。

阅读导航:上一节:13.1 Handler、ServeMux 与路由 · 下一节:13.3 中间件与访问日志 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练