JSON 与输入校验
JSON 与输入校验 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这课守住服务的入口:把请求体解码成结构体,逐字段校验必需、长度和格式,限制请求体大小,让所有无效输入都得到一致形状的错误响应。
问题场景
G05 的服务只读不写,一加 POST /bookmarks,麻烦全来了:body 不是 JSON、title 是空串、url 缺协议、字段名拼错、body 塞了 2GB。只读接口没有输入所以无所谓,写入接口的每一行都要先过安检。
核心解释
解码和校验收成一个纯函数,handler 只管调用:
type bookmarkInput struct {
Title string `json:"title"`
URL string `json:"url"`
Tags []string `json:"tags"`
}
func parseBookmark(r *http.Request) (bookmarkInput, error) {
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 上限 1MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in bookmarkInput
if err := dec.Decode(&in); err != nil {
return in, fmt.Errorf("invalid JSON body: %w", err)
}
if strings.TrimSpace(in.Title) == "" {
return in, errors.New("title is required")
}
if utf8.RuneCountInString(in.Title) > 100 {
return in, errors.New("title too long (max 100 chars)")
}
u, err := url.Parse(in.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return in, fmt.Errorf("url must be an absolute http(s) link, got %q", in.URL)
}
if len(in.Tags) > 10 {
return in, errors.New("too many tags (max 10)")
}
return in, nil
}
四道闸门按成本从低到高排:MaxBytesReader 在读取阶段掐掉超大 body,超限时 Decode 返回错误,不会先把 2GB 读进内存;DisallowUnknownFields 把客户端拼错的字段名(titel)当场拒绝,而不是静默丢弃;必填与长度校验防脏数据入库;url.Parse 配合 scheme 检查挡掉 javascript: 这类链接。结构体字段带 json 标签,解码按标签匹配,字段首字母小写也能从 JSON 接进来。
错误响应统一形状,前端只写一套处理:
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
in, err := parseBookmark(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := store.Add(Bookmark{Title: in.Title, URL: in.URL, Tags: in.Tags})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
})
两条原则:可预期的输入问题一律 400 并把原因写进 error 字段,客户端能改的才配叫 400;内部错误不外泄细节,只说 internal error,err 的内容进日志。创建成功用 201 而不是 200,语义更准。校验顺序也有讲究:先拦结构问题(JSON 能不能解码、字段名对不对),再查语义问题(值合不合法),错误信息按这个顺序返回,客户端修起来最快。
示例拆解
go run .
curl -i -X POST http://127.0.0.1:8080/bookmarks \
-d '{"title":"Go","url":"https://go.dev","tags":["go"]}'
# 201 Created,{"id":2}
curl -i -X POST http://127.0.0.1:8080/bookmarks -d '{"url":"https://go.dev"}'
# 400,{"error":"title is required"}
curl -i -X POST http://127.0.0.1:8080/bookmarks -d '{"title":"Go","url":"ftp://go.dev"}'
# 400,{"error":"url must be an absolute http(s) link, got \"ftp://go.dev\""}
curl -i -X POST http://127.0.0.1:8080/bookmarks \
-d '{"title":"Go","titel":"typo","url":"https://go.dev"}'
# 400,invalid JSON body: unknown field "titel"
四条请求覆盖通过、缺字段、坏格式、拼错字段。错误信息里带着「收到的值」,客户端能自己定位问题,这就是校验信息的价值。
动手任务
- 在 parseBookmark 里加 tags 清洗:去重并过滤空串,输入 ["go","go",""] 存成 ["go"]。
- 把 1MB 上限临时调成 100 字节,发一个大 body,观察 400 的错误内容,再改回来。
- 写一个 normalizeURL 小函数(去掉尾部斜杠),在校验前调用,用三种输入验证。
常见错误
- 校验逻辑直接写在 handler 里越堆越长:解码校验收进 parseXxx 纯函数,handler 只做编排,G13 才好测。
- MaxBytesReader 漏掉:一个手滑或恶意的大 body 直接把内存吃光。
- 未知字段静默忽略还觉得宽容:客户端拼错字段名永远得不到反馈,联调时双方都懵。
- 内部错误也把 err.Error() 原样吐给客户端:泄露 SQL、路径等实现细节,日志归日志、响应归响应。
检查与复盘
完成下方检查题。自查清单:
- 每个写入接口都过了 MaxBytesReader 和 DisallowUnknownFields
- 错误响应永远是 {"error": "..."} 一种形状,4xx 表达可修复问题
- 校验逻辑是可独立测试的纯函数,G13 会直接拿来写表驱动测试
参考资料
- 标准库 encoding/json —— Decoder、结构体标签与未知字段行为
- 标准库 json.Decoder —— DisallowUnknownFields 官方说明
- 标准库 http.MaxBytesReader —— 请求体上限的正确用法
读完这一课了?
标记完成后,工作台会安排接下来的学习。