中间件是 go-zero REST 框架中最容易 凭感觉使用 却最难 准确理解 的部分。它的本质不难——每个中间件都是一个 func(http.Handler) http.Handler,层层包裹业务逻辑。本文从 chain.Chain 的不可变链表机制出发,依次分析每个默认中间件的职责和位置原因,再展开 JWT 认证、签名校验、CORS、SSE 和文件服务这些 附加能力 如何挂载到主链之上。文中会用成功、超时、panic、未认证四种典型请求贯穿讲解,帮你建立"请求穿过中间件链"的肌肉记忆。
chain.Chain:不可变的中间件链表
在深入每个中间件之前,先理解承载它们的容器——chain.Chain。这是 go-zero 基于 justinas/alice 魔改的中间件链实现,相比原版增加了 Prepend 方法和 Chain 接口。
基本用法
1 2 3 4 5 6 7 8 9 type Middleware func (http.Handler) http.Handlertype Chain interface { Append(middlewares ...Middleware) Chain Prepend(middlewares ...Middleware) Chain Then(h http.Handler) http.Handler ThenFunc(fn http.HandlerFunc) http.Handler }
用法非常简洁:
1 2 3 chn := chain.New(m1, m2) chn = chn.Append(m3) handler := chn.Then(businessHandler)
请求到达时,执行顺序是 m1 → m2 → m3 → businessHandler。这与直觉一致:New 中先传入的在外层,Append 追加的在内层。每个中间件内部调用 next.ServeHTTP(w, r) 将控制权交给下一层。
不可变性的意义
注意 chain 结构体是小写开头的——它不对外暴露内部字段。每次 Append 或 Prepend 都返回一个新的 chain 实例,原有实例不受影响:
1 2 3 4 5 6 7 8 9 10 func (c chain) Append(middlewares ...Middleware) Chain { return chain{middlewares: join(c.middlewares, middlewares)} } func join (a, b []Middleware) []Middleware { mids := make ([]Middleware, 0 , len (a)+len (b)) mids = append (mids, a...) mids = append (mids, b...) return mids }
每次都会创建新的 slice,所以一个 Chain 可以派生多个子链来分别使用:
1 2 3 base := chain.New(logMiddleware, authMiddleware) indexRoute := base.Then(indexHandler) adminRoute := base.Append(adminOnly)
这种不可变性在 go-zero 的 bindRoute 中发挥得淋漓尽致:默认链 → 追加 JWT → 追加用户中间件 → 再 .ThenFunc,每一步都产生新链,完全不会相互污染。
Then 的构造过程
Then 将中间件链和最终 handler 收拢为一个 http.Handler:
1 2 3 4 5 6 func (c chain) Then(h http.Handler) http.Handler { for i := range c.middlewares { h = c.middlewares[len (c.middlewares)-1 -i](h) } return h }
注意遍历方向是从后往前 ——这决定了"先加到链上的中间件在外层"。假设链是 [m1, m2, m3],遍历过程是:
1 2 3 4 h = handler h = m3(h) // m3 包裹 handler h = m2(h) // m2 包裹 m3 h = m1(h) // m1 包裹 m2
请求流 = m1 → m2 → m3 → handler。Prepend 则相反,在链首插入,新加入的中间件跑到最外层。
理解了 chain 的机制后,我们再来看 go-zero 的 11 个默认中间件是怎样逐个叠加起来的。
默认中间件链的组装:buildChainWithNativeMiddlewares
所有默认中间件的装配入口在 engine.buildChainWithNativeMiddlewares。它被 bindRoute 调用——每次绑定的路由是一组共享属性的路由(featuredRoutes),它们复用同一个中间件链。但如果某组路由的超时、优先级或 maxBytes 不同,checkedTimeout、getShedder 和 checkedMaxBytes 会取路由组级别的值,而非全局默认值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 func (ng *engine) buildChainWithNativeMiddlewares(fr featuredRoutes, route Route, metrics *stat.Metrics) chain.Chain { chn := chain.New() if ng.conf.Middlewares.Trace { chn = chn.Append(handler.TraceHandler(ng.conf.Name, route.Path, ...)) } if ng.conf.Middlewares.Log { chn = chn.Append(ng.getLogHandler()) } if ng.conf.Middlewares.Prometheus { chn = chn.Append(handler.PrometheusHandler(route.Path, route.Method)) } if ng.conf.Middlewares.MaxConns { chn = chn.Append(handler.MaxConnsHandler(ng.conf.MaxConns)) } if ng.conf.Middlewares.Breaker { chn = chn.Append(handler.BreakerHandler(route.Method, route.Path, metrics)) } if ng.conf.Middlewares.Shedding { chn = chn.Append(handler.SheddingHandler(ng.getShedder(fr.priority), metrics)) } if ng.conf.Middlewares.Timeout { chn = chn.Append(handler.TimeoutHandler(ng.checkedTimeout(fr.timeout))) } if ng.conf.Middlewares.Recover { chn = chn.Append(handler.RecoverHandler) } if ng.conf.Middlewares.Metrics { chn = chn.Append(handler.MetricHandler(metrics)) } if ng.conf.Middlewares.MaxBytes { chn = chn.Append(handler.MaxBytesHandler(ng.checkedMaxBytes(fr.maxBytes))) } if ng.conf.Middlewares.Gunzip { chn = chn.Append(handler.GunzipHandler) } return chn }
11 个中间件,每个都可以在配置中按需关闭。最终形成的处理链,从外到内依次是:
1 2 Trace → Log → Prometheus → MaxConns → Breaker → Shedding → Timeout → Recover → Metrics → MaxBytes → Gunzip → Handler
光看名字很容易产生"需要这么多吗"的疑问。下面逐个分析每个中间件保护什么、为什么在这个位置 ,你就会发现每个位置都有它的设计约束。
逐一剖析:每个默认中间件的职责与位置理由
TraceHandler:在大门外安上监控摄像头
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func TraceHandler (serviceName, path string , opts ...TraceOption) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { tracer := otel.Tracer(trace.TraceName) propagator := otel.GetTextMapPropagator() return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header)) spanCtx, span := tracer.Start(ctx, spanName, ...) defer span.End() propagator.Inject(spanCtx, propagation.HeaderCarrier(w.Header())) trw := response.NewWithCodeResponseWriter(w) next.ServeHTTP(trw, r.WithContext(spanCtx)) span.SetAttributes(semconv.HTTPAttributesFromHTTPStatusCode(trw.Code)...) span.SetStatus(semconv.SpanStatusFromHTTPStatusCodeAndSpanKind( trw.Code, oteltrace.SpanKindServer)) }) } }
TraceHandler 做了 OpenTelemetry 的标准三件事:
Extract :从上游请求头中提取 trace context(如果有的话),建立父子 span 关系。这是分布式链路追踪的基础——REST → RPC 调用链能串联起来,靠的就是这一步 propagation。
Start span :以路由路径为 span name 创建服务端 span。请求完成后通过 defer span.End() 关闭 span,并设置状态码属性。
Inject :将 trace context 注入响应头,方便下游继续传播。
为什么在最外层? TraceHandler 需要测量整个请求的生命周期——包括所有中间件的耗时和最终的业务处理耗时。如果把它放在中间层的某个位置,Span 就覆盖不到外层的中间件了。另外,OpenTelemetry 的 extract/inject 需要在请求的入口和出口处执行,放在最外层天然契合这个语义。
TraceHandler 也会处理 404 请求——在 notFoundHandler 中单独构建了一条 Trace → Log 的短链,保证未匹配路由的请求也能被追踪。
LogHandler:记录每一个请求的呼吸
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func LogHandler (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { timer := utils.NewElapsedTimer() logs := new (internal.LogCollector) lrw := response.NewWithCodeResponseWriter(w) var dup io.ReadCloser r.Body, dup = iox.LimitDupReadCloser(r.Body, limitBodyBytes) next.ServeHTTP(lrw, r.WithContext(internal.WithLogCollector(r.Context(), logs))) r.Body = dup logBrief(r, lrw.Code, timer, logs) }) }
LogHandler 做三件事:
第一,启动计时器,记录响应状态码。 它用 WithCodeResponseWriter 包裹原始的 ResponseWriter,这样在 next.ServeHTTP 返回后,就能从 lrw.Code 中获取实际写入的 HTTP 状态码——而不需要 hook WriteHeader。
第二,注入 LogCollector 到 context。 业务代码内部或其他中间件可以通过 internal.Infof(r, "blah") 写入日志,这些日志被收集到 LogCollector 中,在请求结束时随主日志一起刷新。这是一种请求级日志聚合 ——不是每条 Infof 都单独输出,而是全部挂在一条请求日志下面。
第三,慢请求检测。 当请求耗时超过阈值(默认普通请求 500ms,SSE 请求 3 分钟),日志级别升级为 Slowf:
1 2 3 if duration > getSlowThreshold(r) { logger.Slowf("[HTTP] %s - %s %s - %s - %s - slowcall(%s)" , ...) }
普通请求 200ms 就算慢?SSE 为什么要 3 分钟?因为 SSE 连接天然是长连接——可能在几分钟内都没有数据推送,如果在 500ms 时就报 slow call,日志里会充满误报。框架为 SSE 单独设置了 sseSlowThreshold,这是一个细心实用的设计。
LogHandler 还支持 DetailedLogHandler 模式(通过配置中的 Verbose 开关)。与简洁模式只记录请求元信息不同,详细模式会 dump 完整的 HTTP 请求和响应体,适合开发环境和问题排查。但生产环境务必关掉——dump body 有性能开销,且可能泄露敏感数据。
为什么紧挨着 Trace? 日志和追踪是观测性的姐妹——Trace 负责跨服务的因果关系,Log 负责单服务的明细记录。两者都在最外层,保证覆盖完整的请求生命周期。如果把它们放在中间,其他中间件中写入的 internal.Infof 就无法被 LogCollector 收集了。
PrometheusHandler:为每个请求打上指标标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 func PrometheusHandler (path, method string ) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { startTime := timex.Now() cw := response.NewWithCodeResponseWriter(w) defer func () { code := strconv.Itoa(cw.Code) metricServerReqDur.Observe(timex.Since(startTime).Milliseconds(), path, method, code) metricServerReqCodeTotal.Inc(path, method, code) }() next.ServeHTTP(cw, r) }) } }
它暴露两个指标:
http_server_requests_duration_ms:请求耗时(Histogram),按 path × method × status_code 三维切分,桶值为 5/10/25/50/100/250/500/750/1000ms。
http_server_requests_code_total:请求计数(Counter),同样三维切分。
为什么在 Log 之后、保护类中间件之前? 这个位置很微妙——Prometheus 包裹了它内部的所有中间件和业务逻辑。这意味着:
被 MaxConns 拒绝的请求(503)会被 Prometheus 记录——因为拒绝发生在它里面。
被 Breaker/Shedding 拒绝的请求(503)也会被 Prometheus 记录——同理。
如果 Prometheus 放在 Breaker 里面,熔断期间的请求就会从监控中"消失",你看到的是"请求量突然降为零"而不是"全部返回 503"——前者会让你以为服务没人用,后者会让你立刻发现故障。
所以 Prometheus 在保护类中间件外层 ,是一个正确的观测位置决策:你要看到所有的请求,包括被拒绝的。
MaxConnsHandler:在门口数人头
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func MaxConnsHandler (n int ) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { latch := syncx.NewLimit(n) return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { if latch.TryBorrow() { defer func () { if err := latch.Return(); err != nil { logx.WithContext(r.Context()).Error(err) } }() next.ServeHTTP(w, r) } else { w.WriteHeader(http.StatusServiceUnavailable) } }) } }
用 syncx.Limit 实现了并发连接数的信号量控制。TryBorrow() 成功拿到令牌就继续(请求结束后归还),拿不到就直接返回 503。
为什么在保护类中间件的最外层? MaxConns 的限制对象是当前进程的并发连接数 ,与下游是否故障、CPU 是否高负载都无关。它是第一条防线——门都关了就不要再放人进来了,连后面断路器检查都多余。
默认值是 MaxConns: 10000,你可以在配置中调整。注意这个值要和系统文件描述符上限对齐——如果 MaxConns 设得过高,可能会先打到 OS 的 fd 限制。
BreakerHandler:下游坏了就别再打
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func BreakerHandler (method, path string , metrics *stat.Metrics) func (http.Handler) http.Handler { brk := breaker.NewBreaker(breaker.WithName(strings.Join([]string {method, path}, breakerSeparator))) return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { promise, err := brk.Allow() if err != nil { metrics.AddDrop() w.WriteHeader(http.StatusServiceUnavailable) return } cw := response.NewWithCodeResponseWriter(w) defer func () { if cw.Code < http.StatusInternalServerError { promise.Accept() } else { promise.Reject(fmt.Sprintf("%d %s" , cw.Code, http.StatusText(cw.Code))) } }() next.ServeHTTP(cw, r) }) } }
每条路由(method + path)拥有一个独立的断路器实例。每当响应码 >= 500,断路器收到一个 Reject 信号;响应码 < 500,收到 Accept。当失败率超过阈值,断路器跳闸,后续请求直接被拒绝返回 503,不再打到下游。
关于断路器算法的细节(Google SRE 熔断、滑动窗口等),我们留到第 10 篇"熔断、降载与限流"中专门展开。这里先关注它在链中的位置。
为什么在 Shedding 外侧? 断路器保护的是"下游服务是否健康",而降载保护的是"当前服务 CPU 是否过载"。从优先级上说,如果下游已经明确故障,就应该在前置环节就拒绝请求,不要等到后面的降载判断。这与 HTTP 代理的设计逻辑一致——先看目标可达不可达,再考虑自身负载够不够。
SheddingHandler:CPU 扛不住了就择优放行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 func SheddingHandler (shedder load.Shedder, metrics *stat.Metrics) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { sheddingStat.IncrementTotal() promise, err := shedder.Allow() if err != nil { metrics.AddDrop() sheddingStat.IncrementDrop() w.WriteHeader(http.StatusServiceUnavailable) return } cw := response.NewWithCodeResponseWriter(w) defer func () { if cw.Code == http.StatusServiceUnavailable { promise.Fail() } else { sheddingStat.IncrementPass() promise.Pass() } }() next.ServeHTTP(cw, r) }) } }
降载器使用基于 BBR 思路的自适应算法。它不依赖固定阈值,而是根据 CPU 负载和请求通过率动态决定"允许多少请求通过"。在上一篇中我们提到,engine 创建了两个降载器——普通路由使用配置的 CPU 阈值(默认 90%),标记了 WithPriority() 的高优先级路由使用更高的阈值(默认 95%),给核心接口留出更多"容错空间"。
为什么在 Breaker 内侧、Timeout 外侧? Shedding 的判断依据是当前服务的 CPU 负载 ,而非下游状态。如果下游断路器已经跳闸,说明故障在下游——应在前置环节就拦截。而 Shedding 应该在判定"下游可达"之后、开启超时计时之前执行——降载本身是极快的(内存操作),不消耗实际业务时间。
另一个细节:promise.Fail() vs promise.Pass() 的判断依据是响应码是否等于 503 。这与 Breaker 的 code >= 500 判断不同——降载器认为只有"服务不可用"才算失败,而 500(内部错误)不算降载器的失败(那可能是业务代码的 bug,不是容量问题)。这一区分使得降载器对"自身过载"的判断更加精确。
降载算法的内部实现(BBR、maxPass、minRt、在途请求等)同样会在第 10 篇中详细剖析。
TimeoutHandler:为请求装上倒数计时
在标准库的 http.TimeoutHandler 之上,go-zero 做了自己的实现,原因有两点:
第一,区分客户端取消和服务端超时。 Go 标准库的 http.TimeoutHandler 把所有超时情况一律返回 503 Service Unavailable。但 context.Canceled 和 context.DeadlineExceeded 的语义完全不同——前者可能是客户端主动断开,后者才是服务端处理超时。go-zero 将客户端取消映射为 499(nginx 风格状态码):
1 2 3 4 5 6 7 8 9 case <-ctx.Done(): httpx.ErrorCtx(r.Context(), w, ctx.Err(), func (w http.ResponseWriter, err error ) { if errors.Is(err, context.Canceled) { w.WriteHeader(statusClientClosedRequest) } else { w.WriteHeader(http.StatusServiceUnavailable) } })
这种区分对监控非常关键——499 代表客户端行为(可能是用户关了浏览器),503 代表服务端容量问题(需要扩容或排查),混淆二者会导致错误的告警和运维决策。
第二,自定义 timeoutWriter 的缓冲设计。 超时后不能直接向 http.ResponseWriter 写入——此时底层连接可能已经被标准库释放。go-zero 的 timeoutWriter 采用"先写 header 到临时 Header map、后写 body 到 bytes.Buffer"的策略:请求正常完成时,在主 goroutine 中将缓冲的 header 和 body 复制到真实的 ResponseWriter;请求超时时,只是设置 timedOut = true 标志,后续 handler 的任何写入操作被直接拒绝。这样实现了两个 goroutine(handler goroutine 和 timeout goroutine)之间的安全隔离。
代码中还有一个巧妙的保护:WebSocket 和 SSE 请求完全绕过超时 。这两种长连接会设置 Upgrade: websocket 或 Accept: text/event-stream 头,TimeoutHandler 检测到后直接透传不做超时控制——长连接的超时应该由业务层面(心跳、空闲超时)来处理,而不是一刀切的 HTTP 超时。
为什么在 Recover 外侧? TimeoutHandler 通过 go func() 启动了一个新 goroutine 来执行后续中间件和业务 handler。如果这个 goroutine 中发生 panic,它需要被外层的 Recover 捕获。如果 Recover 放在 Timeout 外面,那 TimeoutHandler 内部 goroutine 的 panic 就收不到保护了——所以 Timeout 必须在 Recover 外面。
RecoverHandler:最后的安全网
1 2 3 4 5 6 7 8 9 10 11 12 func RecoverHandler (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { defer func () { if result := recover (); result != nil { internal.Error(r, fmt.Sprintf("%v\n%s" , result, debug.Stack())) w.WriteHeader(http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) }
它做的事极其简单却不可或缺——recover any panic,记录堆栈,返回 500。不要依赖其他中间件的 defer recover ——Go 的 recover 只能捕获当前 goroutine 中直接发生的 panic,而 TimeoutHandler 已经在不同 goroutine 中了。RecoverHandler 必须在 TimeoutHandler 的内侧 goroutine 中执行,才能捕获业务 handler 及其内侧中间件的 panic。
MetricHandler:在弹性保护之后精准度量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func MetricHandler (metrics *stat.Metrics) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { startTime := timex.Now() defer func () { metrics.Add(stat.Task{ Duration: timex.Since(startTime), }) }() next.ServeHTTP(w, r) }) } }
它把每次请求的耗时记录到一个 stat.Metrics 中。这个 Metrics 在后台以每分钟为周期聚合统计,输出 QPS、平均延迟、中位数、P90、P99、P99.9 等分位数。
注意 MetricHandler 和 PrometheusHandler 的区别。 PrometheusHandler 提供的是"拉模式"的标准 Prometheus 指标(通过 /metrics 端点暴露),而 MetricHandler 提供的是 go-zero 内置的"推模式"统计日志(每分钟一行)。两者互补——Prometheus 适合集中监控系统的图表和告警,内置统计适合开发阶段快速扫一眼日志了解性能趋势。
为什么在保护类中间件之后? Metrics 统计的是"实际通过的请求耗时"。被 Breaker/Shedding/MaxConns 拒绝的请求不应该进入延迟统计——它们耗时接近于零,如果混入统计会把 P99 拉低,让你误以为系统性能很好。Drops 有单独计数(通过 metrics.AddDrop()),这才是衡量保护触发频率的正确方式。
MaxBytesHandler:拒绝超大请求体
1 2 3 4 5 6 7 8 9 10 11 12 func MaxBytesHandler (n int64 ) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { if r.ContentLength > n { w.WriteHeader(http.StatusRequestEntityTooLarge) } else { next.ServeHTTP(w, r) } }) } }
默认限制 1MB。它只看 Content-Length 头——如果客户端没发这个头,那即使 body 实际超大也无法提前拦截。不过没关系,我们在上一篇中学过 httpx.Parse 中还有一层 maxBodyLen(8MB)的 io.LimitReader 保护。这是纵深防御——MaxBytesHandler 做快速前置拦截(只需要读一个 int64 的 Content-Length 头),io.LimitReader 做实际读取量的硬限制。
GunzipHandler:为下游解压
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func GunzipHandler (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get(httpx.ContentEncoding), gzipEncoding) { reader, err := gzip.NewReader(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } r.Body = reader } next.ServeHTTP(w, r) }) }
这是整个链中最内层的中间件——它替换了 r.Body 为 gzip.Reader,让后续的 httpx.Parse 对透明的解压无感知。为什么在最里层? 解压是纯粹的数据转换,不影响任何安全或弹性决策。放在最里层也意味着前面的 MaxBytes 检查的是压缩后的 Content-Length,不会因为解压而导致内存膨胀逃逸——这是合理的,因为内存分配发生在往下传递的 Reader 中。
至此,11 个默认中间件全部讲解完毕。它们在链中的位置不是随意安排的,而是遵循一条清晰的职责分层:
层级
中间件
核心职责
位置理由
观测层 (最外)
Trace、Log、Prometheus
全方位记录请求信息
必须包裹一切,才能记录完整生命周期和所有被拒的请求
入口保护层
MaxConns
限制并发连接数
最前置的保护——满员了就别往后走了
下游保护层
Breaker
下游故障熔断
下游明确坏了就快速失败,不走后续判断
自保层
Shedding
CPU 过载自适应降载
下游可达时才判断自身容量,且决策极快
执行控制层
Timeout、Recover
超时控制和 panic 恢复
Timeout 启动新 goroutine,Recover 必须在它里面
度量层
Metrics
记录通过的保护层的请求延迟
只度量"真正执行的请求",被前置拒绝的不应计入延迟
数据转换层 (最内)
MaxBytes、Gunzip
请求体的安全检查和解压
纯数据转换,不影响任何决策
这张表可以作为你日常调整中间件的参考,你也可以对照这个分层来开发自己的中间件,把它放到正确的层级中。
认证与安全:JWT、密钥过渡和签名校验
默认中间件链组装完成后,bindRoute 紧接着追加认证和签名校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func (ng *engine) bindRoute(fr featuredRoutes, router httpx.Router, metrics *stat.Metrics, route Route, verifier func (chain.Chain) chain.Chain) error { chn := ng.chain if chn == nil { chn = ng.buildChainWithNativeMiddlewares(fr, route, metrics) } chn = ng.appendAuthHandler(fr, chn, verifier) for _, middleware := range ng.middlewares { chn = chn.Append(convertMiddleware(middleware)) } handle := chn.ThenFunc(route.Handler) return router.Handle(route.Method, route.Path, handle) }
认证中间件被追加在默认链的内侧,也就是保护层之后、业务 handler 之前。这样设计的逻辑是:当客户端在安全边界之外,所有保护机制应该对请求一视同仁——超时、熔断、降载保护的是服务器资源,不应该因为客户端没认证就区别对待 。
JWT 认证和密钥过渡
JWT 认证通过 handler.Authorize 实现。解析 token、验证签名、提取 claims,然后将非标准 claims 注入到 request 的 context 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func Authorize (secret string , opts ...AuthorizeOption) func (http.Handler) http.Handler { parser := token.NewTokenParser() return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { tok, err := parser.ParseToken(r, secret, authOpts.PrevSecret) if err != nil { ... } claims, ok := tok.Claims.(jwt.MapClaims) for k, v := range claims { switch k { case jwtAudience, jwtExpire, jwtId, jwtIssueAt, jwtIssuer, jwtNotBefore, jwtSubject: default : ctx = context.WithValue(ctx, k, v) } } next.ServeHTTP(w, r.WithContext(ctx)) }) } }
把业务定义的 claims 注入 context 是一个很好的设计——业务代码通过 r.Context().Value("userId") 就能拿到当前用户,不需要解析 token 两次。
现在看密钥过渡的支持。在密钥轮转场景中,如果直接替换旧密钥,那旧 token 还没过期的新 token 又已签发的短暂窗口内,会有用户被拒绝。WithPrevSecret 提供了一种平滑过渡:
1 2 rest.WithJwtTransition("new-secret-key" , "old-secret-key" )
框架内部,ParseToken 会先尝试用主密钥(新密钥)解析,如果失败再尝试用过渡密钥(旧密钥)。一段时间后确认旧 token 全部过期,再把 WithJwtTransition 改成 WithJwt——零停机切换。
认证失败时,先调用用户自定义的 UnauthorizedCallback(如果设置了),再做兜底处理——写 401:
1 2 3 4 5 6 7 8 9 10 func unauthorized (w http.ResponseWriter, r *http.Request, err error , callback UnauthorizedCallback) { writer := response.NewHeaderOnceResponseWriter(w) if callback != nil { callback(writer, r, err) } writer.WriteHeader(http.StatusUnauthorized) }
这里用了 HeaderOnceResponseWriter——它只允许第一次 WriteHeader 调用生效,后续调用被静默忽略。这个设计是为了防止认证回调里写了 200 后又被框架兜底写成 401。
签名校验与 ContentSecurity
除了 JWT,go-zero 还提供了一种基于 RSA 非对称加密的内容安全机制——ContentSecurityHandler。它校验请求头 X-Content-Security 中携带的签名信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func LimitContentSecurityHandler (limitBytes int64 , decrypters map [string ]codec.RsaDecrypter, tolerance time.Duration, strict bool , callbacks ...UnsignedCallback) func (http.Handler) http.Handler { return func (next http.Handler) http.Handler { return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodDelete, http.MethodGet, http.MethodPost, http.MethodPut: header, err := security.ParseContentSecurity(decrypters, r) if err != nil { executeCallbacks(w, r, next, strict, httpx.CodeSignatureInvalidHeader, callbacks) } else if code := security.VerifySignature(r, header, tolerance); code != httpx.CodeSignaturePass { executeCallbacks(w, r, next, strict, code, callbacks) } else if r.ContentLength > 0 && header.Encrypted() { LimitCryptionHandler(limitBytes, header.Key)(next).ServeHTTP(w, r) } else { next.ServeHTTP(w, r) } default : next.ServeHTTP(w, r) } }) } }
整个校验流程是:解析签名头 → 验证签名是否过期 → 如果 body 已加密,再解密 body。签名校验只在 GET/POST/PUT/DELETE 四种方法上生效(其他方法如 OPTIONS 直接放行,CORS preflight 不能被签名拦截)。解密是与验签配套的能力——CryptionHandler 先 base64 解码 body,再用 AES-ECB 解密,响应也加密后返回。这为需要端到端加密的敏感场景提供了基础能力。
在严格模式(Strict: true)下,校验失败直接返回 403;非严格模式下,校验失败时继续执行后续 handler——这是一种"记录但不拒绝"的调试友好模式。
用户全局中间件与路由级中间件的叠加
链的最后一环是用户通过 server.Use() 注册的全局中间件,以及通过 WithMiddleware 注册的路由级中间件。二者区别在于生效范围:
全局中间件 (server.Use(mw)):对 server 上所有路由生效,追加在 JWT/签名校验之后。
路由级中间件 (WithMiddleware(mw, routes...)):仅对指定路由生效,直接包裹在 handler 外围:
1 2 3 4 5 6 7 8 9 10 11 12 13 func WithMiddleware (middleware Middleware, rs ...Route) []Route { routes := make ([]Route, len (rs)) for i := range rs { route := rs[i] routes[i] = Route{ Method: route.Method, Path: route.Path, Handler: middleware(route.Handler), } } return routes }
路由级中间件不像全局中间件那样"追加到链尾",而是直接把 handler 包裹为新的 handler。这意味着路由级中间件在最内层——比全局中间件还要靠近业务代码。
两者的典型分工是:全局中间件做通用能力(如 CORS header、请求 ID 注入),路由级中间件做特定接口的约束(如角色权限检查、特定接口的限流)。全局中间件能看到 JWT 注入的 context,因为它们位于认证中间件内侧——这是一个隐式的约定,确保了"认证先于授权"的语义。
特殊传输:CORS、SSE 与文件服务
除了中间件链内部的能力,go-zero 还为三种特殊传输场景提供了 RouteOption 和 RunOption 级别的支持。
CORS:拦截在 router 层面的跨域处理
CORS 不是以中间件形式存在的,而是通过 router 装饰器 实现:
1 2 3 4 5 6 7 func WithCors (origin ...string ) RunOption { return func (server *Server) { server.router.SetNotAllowedHandler(cors.NotAllowedHandler(nil , origin...)) server.router = newCorsRouter(server.router, nil , origin...) } }
corsRouter 在 patRouter 外面包裹了一层 cors.Middleware——在路由匹配之前就设置 CORS 响应头。这解决了 CORS 的"鸡和蛋"问题:OPTIONS preflight 请求的路径可能在 radix tree 中找不到,如果在中间件层处理,可能中间件根本跑不到。在 router 外层处理 CORS 保证了 preflight 请求总是能拿到正确的跨域头。
NotAllowedHandler 同时注册为 404 的处理者——保证了即便请求的路径不存在,浏览器也能拿到跨域头,不会因为 CORS 错误掩盖 404。
SSE:清除写入超时、设置流式响应头
SSE(Server-Sent Events)通过 WithSSE() RouteOption 启用。它的处理分两步:
路由注册时 ,buildSSERoutes 包裹 handler,在原 handler 执行前清除写入超时、设置 SSE 响应头:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 func buildSSERoutes (routes []Route) []Route { for i, route := range routes { routes[i].Handler = func (w http.ResponseWriter, r *http.Request) { rc := http.NewResponseController(w) rc.SetWriteDeadline(time.Time{}) w.Header().Set("Content-Type" , "text/event-stream" ) w.Header().Set("Cache-Control" , "no-cache" ) w.Header().Set("Connection" , "keep-alive" ) h(w, r) } } return routes }
请求处理时 ,TimeoutHandler 检测 Accept: text/event-stream,跳过超时控制——这和 WebSocket 一样,长连接不应该被 HTTP 超时控制。
文件服务:在 router 层面劫持请求
文件服务同样不是中间件,而是通过 fileServingRouter 在 router 层拦截:当请求路径以指定前缀开头时,直接交给 http.FileServer 处理,不走路由匹配和中间件链。这个设计避免了"为每个静态文件路径注册一条路由"的麻烦,对于前端静态资源、下载文件等场景非常实用。
四种请求走一遍:成功、超时、panic、未认证
现在我们把前文的分析串起来,追踪四种典型请求在中间件链中的完整旅程。
成功的正常请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 请求到达 → Trace: 创建 span, inject trace context 到响应头 → Log: 启动计时器, 注入 LogCollector 到 ctx → Prometheus: 启动计时器 → MaxConns: TryBorrow() 成功 → Breaker: Allow() 返回 promise → Shedding: Allow() 返回 promise → Timeout: ctx.WithTimeout, 启动 handler goroutine → Recover: defer recover → Metrics: 启动计时器 → MaxBytes: Content-Length <= 1MB ✓ → Gunzip: Content-Encoding 不是 gzip, 跳过 → JWT Authorize: 解析 token 成功, 注入 claims 到 ctx → 用户全局中间件 (如果有) → 业务 Handler ← 返回响应 ← 返回 ← 返回 ← 返回 ← metrics.Add(Duration) ← (recover 未触发) ← tw.Write + tw.WriteHeader ← ctx cancel, promise.Pass() ← promise.Accept() (Code=200 < 500) ← latch.Return() ← Prometheus: observe duration + code_total ← logBrief: [HTTP] 200 - GET /api/user - 12ms ← span.End() + span.SetStatus
业务执行超时
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 请求到达 → Trace/Log/Prometheus/MaxConns 正常通过 → Breaker: Allow() 返回 promise → Shedding: Allow() 返回 promise → Timeout: ctx.WithTimeout(3秒), 启动 goroutine → Recover → Metrics → MaxBytes → Gunzip → Auth → Handler ... 3秒后 ... ← ctx.Done()! 超时触发 → ErrorCtx: 写入 503 (因为 ctx.Err() 是 DeadlineExceeded) → tw.timedOut = true (后续 handler 写入被拒绝) → promise.Fail() (Code=503) → promise.Reject("503 Service Unavailable") (Code >= 500) → latch.Return() → Prometheus: code="503" → Log: [HTTP] 503 - ... slowcall(3000ms) + dumpRequest (因为 code >= 500)
注意:超时返回 503,它既触发降载器的 Failure 计数又触发断路器的 Reject 计数。这意味着如果大量请求持续超时,断路器最终会跳闸,短路掉后续请求——这是合理的设计,持续超时通常意味着下游出了问题,熔断可以给下游恢复的空间。
业务代码 panic
1 2 3 4 5 6 7 8 9 10 11 12 13 请求到达 → 通过所有外层中间件 → 进入 Timeout goroutine → Recover: defer recover() → Metrics → MaxBytes → Gunzip → Auth → Handler 💥 PANIC! ← recover 捕获, internal.Error 写入日志 + 堆栈 ← WriteHeader(500) ← tw.WriteHeader 被调用 → tw.code=500 → 主 goroutine 最终写入真实 ResponseWriter ← promise.Fail() (Code=503? 不, 这里 Code 是 500) 等等——SheddingHandler 判断的是 cw.Code == http.StatusServiceUnavailable (503) 才 Fail。 500 不等于 503,所以降载器会 pass 这个请求。这是有意的还是 bug? 实际上 500 是由于 panic 导致的业务逻辑错误,不是容量问题。降载器的职责是反映"是否因为容量不足而失败",500 不应该被计为降载失败——这与我们之前的分析一致。
再确认一次——BreakerHandler 中判断的是 cw.Code < http.StatusInternalServerError(即 code < 500)才 Accept。500 满足的是 >= 500,所以它会进入 Reject。这是正确的——从断路器视角,500 Internal Server Error 是一个失败调用,应该计入失败率。
所以最终结果:panic 返回 500,断路器 Reject,降载器 Pass,两者各自忠于自己的判断维度。
JWT 认证失败
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 请求到达 → 通过所有默认中间件 → JWT Authorize: ParseToken 失败 → unauthorized: → detailAuthLog: dump 完整请求用于排查 → unauthorizedCallback (如果有自定义回调) → writer.WriteHeader(401) ← HeaderOnceResponseWriter 保证只写一次 ← 返回(没有调用 next.ServeHTTP,handler 不会执行) ← 用户全局中间件没机会运行 ← 返回路径: MaxBytes/Gunzip/Metrics(但没业务执行,耗时极短) ← Shedding: cw.Code = 401 ≠ 503 → Pass ← Breaker: cw.Code = 401 < 500 → Accept ← MaxConns: Return ← Prometheus: code="401" ← Log: [HTTP] 401 - GET /api/user - 2ms ... + dumpRequest (code >= 400) ← Trace: span.End(), status=Unauthenticated
这里有一个值得注意的细节:认证失败返回 401,它小于 500,既不被断路器计为失败,也不被降载器计为失败。这符合直觉——认证失败是客户端问题,不是服务端故障,不应该触发弹性保护。
全景渲染
把上述所有内容整合成一张请求穿过中间件链的全景图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 ┌──────────────────────────────────────────────────────┐ │ patRouter │ │ 路由匹配 + pathvar 注入 │ └────────────────────────┬─────────────────────────────┘ │ ┌──────────────┴──────────────┐ │ chain.Chain │ │ │ │ ┌── Trace ──────────────┐ │ 观测层: 全量记录 │ │ ┌── Log ──────────┐ │ │ 启动span、计时、注入ctx │ │ │ ┌── Prometheus ┐│ │ │ │ │ │ │ ││ │ │ │ │ │ │ MaxConns ───┼┼──┼──┼── 入口保护: 并发限流 │ │ │ │ ││ │ │ │ │ │ │ Breaker ────┼┼──┼──┼── 下游保护: 熔断 │ │ │ │ ││ │ │ │ │ │ │ Shedding ───┼┼──┼──┼── 自保: CPU过载降载 │ │ │ │ ││ │ │ │ │ │ │ Timeout ────┼┼──┼──┼── 执行控制: 超时 + goroutine │ │ │ │ ┌────────┐ ││ │ │ │ │ │ │ │Recover │ ││ │ │ 安全网: panic捕获 │ │ │ │ │┌──────┐│ ││ │ │ │ │ │ │ ││Metrics││ ││ │ │ 度量层: 仅度量通过的请求 │ │ │ │ ││┌─────┐││ ││ │ │ │ │ │ │ │││MaxB │││ ││ │ │ │ │ │ │ │││ ┌───┐││ ││ │ │ │ │ │ │ │││ │Gun│││ ││ │ │ 数据转换层 │ │ │ │ │││ └───┘││ ││ │ │ │ │ │ │ ││└─────┘││ ││ │ │ │ │ │ │ │└──────┘│ ││ │ │ │ │ │ │ └────────┘ ││ │ │ │ │ │ │ ││ │ │ │ │ │ │ JWT Auth ───┼┼──┼──┼── 认证: 解析token, 注入claims │ │ │ │ ││ │ │ │ │ │ │ 用户全局中间件 ─┼──┼──┼── 自定义: Use() 注册 │ │ │ │ ││ │ │ │ │ │ │ Handler ────┼┼──┼──┼── 业务逻辑 │ │ │ │ ││ │ │ │ │ │ └──────────────┘│ │ │ │ │ └─────────────────┘ │ │ │ └───────────────────────┘ │ └─────────────────────────────┘
总结
本文从 chain.Chain 的不可变链表机制出发,逐个剖析了 go-zero REST 框架的 11 个默认中间件,以及 JWT 认证、签名校验、CORS、SSE 和文件服务等扩展能力。核心结论可以归纳为三条:
第一条,中间件顺序有"依赖律"。 一个中间件的位置取决于它需要保护谁、又依赖谁。Trace 和 Log 需要在最外层才能覆盖完整的请求生命周期;Timeout 需要在 Recover 的外面(因为它在新的 goroutine 中执行业务逻辑);Metrics 需要在保护层的内侧(只度量通过的请求)。如果你需要插入自定义中间件,也要按这个思路判断——它是观测型的(放外层)、保护型的(对比 Breaker/Shedding 的定位)、还是数据转换型的(放内层)。
第二条,保护机制各司其职,不可互相替代。 MaxConns 限制并发连接,Breaker 保护下游,Shedding 保护自身 CPU,MaxBytes 和 io.LimitReader 做纵深防御——它们各自攻击不同的失败模式,组合在一起才形成完整的弹性体系。下一篇文章进入 zRPC 领域时,你会发现同样的设计思路也体现在了 RPC 的 interceptor 链中。
第三条,认证和授权在保护层之后。 JWT 认证和签名校验在默认链的内侧——即保护机制对所有人都生效。这是正确的策略:不管你是谁,都应该享受熔断、降载、超时控制的保护;而认证失败只影响你能不能访问这个接口,不影响服务器的稳定性。
还有一点值得强调:go-zero 的 11 个默认中间件全部可以通过 MiddlewaresConf 中的开关按需控制。如果不需要某个能力——比如你的服务不接收 gzip 压缩的请求——直接关掉 Gunzip: false,链上就少了一层,比很多框架"必须全部带上再手动移除"的设计要干净。
下一篇文章我们将开始学习zRPC 调用链 。在那里,你会看到 REST 中这些中间件的"镜像版本"如何以 gRPC interceptor 的形式再次出现。