用 Go 写一个简单的高性能 HTTP 中间件

用 Go 写一个简单的高性能 HTTP 中间件

Go 的net/http没有内置「中间件」这个概念,但整个生态(gin、chi、echo)的中间件都建立在同一个朴素模式上:一个包装http.Handler的函数。搞懂这个模式,你不用任何框架也能写出日志、鉴权、限流、panic 恢复这一整套中间件。这篇从零手写,把链式组合、性能坑、以及「怎么在中间件之间传值」讲清楚。

中间件的本质:Handler 套 Handler

先回忆http.Handler接口——它只有一个方法:

typeHandlerinterface{ServeHTTP(w http.ResponseWriter,r*http.Request)}

所谓中间件,就是一个接收 Handler、返回 Handler的函数。外层 Handler 在调用内层之前/之后做点额外的事:

// Middleware 的统一签名typeMiddlewarefunc(http.Handler)http.Handler// 一个最简单的日志中间件funcLogging(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){start:=time.Now()next.ServeHTTP(w,r)// 调用被包裹的下一层log.Printf("%s %s %v",r.Method,r.URL.Path,time.Since(start))})}

用起来就是「套娃」:

mux:=http.NewServeMux()mux.HandleFunc("/hello",helloHandler)// Logging 包住整个 muxhttp.ListenAndServe(":8080",Logging(mux))

请求进来时,先进Logging记时间 → 调next.ServeHTTP(也就是 mux)→ 处理完回到Logging打日志。这就是中间件的执行流:像洋葱一样,一层层进去,再一层层出来。

把多个中间件串成链

只有一个中间件时套娃还行,三五个就变成A(B(C(mux)))这种反人类的嵌套。写个Chain帮你按顺序组合:

funcChain(h http.Handler,middlewares...Middleware)http.Handler{// 逆序包裹,保证书写顺序 = 执行顺序fori:=len(middlewares)-1;i>=0;i--{h=middlewares[i](h)}returnh}

为什么要逆序遍历?因为最先写的中间件应该在最外层(最先执行)。举例:

handler:=Chain(mux,Recover,// 最外层:最先执行,兜住后面所有 panicLogging,// 记录整个请求耗时Auth,// 鉴权,不通过就不进业务)http.ListenAndServe(":8080",handler)

执行顺序是Recover → Logging → Auth → mux → Auth → Logging → Recover。如果不逆序,写在最前的Recover反而跑在最里层,就兜不住Logging/Auth里的 panic 了。

实战中间件一:panic 恢复

Go 里一个 goroutine panic 且没 recover,整个进程会挂。HTTP 服务绝不能因为某个 handler 的空指针就整个崩掉,所以 Recover 中间件几乎是必备:

funcRecover(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){deferfunc(){iferr:=recover();err!=nil{// 打印堆栈,方便定位,但别把堆栈返回给客户端log.Printf("panic: %v\n%s",err,debug.Stack())http.Error(w,"Internal Server Error",http.StatusInternalServerError)}}()next.ServeHTTP(w,r)})}

关键点:defer + recover必须放在调用 next 之前注册,这样 next 里任何一层 panic 都会被这个 defer 捕获。返回给客户端的是笼统的 500,堆栈只进日志——别把内部堆栈泄露给外部。

实战中间件二:令牌桶限流

高并发下要防止被打爆。用官方扩展库golang.org/x/time/rate的令牌桶,几行就能限流:

import"golang.org/x/time/rate"funcRateLimit(rpsint,burstint)Middleware{// limiter 在闭包里创建,整个中间件生命周期内共享同一个limiter:=rate.NewLimiter(rate.Limit(rps),burst)returnfunc(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){if!limiter.Allow(){// 没令牌就直接拒绝,不进业务http.Error(w,"Too Many Requests",http.StatusTooManyRequests)return}next.ServeHTTP(w,r)})}}

注意RateLimit多了一层:它返回的是Middleware(带参数的中间件)。limiter创建在外层闭包里,所有请求共享同一个 limiter——千万别把rate.NewLimiter写进最内层的 handler,那样每个请求都新建一个 limiter,限流等于没做,这是新手最常见的坑。

中间件之间传值:用 context,别用全局变量

鉴权中间件解析出用户 ID 后,业务 handler 怎么拿到?正确做法是塞进r.Context(),而不是全局 map(全局 map 在并发下会串数据):

typectxKeystringconstuserKey ctxKey="userID"// 用自定义类型做 key,避免和别的包冲突funcAuth(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){token:=r.Header.Get("Authorization")uid,ok:=verify(token)if!ok{http.Error(w,"Unauthorized",http.StatusUnauthorized)return}// 把 uid 存进 context,派生出新的 request 往下传ctx:=context.WithValue(r.Context(),userKey,uid)next.ServeHTTP(w,r.WithContext(ctx))})}// 业务 handler 里取值funchelloHandler(w http.ResponseWriter,r*http.Request){uid,_:=r.Context().Value(userKey).(string)fmt.Fprintf(w,"hello, user %s",uid)}

两个细节:context key 用自定义类型(type ctxKey string)而不是裸字符串,避免不同包用了相同字符串 key 互相覆盖;取值时用类型断言(.(string)),因为Value返回的是any

性能坑:别在中间件里做无谓的内存分配

中间件在每个请求上都会跑,是热路径,任何多余分配都会被放大。常见的性能杀手是「包装 ResponseWriter 记录状态码」时的写法:

// 需要记录响应状态码时,包一层 ResponseWritertypestatusRecorderstruct{http.ResponseWriter statusint}func(r*statusRecorder)WriteHeader(codeint){r.status=code r.ResponseWriter.WriteHeader(code)}funcLoggingWithStatus(next http.Handler)http.Handler{returnhttp.HandlerFunc(func(w http.ResponseWriter,r*http.Request){rec:=&statusRecorder{ResponseWriter:w,status:200}// 默认 200next.ServeHTTP(rec,r)log.Printf("%s %s -> %d",r.Method,r.URL.Path,rec.status)})}

这个包装本身开销极小(一个栈上/逃逸的小结构体)。真正要避免的是:在中间件里fmt.Sprintf拼一堆字符串、每请求都json.Marshal、或在没命中限流/鉴权失败时仍然做了昂贵的准备工作。原则是能提前 return 就别往下走——限流不通过、鉴权失败,立刻返回,不要浪费后面的计算。

小结

  • 中间件的本质就一句话:接收http.Handler、返回http.Handler的函数,在调用next.ServeHTTP前后插逻辑,不依赖任何框架。
  • Chain逆序包裹多个中间件,保证「书写顺序 = 执行顺序」,Recover放最外层才能兜住全部 panic。
  • 带状态的中间件(限流器、连接池)要把状态建在外层闭包里共享,别写进每请求的 handler。
  • 跨中间件传值用context.WithValue+ 自定义 key 类型,不要用全局变量。
  • 中间件是热路径,能提前 return 就 return,别做无谓分配。

一句话记忆点:Go 的中间件不是框架特性,而是「函数包函数」这个模式的自然结果——理解了洋葱模型,你就能徒手搭出整套请求处理链。