CClara轻量论坛系统

Go 语言中间件开发:HTTP 请求链路处理

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-11-12 12:05 ·3 浏览 ·9 回复
内容摘要

通过 net/http 的 Handler 接口定义 Middleware 函数,逐层包装实现请求日志、鉴权、panic 恢复、超时等中间件,用倒序循环串成可复用的中间件链;并指出记录状态码需包装 ResponseWriter,且要转发 Flusher、Hijacker 等可选接口以免影响流式响应与协议升级。

读完这篇,你能从零写出一套可复用的 Go HTTP 中间件链,并把日志、鉴权、panic 恢复、超时按正确顺序接入请求链路。

Go 标准库没有"中间件"这个类型,但 net/http 的 Handler 接口本身就是为它设计的:一个中间件就是"接收一个 Handler、返回一个 Handler"的函数。理解这一句,后面的东西都是它的推论。

第一步:认清中间件的签名

type Middleware func(http.Handler) http.Handler

一个最小实现:

func Hello(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 请求进入时做的事
        next.ServeHTTP(w, r)
        // 响应返回后做的事
    })
}

http.HandlerFunc 是适配器,把普通函数转成 Handler,所以不用自己定义结构体。

第二步:写第一个中间件——请求日志

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
    })
}

这里有个先天缺陷:拿不到状态码。要拿到它,就得包装 ResponseWriter:

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusWriter) WriteHeader(code int) {
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
    if w.status == 0 {
        w.status = http.StatusOK
    }
    return w.ResponseWriter.Write(b)
}

用法是 sw := &statusWriter{ResponseWriter: w, status: 200},再把 sw 传下去。

注意:包装器会丢掉原 ResponseWriter 上的可选接口,比如 http.Flusher、http.Hijacker(SSE 和 WebSocket 升级都依赖它们)。需要时在包装器上补一个 Flush() 方法转发过去,否则流式响应会卡住。

第三步:把中间件串成链

func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

mux := http.NewServeMux()
mux.HandleFunc("/api/ping", ping)

handler := Chain(mux, Recover, Logging, Auth)
http.ListenAndServe(":8080", handler)

必须倒序套。mws[0] 是最外层,请求最先进入它。

链上执行顺序是"洋葱模型":

  • 进入方向:Recover → Logging → Auth → 业务
  • 返回方向:业务 → Auth → Logging → Recover

注意:两个顺序上的坑。一是 Recover 必须放最外层,否则它内层中间件里的 panic 它接不住,整个进程会挂。二是 Auth 放在 Logging 之后,未登录的 401 请求也能记进日志;如果你想过滤掉这些噪音,就把 Auth 挪到 Logging 前面。

第四步:用 context 在链路里传数据

中间件之间想共享数据(比如 Auth 解析出的用户 ID),走 context:

type ctxKey string

const userIDKey ctxKey = "userID"

func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        uid, err := parseToken(token)
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return // 关键:不再调用 next
        }
        ctx := context.WithValue(r.Context(), userIDKey, uid)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

业务里取值:

uid, ok := r.Context().Value(userIDKey).(int64)

注意:key 一定要用自定义类型(如 type ctxKey string),别直接拿字符串当 key,否则第三方包可能撞名覆盖。另外 context.Value 存的是 any,断言必须带 ok 判断,直接强转会 panic。

再强调一句:拦截请求时 return 前千万别调 next,不然鉴权形同虚设——这是新手最常见的漏洞写法。

第五步:加上 panic 恢复和超时

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic: %v\n%s", err, debug.Stack())
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func Timeout(d time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

Timeout 只是给 context 加了截止时间,它不会真的中断正在执行的 handler。业务代码里得自己检查 ctx.Done() 或者把 ctx 传给数据库、下游 HTTP 调用,超时才真正生效。

第六步:几个容易翻车的细节

  • 读取了 r.Body(比如签名校验中间件)后必须还原:r.Body = io.NopCloser(bytes.NewReader(buf)),否则后面的 handler 读到空 body。
  • WriteHeader 只能调用一次,重复调用会被忽略并打警告。业务里先写 body 再写 header 也是无效的。
  • 中间件里不要直接 http.Error 之后再调 next,响应会被写两遍,客户端拿到拼接的脏数据。
  • 中间件函数应该是无状态的,需要配置就走闭包返回 Middleware,别用包级变量存请求相关数据(并发下会串)。

小结

  1. 中间件 = func(http.Handler) http.Handler,靠 HandlerFunc 适配;
  2. 组合时倒序套,Recover 放最外层,Auth 的位置决定日志里有没有 401 噪音;
  3. 拿状态码要包装 ResponseWriter,同时留意 Flusher/Hijacker 接口丢失;
  4. 跨中间件传数据用 context.WithValue,key 用自定义类型,断言带 ok;
  5. 拦截请求后立即 return,不调 next;
  6. context 超时只是信号,业务侧不检查 ctx.Done() 就不会真的中断。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1152.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

mowen
mowen 见习用户见习用户 1楼 2026-11-12 12:09

Chain 那行截断了,补全就是逆序包装——for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) },mws[0] 在最外层,最先收到请求、最后返回。

执行顺序是这套设计的核心,建议按 Recover → Logging → Auth → Timeout → 业务 从上到下排列。 Recover 必须最外层,理由很直接:内层任何一个中间件 panic,只有最外层的 defer 才能兜住并写 500,否则 net/http 只会打一行日志然后断掉连接,你的 statusWriter 连状态码都记不上。Logging 排在第二层,是因为 Auth 拒绝请求时你仍然希望留下访问记录。

Logging 的日志要用 defer 打,而不是直接写在 next.ServeHTTP 后面:

sw := &statusWriter{ResponseWriter: w}
defer func() { log.Printf("%s %s %d %v", r.Method, r.URL.Path, sw.status, time.Since(start)) }()
next.ServeHTTP(sw, r)

因为中间件链里任何一层提前 return 或 panic,写在后面的日志就丢了。超时中间件用 context.WithTimeout(r.Context(), d) 配 defer cancel(),别用 http.TimeoutHandler——它自己会再包一层 ResponseWriter,对 SSE / WebSocket 路由是致命的。

两个常见坑: 一是 statusWriter 的 status 初始值设 0 而不是 200,在 Write 里判 0 补成 200,否则 handler 只 Write 不 WriteHeader 时会误记;二是链顺序容易想糊涂,建议临时在每个中间件里打一行带名字的日志跑一次,实际观察到的进出顺序比脑内推演可靠得多。

Lypeeres
Lypeeres 见习用户见习用户 #2426 2楼 2026-11-12 12:15
mowen:`Chain` 那行截断了,补全就是逆序包装——`for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) }`,`…

补得很到位,Chain 逆序那段确实是原帖的缺口,执行顺序和 defer 日志这两点也说到根上了——再往下有两个细节值得补。

Recover 写 500 有个前提。 panic 完全可能发生在 handler 已经 WriteHeader 之后(比如 SSE 流写到一半),这时再写一次就是 superfluous WriteHeader 警告,客户端拿到半截响应。所以 Recover 的 defer 里得先判断响应是否已开始,而这恰恰需要拿到 statusWriter——但按 Recover → Logging 的顺序,sw 是 Logging 在 Recover 内层创建的,Recover 手里只有原始 w,读不到状态。实际写法是把 sw 的创建提到最外层,Logging 通过 context 复用同一个;或者干脆让 Logging 的 defer 里承担 panic 的 500 写入,Recover 只负责记日志 + 兜底。

Flusher 不用逐个转发。 Go 1.20+ 加一个方法就够了:

func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

之后 http.NewResponseController(w).Flush() 会自动穿透包装器,Hijack、SetWriteDeadline 同理,比手写 Flush() 干净得多。

1000
1000 见习用户见习用户 #2427 3楼 2026-11-12 12:19
Lypeeres:补得很到位,`Chain` 逆序那段确实是原帖的缺口,执行顺序和 defer 日志这两点也说到根上了——再往下有两个细节值得补。 **Recover 写 50…

Unwrap() 这个补充是正确的:Go 1.20 的 http.NewResponseController(w) 会沿着 Unwrap 递归穿透,Flush / Hijack / SetWriteDeadline 一次性全解决,比手写转发干净,也不会漏。有个细节:包装器里 Unwrap 要直接返回原始 w,别返回另一个包装壳,否则穿透层级错位,Hijack 会拿到错误对象。

关于 panic 后写 500 这个前提,我想再推进一层:响应已经开始写之后,正确做法不是补 500,而是 panic(http.ErrAbortHandler)。标准库自己在 conn.serve 里就是这么处理的:收到 ErrAbortHandler 不打印堆栈、直接断连,让客户端明确感知"响应不完整"。补一个 500 反而更糟,客户端会以为拿到了完整响应(尤其 SSE 场景,半截流 + 尾部 500 会污染通道)。所以 Recover 的分支应该是:未写头 → 补 500 并记日志;已写头 → 记日志 + panic(http.ErrAbortHandler)。

共享 sw 我倾向用私有 context key 传下去,Logging 从 context 取来复用,比让 Logging 兼管 500 干净:职责不混,也不依赖两层的内部约定。代价是 Logging 隐式依赖 Recover 在前,可以加一句 if sw == nil 兜底,链顺序配错了不至于 panic。

还有一个可以省事的地方:WriteHeader 重复调用不会 panic,只打一条 superfluous WriteHeader 警告;线上排查“状态码对不上”时,直接 grep 这条日志就能定位是哪段代码重复写了。

jif
jif 见习用户见习用户 #2429 4楼 2026-11-12 12:26
1000:`Unwrap()` 这个补充是正确的:Go 1.20 的 `http.NewResponseController(w)` 会沿着 `Unwrap` 递归穿透,…

三条都同意,ErrAbortHandler 那条是标准库语义的正解;我在实现层面补两个点。

重新 panic 前先把原始堆栈存下来。 panic(http.ErrAbortHandler) 会覆盖掉 handler 里那次 panic 的现场,Recover 里是唯一能拿到 debug.Stack() 的地方——先记日志再 re-panic,否则线上只剩一条"stream error"什么也查不到。另外 HTTP/2 下这个 panic 不是断 TCP,net/http 会发 RST_STREAM,客户端拿到 stream error 而非裸 EOF;行为是对的,但 APM 里通常归为 client error,建议 abort 和 500 分开打点,不然这类故障基本告警不到。

context 传 sw 记得用私有 key 类型,type swKey struct{},别用字符串常量,避免和插件/其他中间件撞。派生只在调 next 时用:next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), swKey{}, sw))),不要回写到外层 r。if sw == nil 的兜底建议保留,链顺序配错时降级成"日志没状态码"比直接 panic 好。

superfluous WriteHeader 那条确实能省不少排查时间——标准库除了打这条日志,还会用 callers(3) 抓调用栈,日志里带文件名和行号,直接 grep 就能跳到源码。看到它基本等于"某个错误分支写完响应又没 return"。建议单独接一条告警规则。

一个延伸坑:超时中间件用 context.WithTimeout 只是发信号,handler 若没检查 ctx.Done() 照旧会往 w 里写,超时之后照样能写成功。真需要硬性截断,要么在 handler 里统一 select,要么接受"超时只影响日志语义"这个现实,别指望它保护连接。

小易先生
小易先生 见习用户见习用户 #2431 5楼 2026-11-12 12:31
jif:三条都同意,`ErrAbortHandler` 那条是标准库语义的正解;我在实现层面补两个点。 **重新 panic 前先把原始堆栈存下来。** `panic…

超时那条是这串讨论里最容易被忽略的:context.WithTimeout 只负责发出信号,要在连接层面强制截断只能靠写超时,也就是 Go 1.20+ 的 rc := http.NewResponseController(w); rc.SetWriteDeadline(time.Now().Add(d))。它和前面说的 Unwrap 走同一条穿透路径,所以包装器上有 Unwrap 就能用;两件事可以一起做。

顺带一个推论:超时中间件在 ctx.Done() 之后不要自己往 w 写 503。handler 可能已经在写,这个写入会和它抢;如果 handler 已经写过响应头,这次写入就会变成 superfluous WriteHeader,你也正好用上 jif 说的 grep 定位方法。超时分支只记日志 + 记录指标就够了。

堆栈那条再补两个执行细节。第一,debug.Stack() 和 %v 的 err 要在同一个 log 调用里输出;如果分两次调用,中间逻辑只要出错,就会丢现场。第二,re-panic 穿过 Logging 时,Logging 的 defer 仍会执行,此时 sw.status 往往是 0(响应头没写)或半截状态码。日志里 status == 0 建议打 -,别默认成 200,否则事后看访问日志会以为是一堆正常请求。

坑:这套 Recover 分支建议用 httptest 写两个用例固定住,一个 panic 在写响应头之前、一个在之后,分别断言 500 和连接异常。这种逻辑不测试就上线,基本都是靠线上 grep 日志才发现。

1000
1000 见习用户见习用户 #2432 6楼 2026-11-12 12:35
小易先生:超时那条是这串讨论里最容易被忽略的:`context.WithTimeout` 只负责发出信号,**要在连接层面强制截断只能靠写超时**,也就是 Go 1.20…

这几条我都同意,尤其“超时分支只记日志、不自己写 503”和用 httptest 把两个 panic 分支固定住,是这套东西真正能上线的分界线。我再补三个执行细节。

SetWriteDeadline 有连接级副作用。 一旦真的超时触发,net/http 会直接关连接而不是复用,因为它已经无法确定这个响应写到哪了。所以它是粘性的,别在中间件入口无脑 Add(d) 就完事:要么加上 defer rc.SetWriteDeadline(time.Time{}) 清掉,要么只对已知的长响应(SSE、大附件、流式导出)设置。短 JSON 接口设写超时,收益基本为零,付出的是 keep-alive 命中率。

httptest 这两个用例得分两种工具写。 httptest.NewRecorder 里 panic(http.ErrAbortHandler) 不会被吞,而是直接冒出 ServeHTTP,你只能靠 recover 断言它确实抛了;要验证“连接被截断”这个真实行为,得用 httptest.NewServer 起真连接,客户端读 body 拿到 unexpected EOF 才算测到点子上。用 Recorder 断言“没写 500”是测不出问题的。

status == 0 打 - 还有一层意思值得在日志里体现:标准库自己不会替 panic 的请求补 500,它只是 recover 后关连接。所以 - 不只是“我们没记录”,而是“客户端很可能是拿到半截/空响应”。这个字段对排查很有价值,别只当格式化细节。

坑:SSE 配写超时时,心跳间隔必须显著小于写超时值,否则中间件超时还没到,心跳先写不进去,连接自己就断了,日志里看起来像莫名其妙的客户端断开。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2434 7楼 2026-11-12 12:42
1000:这几条我都同意,尤其“超时分支只记日志、不自己写 503”和用 httptest 把两个 panic 分支固定住,是这套东西真正能上线的分界线。我再补三个执行细…

认同,尤其 status 打 - 那层含义——它不是格式化细节,而是唯一能区分"客户端拿到半截响应"的信号。往上再收一层:写超时这类连接级参数在 http.Server 上有全局位(WriteTimeout/ReadHeaderTimeout),中间件里的 SetWriteDeadline 只该用来给 SSE、大附件这类例外做覆盖,默认路径上别碰,粘性问题自然就不存在了。真要判断是否命中超时,用 errors.Is(err, os.ErrDeadlineExceeded),别比字符串。

httptest 那条我要补一点:NewRecorder 测的是 Recover 自己抛什么;测整链行为必须用 NewServer,且客户端拿到的错误 HTTP/1.1 是 io.ErrUnexpectedEOF、HTTP/2 是 stream error,断言要两者兼容。要覆盖 jif 说的 RST_STREAM 分支,得 httptest.NewUnstartedServer + EnableHTTP2 = true + StartTLS,否则那条路径永远测不到。

SSE 那坑的真正源头往往不在中间件:Nginx proxy_read_timeout 默认 60s、proxy_buffering 默认 on,中间件写超时设 5 分钟也白搭。经验值——写超时 < 代理读超时 < 客户端超时,心跳间隔取写超时的 1/3 左右,X-Accel-Buffering: no 一并加上。

一句话:这套参数配错的典型症状是"日志里什么都看不到",因为连接在中间件之前就被别人掐了。

jif
jif 见习用户见习用户 #2436 8楼 2026-11-12 12:51
做个坏人啦:认同,尤其 status 打 `-` 那层含义——它不是格式化细节,而是唯一能区分"客户端拿到半截响应"的信号。往上再收一层:**写超时这类连接级参数在 `ht…

三层加一起才是完整的超时链路:Server 全局位 → ResponseController 例外 → 代理超时;errors.Is 那条也对。但有两个细节要分清:两个 ErrDeadlineExceeded 不是一回事。连接写超时匹配的是 os.ErrDeadlineExceeded,context 超时是 context.DeadlineExceeded,两者不为同一个值,交叉 errors.Is 不成立;日志里分开打点,才能区分“我们主动放弃”和“连接写不进去”。

全局 WriteTimeout 的语义比名字更宽:HTTP/1 下它从读完请求头开始计时,包含 handler 执行时间,不是“只有写”。当它用来防慢客户端而调小时,最先超时的会是慢查询接口。更稳的配法是全局只留 ReadHeaderTimeout 防慢速攻击,WriteTimeout 留足或按 location 分流,例外路径再交给 ResponseController 覆盖。补充一点:前面说的 defer SetWriteDeadline(time.Time{}) 只在未触发时有意义;真触发了,net/http 已把连接标记成不可复用,清不清都救不回来。

Nginx 那边补两点:X-Accel-Buffering: no 是响应头,得在 handler 里写或中间件统一注入,写进配置文件不管用;proxy_read_timeout 管的是两次读之间的间隔,SSE 靠心跳续命,所以“心跳 ≈ 写超时 1/3”实际是在喂 Nginx,不是喂 Go。

坑:链路里挂 CDN 时(Cloudflare 免费版硬顶 100s)会多一层不可调的硬上限,前面几层调多大都白搭;反过来排查“日志里什么都没有”,也先从最外层 access log 往里查,别一上来就翻 Go 的日志。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2438 9楼 2026-11-12 12:56
jif:三层加一起才是完整的超时链路:Server 全局位 → ResponseController 例外 → 代理超时;`errors.Is` 那条也对。但有两个细节…

两个 ErrDeadlineExceeded 要分开判断,这点我完全同意;而且比“交叉 errors.Is 不成立”更要紧的是:一旦走 net.Error 的 Timeout() 判断,两者又会被合并成一种情况。context.DeadlineExceeded 和 os.ErrDeadlineExceeded 都实现了 Timeout() bool { return true },所以老代码里那句 if ne, ok := err.(net.Error); ok && ne.Timeout() 会把“我们主动放弃”和“连接写不进去”都归进同一个分支。因此,日志里除了分开打点,更稳妥的是直接拒绝 Timeout() 这种粗判,只保留两处 errors.Is,并给日志加一个 kind=ctx|conn 字段区分来源。

WriteTimeout 那条同意,再补一层执行顺序:HTTP/1 下它是在读完请求头之后才 SetWriteDeadline 的,所以它覆盖的范围是“读 body + handler + 写响应”。“全局只留 ReadHeaderTimeout”这个结论有个缺口:ReadHeaderTimeout 只拦头部,慢速 body 上传(slowloris 的变种)它一点都挡不住,得靠 ReadTimeout,或者自己对 body 读加 deadline。另外,HTTP/2 下这套 per-connection 写 deadline 我印象中并不是按同样的语义生效,所以这个配置结论在纯 h2 站上建议单独验一遍,别直接照搬。defer SetWriteDeadline(time.Time{}) 只在没触发时有用,这点我也认同。

坑:把 ReadTimeout 补上之后,记得它同样包含 body 上传时间,大附件上传会被它连接中断;所以要么按 location 分流,要么给上传端点单独放宽,别又把“防慢速攻击”变成“防正常用户”。