3. HTTP请求解析:使用net/http解析GET/POST请求参数、路径参数、查询参数、请求体

在构建REST API时,请求参数的解析是参数校验的第一道关卡。Go标准库net/http提供了原生且高效的请求解析能力,理解其底层机制是后续校验逻辑正确落地的基石。本章将系统梳理四种核心参数类型的解析方式,并揭示常见陷阱。

3.1 参数类型与解析概览

REST API中,参数通常通过以下四种载体传递,每种载体对应不同的解析策略:

参数类型 传递位置 典型示例 net/http 核心方法
路径参数 URL路径段 /users/42 r.URL.Path + 手动解析
查询参数 URL问号后 /users?page=2&limit=10 r.URL.Query()
表单参数 请求体(POST/PUT) username=alice&age=30 r.ParseForm() + r.Form
JSON/XML请求体 请求体(POST/PUT) {"name":"alice"} json.NewDecoder(r.Body)
核心原则: net/http 不会自动解析请求体。你必须显式调用 ParseForm()、ParseMultipartForm() 或手动读取 r.Body,否则 r.Form、r.PostForm 等字段将为空。

3.2 路径参数解析:手动拆分与标准库局限

Go标准库net/http的默认ServeMux不支持路径参数模式匹配(如/users/:id)。你需要手动从r.URL.Path中提取参数值。

func userHandler(w http.ResponseWriter, r *http.Request) {
    // 假设路由为 /users/{id}
    path := r.URL.Path
    // 去掉前缀 /users/
    idStr := strings.TrimPrefix(path, "/users/")
    if idStr == "" || idStr == path {
        http.Error(w, "invalid path", http.StatusBadRequest)
        return
    }
    // 此时 idStr 即为路径参数值,例如 "42"
    fmt.Fprintf(w, "User ID: %s", idStr)
}
生产环境建议: 手动解析路径参数容易出错且难以维护。在真实项目中,推荐使用 gorilla/mux、chi 或 gin 等第三方路由器,它们原生支持路径参数提取,例如 chi.URLParam(r, "id")。

3.3 查询参数解析:Query() 与 Form() 的差异

查询参数位于URL的?之后,使用r.URL.Query()可以快速获取。但r.Form也包含查询参数,两者存在细微差别。

func searchHandler(w http.ResponseWriter, r *http.Request) {
    // 方法一:仅获取查询参数
    queryParams := r.URL.Query()
    page := queryParams.Get("page")   // "2"
    limit := queryParams.Get("limit") // "10"

    // 方法二:通过 r.Form 获取(需先调用 ParseForm)
    r.ParseForm()
    page2 := r.Form.Get("page") // 同样返回 "2"

    // 关键区别:r.Form 包含查询参数 + 表单参数
    // r.URL.Query() 只包含查询参数
}
方法 数据来源 是否需要显式解析 典型使用场景
r.URL.Query() 仅URL查询字符串 否(直接解析) GET请求,只关心查询参数
r.Form 查询参数 + 表单参数 是(需先调用ParseForm()) 需要同时处理查询和表单参数
r.PostForm 仅表单参数(POST/PUT) 是(需先调用ParseForm()) 只关心POST表单数据,忽略查询参数

3.4 请求体解析:表单与JSON的两种路径

请求体解析是参数校验中最容易出错的环节。根据Content-Type的不同,解析方式截然不同。

3.4.1 表单请求体(application/x-www-form-urlencoded)

func formHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 检查 Content-Type
    contentType := r.Header.Get("Content-Type")
    if !strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
        http.Error(w, "unsupported content type", http.StatusUnsupportedMediaType)
        return
    }

    // 2. 解析表单
    err := r.ParseForm()
    if err != nil {
        http.Error(w, "failed to parse form", http.StatusBadRequest)
        return
    }

    // 3. 获取参数
    username := r.FormValue("username") // 便捷方法,内部调用 ParseForm
    age := r.PostFormValue("age")       // 仅从表单获取,忽略查询参数
}

3.4.2 JSON请求体(application/json)

type CreateUserRequest struct {
    Username string `json:"username"`
    Age      int    `json:"age"`
    Email    string `json:"email"`
}

func jsonHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 检查 Content-Type
    if r.Header.Get("Content-Type") != "application/json" {
        http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
        return
    }

    // 2. 限制请求体大小(防止内存攻击)
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB

    // 3. 解码 JSON
    var req CreateUserRequest
    decoder := json.NewDecoder(r.Body)
    // 禁止未知字段(严格模式)
    decoder.DisallowUnknownFields()
    err := decoder.Decode(&req)
    if err != nil {
        http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
        return
    }

    // 4. 检查是否有多余数据
    if decoder.More() {
        http.Error(w, "request body contains multiple JSON objects", http.StatusBadRequest)
        return
    }

    // 此时 req 结构体已填充,可进行后续校验
    fmt.Fprintf(w, "User: %+v", req)
}
关键实践:
  • 始终使用 http.MaxBytesReader 限制请求体大小,防止大请求耗尽服务器内存。
  • 使用 decoder.DisallowUnknownFields() 拒绝未知字段,避免客户端传错字段名导致静默忽略。
  • 调用 decoder.Decode() 后检查 decoder.More(),确保请求体只包含一个JSON对象。
  • 请求体读取后必须关闭 r.Body,但 net/http 在请求处理结束后会自动关闭,无需手动操作。

3.5 请求解析完整流程:从原始字节到结构化数据

下面的SVG图展示了从HTTP请求到达Go服务器,到参数被解析为结构化数据的完整流程:

HTTP请求解析完整流程 原始HTTP请求 GET/POST + URL + Body 路由匹配 提取路径参数 查询参数解析 r.URL.Query() 请求体解析 ParseForm / JSON Decode 参数合并 r.Form / 结构体 参数校验 类型/范围/必填 业务逻辑处理 ⚠ 常见陷阱 • 忘记调用 ParseForm() • 未限制请求体大小 • 未检查 Content-Type • 路径参数手动解析易错

3.6 常见陷阱与最佳实践

陷阱1:多次调用 ParseForm 是安全的
r.ParseForm() 可以多次调用,第二次调用不会重复解析,也不会报错。但 r.Body 只能读取一次,JSON解码后无法再次读取。
陷阱2:r.Form 包含查询参数和表单参数
如果URL中同时包含查询参数和表单参数,r.Form.Get("key") 会返回查询参数的值(查询参数优先级更高)。使用 r.PostFormValue() 可以只获取表单参数。
最佳实践清单:
  1. 始终检查 Content-Type:在解析请求体前验证 Content-Type 头,返回 415 Unsupported Media Type 拒绝不支持的格式。
  2. 限制请求体大小:使用 http.MaxBytesReader 防止大请求攻击。
  3. JSON解码使用严格模式:启用 DisallowUnknownFields() 拒绝未知字段。
  4. 路径参数使用第三方路由器:避免手动解析 r.URL.Path,使用 chi、gorilla/mux 等库。
  5. 查询参数使用 r.URL.Query():语义清晰,不依赖 ParseForm() 调用。
  6. 表单参数使用 r.PostFormValue():避免意外读取查询参数。

3.7 参数解析后的数据流向

下面的SVG图展示了参数解析完成后,数据如何从原始请求流向业务逻辑层:

参数解析后的数据流向 原始请求 r *http.Request 解析层 ParseForm / Decode 校验层 类型/范围/业务规则 业务逻辑层 Service / UseCase map[string][]string r.Form / r.URL.Query() 结构体 (struct) CreateUserRequest 校验后的结构体 已验证的领域对象 数据转换说明 1. 原始请求 → 解析层 将字节流转为 map 或结构体 2. 解析层 → 校验层 对字段进行类型断言和规则检查 3. 校验层 → 业务层 传递已验证的干净数据 关键:每一层只做自己的事 解析层不校验,校验层不解析

3.8 本章小结

HTTP请求解析是参数校验的起点。通过本章的学习,你应该掌握:

  • 四种参数类型(路径、查询、表单、JSON)的解析方法
  • r.URL.Query()、r.Form、r.PostForm 的差异与选择
  • JSON请求体解析的严格模式与安全限制
  • 解析层与校验层的职责分离原则