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,但不要默认完整记录。开发环境随便一点问题不大,线上最好加白名单、长度限制和脱敏规则。能用测试把中间件行为锁住,也别只靠联调时看一眼日志。

相关推荐
曾令胜3 小时前
HTTP协议基础总结
网络·网络协议·http
tyung21 小时前
zhenyi-base zqueue 基准测试分享
后端·go
香吧香21 小时前
go tool pprof 使用总结二
go
ClouGence1 天前
零代码自动化测试工具回演 CueCast 新版本发布:数据变量、多标签页录制与登录准备能力上线
selenium·测试
Helen_cai1 天前
OpenHarmony http 网络请求封装与全局拦截实战(API Version23 + 适配版)
网络·网络协议·http·华为·harmonyos
用户6757049885021 天前
写了 6 年 Go ,我终于领悟到了泛型的真正威力!
后端·go
MegatronKing1 天前
AI时代我们要如何玩抓包测试
前端·后端·测试
名字还没想好☜1 天前
Go error 处理:errors.Is/As 与错误包装
开发语言·后端·golang·go·错误处理
gugucoding1 天前
39. 【C语言】经典 C 项目实战:迷你 HTTP 服务器
c语言·开发语言·http