在 Web 开发中,我们经常需要通过 GET 请求传递结构化的查询条件,比如列表页的筛选参数、分页排序规则等。这类数据天然适合用 JSON 表达,但 URL 本身对特殊字符有严格的编码限制。Go 标准库提供了 net/url 和 encoding/json 两个包,把它们组合起来就能稳妥地完成解析工作,但其中有不少细节容易被忽视,比如加号被当作空格、中文未解码、结构体字段映射错误等。本文将从基础用法讲到进阶封装,帮你把这块逻辑写得干净可靠。

基础方案:先解码再反序列化
最直接的做法是:客户端把 JSON 字符串进行 URL 编码后拼到查询参数里,服务端取出参数后先调用 url.QueryUnescape 解码,再用 json.Unmarshal 反序列化到结构体。整个流程分为两步,第一步还原出原始的 JSON 文本,第二步把 JSON 文本映射到 Go 的结构体。下面是一段完整的服务端示例:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
)
// FilterParams 定义筛选参数的结构体
type FilterParams struct {
Category string `json:"category"`
Tags []string `json:"tags"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func parseJSONQuery(r *http.Request) (*FilterParams, error) {
raw := r.URL.Query().Get("filter")
if raw == "" {
return nil, fmt.Errorf("缺少 filter 参数")
}
// 第一步:URL 解码,还原 JSON 原文
decoded, err := url.QueryUnescape(raw)
if err != nil {
return nil, fmt.Errorf("URL 解码失败: %w", err)
}
// 第二步:反序列化到结构体
var params FilterParams
if err := json.Unmarshal([]byte(decoded), ¶ms); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %w", err)
}
return ¶ms, nil
}
func handler(w http.ResponseWriter, r *http.Request) {
params, err := parseJSONQuery(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Fprintf(w, "分类: %s, 标签: %v, 页码: %d", params.Category, params.Tags, params.Page)
}
func main() {
http.HandleFunc("/list", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
需要特别注意的是,客户端拼接 URL 时必须使用 url.QueryEscape 进行编码。很多初学者直接把 JSON 字符串拼进 URL,一旦 JSON 里含有 {、"、空格或中文,服务端取到的数据就已经是残缺的了。正确的客户端写法示例如下:
params := map[string]interface{}{
"category": "电子产品",
"tags": []string{"手机", "数码"},
"page": 1,
}
b, _ := json.Marshal(params)
encoded := url.QueryEscape(string(b))
requestURL := "http://127.0.0.1:8080/list?filter=" + encoded
这样编出来的 URL 中所有特殊字符都会被百分号编码,服务端解析时就能完整还原。这个方案虽然简单,但有几个隐患:一是参数缺失时结构体字段会是零值,无法区分用户没传和传了零值;二是嵌套层级深时错误信息不够友好。这些问题我们后面逐一解决。
常见坑点分析:加号、中文与多重编码
第一个坑是加号问题。在查询字符串的编码规范中,空格可以被编码成加号,也就是说 QueryEscape 会把空格转成 +,解码时 QueryUnescape 又会把 + 还原成空格。如果客户端用的是 base64.URLEncoding 之外的普通 base64 编码,编码结果里可能包含 +,经过一次解码就会被错误地替换成空格,导致解析失败。解决办法有两种:要么统一使用 URL 安全的 base64(base64.URLEncoding,它用 - 和 _ 替代 + 和 /),要么干脆只用 JSON 加百分号编码,不引入 base64 这一层。
第二个坑是中文参数。如果客户端没有做编码直接把中文塞进 URL,Go 的 http.ListenAndServe 在解析请求时通常会自动处理,但某些网关或代理会先做一次转码,造成多重编码。此时服务端拿到的字符串可能是 %2526 这种被编码了两次的形式,一次解码后仍是 %26。遇到这种情况,可以在解码后检查字符串是否仍然含有 % 开头的转义序列,必要时循环解码,但要设置最大循环次数防止死循环:
func safeUnescape(s string) (string, error) {
for i := 0; i < 3; i++ {
decoded, err := url.QueryUnescape(s)
if err != nil {
return "", err
}
if decoded == s {
return decoded, nil // 已经无法继续解码
}
s = decoded
}
return s, nil
}
第三个坑是大小写和字段映射。json.Unmarshal 默认对字段名大小写不敏感,这意味着 Category、category、CATEGORY 都能匹配到同一个字段。这个特性有时是便利,有时却是隐患:如果客户端参数拼写错误但恰好大小写匹配,服务端不会报错,可能导致难以排查的 bug。建议始终在结构体上显式声明 json 标签,并配合 json.Decoder 的 DisallowUnknownFields 方法来发现拼写错误的字段。
进阶封装:支持嵌套结构与默认值
实际项目中,筛选条件往往是嵌套的,比如价格区间是一个对象,排序是一个数组。我们可以写一个通用的泛型解析函数,配合指针类型字段来区分零值和未传值,同时提供默认值兜底逻辑。Go 1.18 之后可以用泛型消除重复代码:
package main
import (
"encoding/json"
"net/http"
"net/url"
)
type Range struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
}
type SortRule struct {
Field string `json:"field"`
Order string `json:"order"` // asc 或 desc
}
type AdvancedFilter struct {
Keyword *string `json:"keyword"` // 指针类型区分未传值
Price *Range `json:"price"`
Sort []SortRule `json:"sort"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ParseJSONQuery 泛型解析函数
func ParseJSONQuery[T any](r *http.Request, key string, defaults *T) (*T, error) {
raw := r.URL.Query().Get(key)
if raw == "" {
if defaults != nil {
return defaults, nil
}
var zero T
return &zero, nil
}
decoded, err := url.QueryUnescape(raw)
if err != nil {
return nil, err
}
var result T
if err := json.Unmarshal([]byte(decoded), &result); err != nil {
return nil, err
}
return &result, nil
}
func listHandler(w http.ResponseWriter, r *http.Request) {
defaults := &AdvancedFilter{Page: 1, PageSize: 20}
filter, err := ParseJSONQuery(r, "filter", defaults)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
page := filter.Page
if page <= 0 {
page = 1
}
// 后续业务逻辑...
_ = page
}
这个封装有几个设计要点。第一,用指针字段表达可选参数:如果客户端没传 keyword,解析后它是 nil;传了空字符串则是指向空串的指针,两者语义完全不同。第二,默认值通过参数注入而不是硬编码,测试时可以灵活替换。第三,解析逻辑与业务处理分离,任何 handler 都能复用这个泛型函数。
如果要进一步校验参数合法性,比如页码范围、排序字段白名单,可以在反序列化之后追加一层校验函数,或者引入第三方的校验库。但务必把校验错误和解析错误区分开返回不同的状态码,解析失败返回 400,校验不通过返回 422,这样前端能更准确地提示用户。
方案取舍:查询参数传 JSON 是否合理
最后需要讨论一个架构层面的问题:把 JSON 塞进查询参数到底是不是好实践。它的优点是客户端只需要一次 GET 请求就能传递复杂条件,且条件可以被收藏、被分享,这对列表页很友好。缺点也很明显:URL 长度受浏览器和服务器限制(一般建议控制在 2000 字符以内),条件一多就容易超限;另外 GET 请求通常不会被 CDN 缓存掉含长查询串的响应,缓存命中率会下降。
一个折中的做法是:简单筛选用普通查询参数逐项传递(如 ?category=phone&page=1),只有动态组合的复杂条件才用 JSON 参数。如果条件复杂到 JSON 编码后超过 URL 长度限制,那就应该改成 POST 请求,把条件放进请求体,语义上也更符合 RESTful 风格——查询条件本质上是发给服务端的一份数据,用请求体承载并不会有语义冲突,只是失去了可收藏性。
总结一下要点:编码与解码必须成对使用 QueryEscape 和 QueryUnescape;注意加号和多重编码的坑;结构体显式声明 json 标签;用指针字段和默认值机制让参数语义更清晰;超长条件及时切换为 POST。把这些细节处理好,Go 中解析 URL 里的 JSON 查询参数就不再是难事。