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

相关推荐
Zelman12 小时前
测试金字塔与自动化分层
测试
JWASX14 小时前
Java 转 go 学习 - 项目管理
go
喵个咪18 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
后端·rust·go
EatFan18 小时前
Go语言全栈实战:基于 Gin + Vue + JWT + RBAC 从零搭建前后端分离权限管理系统
vue.js·golang·go·vue·gin·jwt·rbac
ZealSinger18 小时前
Go slog生产落地LevelVar与共存
开发语言·后端·golang·go
福兮说20 小时前
Go encoding/json 的八个默认行为,以及 Go 1.25 的 json/v2 改了哪几个(附实测输出)
go
傲世仙尊20 小时前
HTTP起步-URL域名解析与协议的本质
网络·网络协议·http
柒和远方20 小时前
LangSmith RAG 量化评估:从"感觉还行"到"数据说话"
langchain·llm·测试
ControlRookie20 小时前
第27篇_综合排障|从现象反查状态、错误码、指标和连接快照
http·codesys·plc通信
匠测AI说20 小时前
AI for Testing 提效实战·测试设计(一):喂段需求给AI,30秒铺开测试点,防漏测的第一步
测试