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 中间件与访问日志 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。