Go 里读取 request body 后,下游拿不到参数:别让日志中间件把请求吃掉

很多 Go Web 项目都会加请求日志:记录 method、path、耗时,再顺手把 body 也打出来。问题通常就出在这个"顺手"上。

http.Request.Body 不是普通字符串,它是一个只能往前读的流。中间件里先 io.ReadAll(r.Body),后面的 handler 再去 json.NewDecoder(r.Body).Decode(&req),读到的就是空内容。这个问题不玄学,也不是框架 bug。它只是 body 被前面的人读完了。

下面用标准库写一个最小例子,Gin、Echo、Fiber 里遇到的现象本质一样。

一个会把 body 吃掉的中间件

go 复制代码
package main

import (
    "encoding/json"
    "io"
    "log"
    "net/http"
)

type CreateUserReq struct {
    Name string `json:"name"`
}

func badLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        b, _ := io.ReadAll(r.Body)
        log.Printf("body=%s", string(b))

        // 这里没有把 r.Body 放回去,后面的 handler 已经没东西可读了
        next.ServeHTTP(w, r)
    })
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
        return
    }
    w.Write([]byte("ok: " + req.Name))
}

复现步骤很简单:

bash 复制代码
curl -i -X POST http://127.0.0.1:8080/users \
  -H 'content-type: application/json' \
  -d '{"name":"alice"}'

中间件日志能看到 {"name":"alice"},但 handler 返回 400,常见错误是 EOF。这类 bug 很烦,因为日志看起来"明明有 body",真正的业务代码却拿不到。

正确做法:读完以后把流重新塞回去

如果请求体不大,可以读出来后再用 io.NopCloser(bytes.NewReader(b)) 还原。

go 复制代码
package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
)

func safeLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Body == nil {
            next.ServeHTTP(w, r)
            return
        }

        b, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "read body failed", http.StatusBadRequest)
            return
        }
        _ = r.Body.Close()

        log.Printf("body=%s", string(b))
        r.Body = io.NopCloser(bytes.NewReader(b))

        next.ServeHTTP(w, r)
    })
}

验证可以写成一个 httptest,比靠浏览器点接口稳得多。

go 复制代码
package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestSafeLogDoesNotConsumeBody(t *testing.T) {
    h := safeLog(http.HandlerFunc(createUser))

    req := httptest.NewRequest(
        http.MethodPost,
        "/users",
        strings.NewReader(`{"name":"alice"}`),
    )
    req.Header.Set("content-type", "application/json")

    rr := httptest.NewRecorder()
    h.ServeHTTP(rr, req)

    if rr.Code != http.StatusOK {
        t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
    }
    if !strings.Contains(rr.Body.String(), "alice") {
        t.Fatalf("unexpected body=%s", rr.Body.String())
    }
}

运行:

bash 复制代码
go test ./...

如果把 safeLog 换成前面的 badLog,这个测试会失败。这个对照足够说明问题,不需要靠猜。

但别把所有 body 都完整打进日志

把流放回去只是第一步。生产环境里更大的坑是日志内容本身。

场景 建议
登录、注册、改密 不记录原始 body,最多记录字段名和请求 ID
上传文件 不读完整 body,容易把内存打爆
普通 JSON 接口 限制最大读取长度,超出就截断
排查线上问题 只对指定 path、trace id 或临时开关生效

一个比较稳的写法是限制日志读取大小,比如只读前 4KB。注意,这里的限制是为了日志,不是替代接口层的请求体大小限制。

go 复制代码
const maxLogBody = 4 << 10 // 4KB

func readBodyForLog(r *http.Request) ([]byte, error) {
    if r.Body == nil {
        return nil, nil
    }

    limited := io.LimitReader(r.Body, maxLogBody+1)
    b, err := io.ReadAll(limited)
    if err != nil {
        return nil, err
    }
    _ = r.Body.Close()

    r.Body = io.NopCloser(bytes.NewReader(b))
    if len(b) > maxLogBody {
        return append(b[:maxLogBody], []byte("...[truncated]")...), nil
    }
    return b, nil
}

这段代码还有一个细节:如果你真的要让下游继续读取完整 body,不能只把截断后的内容放回 r.Body。上面这个函数适合"小请求体日志"。如果接口可能接收大 JSON,就应该先限制业务请求体大小,或者用 TeeReader 写到临时 buffer,但仍然要想清楚内存上限。

我会怎么检查这类问题

遇到"日志里有参数,handler 里参数为空",我通常先看四件事:

  • 中间件里有没有 io.ReadAll(r.Body)ShouldBindBodyWith 之外的读取逻辑;
  • 读完以后有没有把 r.Body 重新赋值;
  • 有没有多个中间件重复读取 body,前一个还原了,后一个又没还原;
  • 日志里有没有敏感字段,比如 password、token、authorization。

这类问题不大,但很容易藏在"统一日志""统一签名校验""统一审计"这些公共逻辑里。业务 handler 看起来没错,真正动过 body 的地方在更前面。

最后给一个比较保守的结论:请求日志可以记录 body,但不要默认完整记录。开发环境随便一点问题不大,线上最好加白名单、长度限制和脱敏规则。能用测试把中间件行为锁住,也别只靠联调时看一眼日志。

相关推荐
实心儿儿7 小时前
Linux —— 应用层协议HTTP
linux·网络·http
捧 花8 小时前
从同步生成到异步任务:YoudaoNoteLM 的生成队列模块设计
go·agent·worker·eino
小满zs11 小时前
Go语言第四章(类型转换)
后端·go
会编程的土豆13 小时前
GoWeb 处理请求详解:请求行、请求头、请求参数与给客户端响应
开发语言·http·golang
Apifox1 天前
Apifox 7 月更新|审计日志、密钥扫描防护、Postman/OpenAPI / Swagger 导入体验优化
前端·后端·测试
狂师1 天前
AI 测试提效 | 告别手工写脚本,分享我的 Playwright + Skill 批量生成 UI 自动化脚本方案
人工智能·测试·全栈
一条泥憨鱼1 天前
【从0开始学习计算机网络】| HTTP 状态码
开发语言·网络·网络协议·计算机网络·http
驭渊的小故事1 天前
网络原理02(http协议详细解析1)
网络·网络协议·http
q567315232 天前
企业级 HTTP 代理采购选型:技术评估清单 15 项
开发语言·网络·爬虫·网络协议·http·隧道ip·代理ip
白嫖一茶2 天前
HTTP 状态码详解:五大类与常见状态码解析
网络·网络协议·http