Go HTTP 中间件入门:日志、恢复、鉴权和请求 ID 怎么串起来

本文详解 Go HTTP 中间件的设计模式与实现,包含请求 ID、panic 恢复、鉴权、日志等实战中间件,附带中间件链组合和常见陷阱。

中间件的核心本质:包装 Handler

Go 的 HTTP handler 接口非常简单:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

中间件本质上就是一个包装函数:它接收一个 http.Handler,返回一个新的 http.Handler。新的 handler 在原 handler 执行前后插入横切逻辑——记录日志、恢复 panic、注入请求 ID、统计耗时、设置响应头、认证鉴权等。这种设计非常优雅,因为它不侵入业务代码,只是在外围叠加功能。

理解了这个模式后,你会发现所有 Go Web 框架(Gin、Echo、Chi、Fiber)的中间件实现思路都与此相同。标准库完全能写出工业级质量的中间件链。框架提供的丰富语法糖只是在底层包装了这个模式。

中间件类型定义与链式组合

基础类型

type Middleware func(http.Handler) http.Handler

链式组合函数

package main

import "net/http"

type Middleware func(http.Handler) http.Handler

// Chain 将多个中间件串联成一个链
// 注意:执行顺序是从 middlewares[0] 到 len-1
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
	// 从后往前包装,保证外层先执行
	for i := len(middlewares) - 1; i >= 0; i-- {
		h = middlewares[i](h)
	}
	return h
}

使用方式

handler := Chain(mux,
	Recover(logger),      // 最外层:panic 恢复兜底
	CORS(allowedOrigins), // 跨域处理
	RateLimit(100),       // 限流
	RequestID(),          // 注入请求 ID
	RequestLogger(logger),// 请求日志
)

执行顺序极其重要。外层中间件最先拦截请求,最后收到响应。上面的顺序意味着:请求到达时依次经过 Recover -> CORS -> RateLimit -> RequestID -> RequestLogger -> 业务 Handler;响应返回时顺序反过来。所以 Recover 在最外层——即使内部所有中间件和业务代码都 panic 了,它也能兜底恢复。

请求 ID 中间件:追踪全链路日志

package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"net/http"
	"time"
)

type contextKey string

const requestIDKey contextKey = "request_id"

// RequestID 中间件:为每个请求生成或复用唯一 ID
func RequestID() Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// 优先从客户端 header 中获取,便于分布式追踪
			id := r.Header.Get("X-Request-ID")
			if id == "" {
				id = newRequestID()
			}

			// 将 ID 写入响应头,方便客户端排查
			w.Header().Set("X-Request-ID", id)

			// 将 ID 注入 context,供下游 handler 和日志使用
			ctx := context.WithValue(r.Context(), requestIDKey, id)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// RequestIDFromContext 从 context 中提取请求 ID
func RequestIDFromContext(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}

// newRequestID 生成 32 字符的随机 ID
func newRequestID() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		// 降级方案:时间戳
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

context value 要克制使用,适合传递请求范围的元信息(如请求 ID、用户身份),绝对不要传业务参数。过度使用 context value 会隐藏函数之间的数据依赖关系,让代码难以理解和测试。

panic 恢复中间件:避免单个请求打崩服务

package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"runtime/debug"
)

// Recover 中间件:捕获 panic,防止整个进程崩溃
func Recover(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if value := recover(); value != nil {
					logger.Error("panic recovered",
						"panic", value,
						"request_id", RequestIDFromContext(r.Context()),
						"path", r.URL.Path,
						"stack", string(debug.Stack()),
					)
					// 只能返回 500,因为 handler 可能已经写入了部分响应
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()

			next.ServeHTTP(w, r)
		})
	}
}

关键注意事项:

  • panic 恢复不能替代正常的错误处理。可预期的失败(如参数校验失败、数据库查询无结果)应该返回 error 并转成对应的 HTTP 响应码。
  • panic 更适合表达真正不可恢复的编程错误(如数组越界、nil 指针解引用)。
  • 一旦 panic 发生,当前请求的状态已经不可预期——不要尝试继续执行业务逻辑。
  • 务必记录 debug.Stack(),否则生产环境排查 panic 根源非常困难。

简单 Token 鉴权中间件

package main

import (
	"net/http"
	"strings"
)

// RequireToken 返回一个要求指定 API Token 的中间件
func RequireToken(expected string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
			if token == "" {
				http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized)
				return
			}
			if token != expected {
				http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

这只是演示位置概念的入门示例。真实系统中鉴权应该考虑:

  • Token 签名验证(JWT、HMAC)
  • Token 过期检查
  • 权限范围校验(Scope 或 RBAC)
  • 密钥轮换机制
  • 审计日志记录

但中间件的"鉴权通过后才进入业务 handler"这个边界原则始终不变。

完整的请求日志中间件(含状态码记录)

初学者版本的日志中间件不记录状态码,因为 http.ResponseWriter 没有公开 Status() 方法。我们需要用一个包装器来拦截写入操作:

package main

import (
	"log/slog"
	"net/http"
	"time"
)

// responseRecorder 包装 http.ResponseWriter,记录状态码和写入字节数
type responseRecorder struct {
	http.ResponseWriter
	status int
	size   int
}

func (rr *responseRecorder) WriteHeader(code int) {
	rr.status = code
	rr.ResponseWriter.WriteHeader(code)
}

func (rr *responseRecorder) Write(b []byte) (int, error) {
	n, err := rr.ResponseWriter.Write(b)
	rr.size += n
	return n, err
}

func (rr *responseRecorder) Status() int {
	if rr.status == 0 {
		return http.StatusOK // 默认值
	}
	return rr.status
}

// RequestLogger 记录每个请求的完整信息
func RequestLogger(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &responseRecorder{ResponseWriter: w}

			next.ServeHTTP(rec, r)

			logger.Info("http request",
				"request_id", RequestIDFromContext(r.Context()),
				"method", r.Method,
				"path", r.URL.Path,
				"status", rec.Status(),
				"size", rec.size,
				"duration_ms", time.Since(start).Milliseconds(),
				"ip", r.RemoteAddr,
				"user_agent", r.UserAgent(),
			)
		})
	}
}

完整版本的请求日志中间件能记录状态码、响应体大小、请求耗时、客户端 IP 和用户代理。这些信息在排查慢接口和分析 API 使用模式时至关重要。

CORS 中间件实战实现

跨域资源共享(CORS)是 Web API 中几乎必备的功能。以下是一个简洁的实现:

package main

import "net/http"

// CORS 配置中间件
func CORS(allowedOrigins []string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			origin := r.Header.Get("Origin")
			allowed := false
			for _, o := range allowedOrigins {
				if o == origin || o == "*" {
					allowed = true
					break
				}
			}

			if allowed {
				w.Header().Set("Access-Control-Allow-Origin", origin)
				w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
				w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID")
				w.Header().Set("Access-Control-Allow-Credentials", "true")
			}

			// Preflight 请求直接返回
			if r.Method == http.MethodOptions {
				w.WriteHeader(http.StatusNoContent)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

生产环境推荐使用成熟的库如 github.com/rs/cors,但理解其底层原理很有必要。特别要注意:

  • Access-Control-Allow-Credentials: true 与通配符 * 不能共存
  • Preflight 请求不会走到业务 handler,必须在中间件拦截层就返回
  • 对不需要跨域的 API 不要开启 CORS,减少攻击面

简单位桶限流中间件

在高并发场景中,限流是保护后端服务的重要手段。以下是一个基于令牌桶算法的简单实现:

package main

import (
	"net/http"
	"sync"
	"time"
)

// RateLimiter 实现简单的令牌桶限流
type RateLimiter struct {
	tokens   float64
	capacity float64
	rate     float64 // 每秒生成令牌数
	mu       sync.Mutex
	lastTime time.Time
}

func NewRateLimiter(capacity, rate float64) *RateLimiter {
	return &RateLimiter{
		tokens:   capacity,
		capacity: capacity,
		rate:     rate,
		lastTime: time.Now(),
	}
}

func (rl *RateLimiter) Allow() bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(rl.lastTime).Seconds()
	rl.tokens = min(rl.capacity, rl.tokens+elapsed*rl.rate)
	rl.lastTime = now

	if rl.tokens >= 1 {
		rl.tokens--
		return true
	}
	return false
}

func min(a, b float64) float64 {
	if a < b {
		return a
	}
	return b
}

func RateLimit(limit *RateLimiter) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if !limit.Allow() {
				w.Header().Set("Retry-After", "1")
				http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

这个实现使用 sync.Mutex 保证并发安全,适合单机部署。如果是分布式系统,需要使用 Redis 等共享存储进行限流。生产环境中推荐使用 golang.org/x/time/rate 包提供的标准限流器。

完整的服务器组装示例

package main

import (
	"fmt"
	"log"
	"log/slog"
	"net/http"
	"os"
	"time"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	// 业务路由
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, `{"status":"ok"}`)
	})
	mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintln(w, `{"users":[]}`)
	})

	// 组装中间件链
	handler := Chain(mux,
		Recover(logger),
		CORS([]string{"https://example.com"}),
		RateLimit(NewRateLimiter(100, 10)),
		RequestID(),
		RequestLogger(logger),
	)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      handler,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	log.Println("Server listening on :8080")
	log.Fatal(server.ListenAndServe())
}

注意 ReadTimeout 和 WriteTimeout 的设置:没有超时限制的服务器在面对慢客户端时会持续持有连接,消耗 goroutine 资源,最终可能导致服务不可用。

常见陷阱与 FAQ

Q1: 中间件顺序为什么这么重要?

外层中间件包裹内层中间件。请求穿过时是从外到内,响应返回时是从内到外。如果 Recover 不在最外层,内部中间件的 panic 就无法被捕获。

Q2: 中间件之间如何传递数据?

使用 context.WithValue 传递请求级别的元信息。不要传递业务数据——那是函数参数的职责。

Q3: 包装 ResponseWriter 会不会丢失额外方法?

如果需要支持 http.Hijacker、http.Flusher 或 http.Pusher 等接口,包装器需要显式实现这些接口。

Q4: 生产环境推荐使用标准库还是框架?

标准库适合理解原理、小型服务和学习。生产级项目推荐 Echo 或 Chi,因为它们提供成熟的中间件生态、参数绑定和路由组。

Q5: defer 在中间件中的执行顺序是什么?

defer 是后进先出(LIFO)。在 Chain 的包装结构中,外层中间件的 defer 先注册,最后执行。这与 Go 的 defer 语义一致。

总结

Go HTTP 中间件的本质就是 func(http.Handler) http.Handler。通过包装 handler,可以把日志、请求 ID、panic 恢复、鉴权、CORS、限流等横切逻辑从业务 handler 中抽离出来。中间件顺序必须明确,context value 使用要克制,panic 恢复不能替代正常错误处理,超时配置不可忽视。用标准库自己实现一次完整的中间件链后,再使用任何框架都会更加得心应手。

性能对比与基准测试

理解 Go HTTP 中间件入门 的最佳方式是通过基准测试观察实际行为。下面是一个基本的测试框架:

func BenchmarkMain(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // 你的核心操作
        _ = i
    }
}

运行 go test -bench=. -benchmem 可以得到每个操作的耗时和内存分配数据。对比不同实现时,建议固定输入规模,跑多次取平均值。机器负载、CPU 频率和缓存状态都会影响结果,所以重要的优化应该在稳定环境中反复验证。

常见错误与最佳实践

错误一:性能优化过早

很多初学者在代码刚写好就开始担心性能,结果引入了不必要的复杂度。正确的做法是先用清晰的写法实现功能,在性能问题真实出现时再通过 profile 定位热点,再针对性优化。

错误二:忽略边界条件

空输入、超大输入、并发场景、系统资源耗尽等边界条件往往是 bug 的来源。写代码时养成习惯:每个函数都问自己,空值怎么办?错误怎么处理?资源泄漏有没有可能?

错误三:错误处理不完整

Go 的错误处理要求显式检查。常见问题是只在最外层处理错误,中间层把 error 吞掉或转换后丢失了上下文。使用 fmt.Errorf 配合 %w 保留原始错误链,上层可以用 errors.Is 判断。

错误四:并发代码缺少同步

Go 的并发模型很简洁,但共享内存访问必须同步。不要凭感觉认为"这里应该不会并发访问"就省略锁或原子操作。用 go test -race 验证并发安全性。

生产环境注意事项

生产环境的代码比本地开发要求更高。以下是一些通用原则:

  1. 日志要克制:不要记录敏感信息,不要在热路径上打印大量日志。日志的目的是排查问题,不是记录所有细节。
  2. 超时和取消:所有外部调用都要有超时。使用 context.WithTimeout 或 context.WithDeadline,不要依赖默认的无限等待。
  3. 资源限制:限制请求体大小、并发连接数、内存使用。不要让客户端决定你的资源消耗。
  4. 优雅关闭:http.Server 要设置 Shutdown 超时,goroutine 要有退出机制,channel 要有容量和关闭策略。
  5. 可观测性:至少记录关键指标(QPS、延迟、错误率)。没有指标的服务就像黑箱,出了问题只能靠猜。

测试策略

好的测试应该覆盖正常路径、错误路径和边界条件。表驱动测试是 Go 社区推荐的方式:

func TestExample(t *testing.T) {
    tests := []struct {
        name string
        input string
        want  string
    }{
        {"valid", "hello", "HELLO"},
        {"empty", "", ""},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := strings.ToUpper(tt.input)
            if got != tt.want {
                t.Fatalf("ToUpper(%q) = %q, want %q", tt.input, got, tt.want)
            }
        })
    }
}

测试不是写完就扔,每次修改代码后都要跑一遍。CI 中集成 go test ./... 是最基本的自动化保障。

实战 FAQ

Q: 这个功能在旧版 Go 中能用吗?
A: 需要看具体功能引入的版本。建议使用最新的稳定版 Go,以获得最佳工具链支持和标准库能力。

Q: 第三方库更好还是标准库更好?
A: 能标准库解决先用标准库。第三方库引入依赖成本和许可证风险。只有在标准库确实无法满足需求时才引入。

Q: 写测试时发现代码难测怎么办?
A: 这通常意味着代码耦合度太高。考虑把大函数拆成小函数,把外部依赖抽象成接口,把全局状态改成参数传递。好的代码往往是好测的代码。

Q: 怎么判断代码算不算过度设计?
A: 问自己几个问题:这个抽象让调用方更简单了吗?减少了多少重复代码?维护成本是增加还是减少了?如果答案不确定或是否定的,那可能就是过度设计。

小结

Go HTTP 中间件入门 是 Go 开发中非常实用的技能。掌握它不仅能解决当前问题,更能建立正确的编程习惯和思维方式。关键不是记住所有 API,而是理解背后的设计原则和适用边界。

在实际项目中,先让代码工作起来,再让它正确,最后才考虑让它更快。清晰的代码比聪明的代码更有价值。遇到问题时先看文档和源码,再查社区经验,最后才考虑引入新依赖。保持克制和好奇心,你的 Go 代码会越来越稳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练