二、Context和AOP方案

2021-02-12T14:21:02+08:00 | 46分钟阅读 | 更新于 2021-02-12T14:21:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 讲清一个 HTTP 请求的数据从哪来(Body / Query / Header / Form / Path)以及如何被 Context 封装、缓存、重复读取。
  2. 对照 Beego / Gin / Echo / Iris 的 Context 设计,解释我们「用结构体而非接口」「不为小众需求进核心」的设计取舍。
  3. 说清 RespData + flashResp 机制为什么是 AOP 的基石:它让响应先缓存后发送,中间件才能读取运行结果。
  4. 用洋葱模型讲透 Middleware(type Middleware func(next HandleFunc) HandleFunc):前置 / 后置逻辑、调用 next、不调用即中断。
  5. 自己实现并注册五大经典中间件(Recovery、AccessLog、错误处理、Tracing、Prometheus),说清注册顺序对执行顺序的影响。

前置知识:

  • 上一章的 Server 抽象与路由树(本文承接其路由匹配结果)
  • Go 的 net/http 基础:http.Request、http.ResponseWriter、Handler 模型
  • 了解 io.Reader / io.Writer 与「流只能读一次」的直觉
  • 基本的可观测性概念(日志、指标、链路追踪,后文会展开)

本章你会动手做的事:

  • 给 Context 加上 readBody 缓存,验证中间件读了 Body 后业务函数仍能 BindJSON。
  • 写一个 LoggingMiddleware,用 defer 在响应发出前打印状态码与耗时。
  • 注册 Recovery 为最外层中间件,故意在 handler 里 panic,观察它如何兜住并返回 500。
Context 与 AOPContextAOP 基石Middleware请求输入缓存bodyCache 可重读RespData+flashResp先缓存后发送洋葱模型/中断Recovery/Tracing

一、本章导读

在上一章中,我们实现了 Server 抽象和路由树,搭建了 Web 框架的骨架。当你注册了一条路由 GET /user/:id 后,框架已经能找到对应的处理函数了。但找到函数之后呢?

业务函数需要从请求中读取数据(比如用户的 id),处理之后再把结果写回给浏览器。这个"读请求"和"写响应"的过程,就是 Context 要解决的问题。

而当你有多个业务函数都需要做登录校验、记录日志、捕获 panic……这些跟业务无关但又每个接口都需要的逻辑,就轮到 AOP(面向切面编程) 出场了。

本章分两大板块:

  1. Context 模块——封装请求输入和响应输出
  2. AOP 方案——通过 Middleware 机制解决横向关注点

二、HTTP 请求结构速览

在深入 Context 设计之前,我们需要先了解一个 HTTP 请求里到底有哪些部分可以被我们读取。这是 Context 存在的基础。

2.1 Request 的五大输入来源

一个 HTTP 请求到达服务器时,数据藏在这几个地方:

POST /user/profile?id=123&source=mobile HTTP/1.1
Host: localhost:8081
Content-Type: application/json
X-My-Company-Token: abc123

{"name": "xiaoming", "age": 18}
来源位置Go 中的访问方式
Body请求体({"name":...})r.Body
QueryURL 问号后(id=123)r.URL.Query()
Header请求头(X-My-Company-Token)r.Header.Get(key)
Form表单编码的 Bodyr.Form(需先 ParseForm)
Path 参数URL 路径段(/user/:id 中的 id)由路由树匹配后注入

2.2 Request Body 的关键特征

Body 只能读取一次。 这是 HTTP 协议和 Go 标准库共同决定的事实。

// 第一次读取:正常返回 body 内容
body, err := io.ReadAll(r.Body)

// 第二次读取:返回空,因为 Body 是一个 Stream(流)
body2, err := io.ReadAll(r.Body) // body2 是空的!

为什么只能读一次? 因为 r.Body 本质上是一个 io.ReadCloser,底层是网络连接上的字节流。数据从网络流入内存后,就像河水流过一样,不会再倒回来。

这个特征对 Context 设计影响极大:如果我们在中间件里读了 Body 做 logging,那么后面的业务函数再读就什么都读不到了。解决方案后面会讲。

http.Request 有个 GetBody 字段,理论上可以多次读取,但原生实现里它是 nil。有些 Web 框架会在收到请求后第一件事就是给 GetBody 赋值,从而支持重复读取。

2.3 Query 查询参数

// URL: http://localhost:8081/search?name=xiaoming&age=18
values := r.URL.Query()
name := values.Get("name") // "xiaoming"
age := values.Get("age")   // "18"(字符串!需要自己转 int)

所有 Query 值都是 string 类型,如果需要数字或其他类型,用户要自己转换。

2.4 Header 请求头

// Go 会自动规范化 Header 名字的大小写
r.Header.Get("Content-Type")     // 正确
r.Header.Get("content-type")     // 也能取到(Go 内部做了规范化)
r.Header.Get("CONTENT-TYPE")    // 也能取到

自定义 Header 一般用 X- 开头,例如 X-My-Company-Token。

2.5 Form 表单

// 使用前必须先调用 ParseForm
r.ParseForm()
// r.Form 包含 URL 查询参数 + Body 表单数据
// r.PostForm 仅包含 Body 中的表单数据(且要求 Content-Type 为 application/x-www-form-urlencoded)

name := r.FormValue("name") // 内部会自动调用 ParseForm
字段包含范围编码要求
r.FormURL 查询参数 + Body 表单无特殊要求
r.PostForm仅 Body 表单必须是 x-www-form-urlencoded

建议:在实际项目中,优先使用 JSON 进行通信。表单主要在传统的 HTML 表单提交场景下使用。


三、各框架 Context 设计对比

在动手实现之前,先看看成熟的 Web 框架是怎么设计 Context 的,从中学习经验和教训。

3.1 Beego 的 Context 设计

Beego 的 Context 结构:

Context
├── Input          // 对输入的封装
│   ├── Bind 方法   // 将各部分输入绑定到结构体
│   ├── 数据获取方法 // 从 Body、Query、Form 等处获取数据
│   └── 判断方法     // 判断是否为 AJAX 等
├── Output         // 对输出的封装
│   ├── Resp 方法   // 序列化后输出(JSON、XML 等)
│   ├── Render     // 渲染模板
│   └── 状态码维护   // 维持 HTTP 响应码
└── Response       // 直接耦合了 Session

特点:将输入和输出分别封装为 Input 和 Output 两个子结构,职责分离清晰。但 Response 直接耦合了 Session,设计略显臃肿。

3.2 Gin 的 Context 设计

Gin 的 Context 内部维护了多个字段:

  • 缓存数据(Keys map):避免重复读取和解析的开销
  • 控制 Handler 调度的方法(Abort、Next):Context 不只是数据容器,还兼任调度器

特点:Context 同时承担了数据容器和流程控制两个角色,功能丰富但结构复杂。

3.3 Echo 的 Context 设计

Echo 将 Context 设计为接口,但只有一个实现 context。

特点:额外维护了 logger 和 lock(用锁保护 Context),这在 Web 框架中非常罕见。

3.4 Iris 的 Context 设计

Iris 同样将 Context 设计为接口,并且允许用户接入自己的实现。

特点:抽象程度最高,但从实际使用来看,自定义实现的需求很少。

3.5 对比总结

框架Context 类型输入输出分离内置缓存流程控制
Beego结构体是(Input/Output)否否
Gin结构体否是是(Abort/Next)
Echo接口否否是
Iris接口否是是

我们的选择:采用结构体而非接口。原因很简单——目前看不出设计为接口的必要性。Echo 设计为接口但只有一个实现,说明有点过度设计。如果将来真有需求,再抽象不迟。


四、Context 核心职责:处理输入

4.1 输入处理要解决的七个子问题

回顾上一章我们定义的基础 Context,它已经有 Query、PostForm 等基础方法。现在需要进一步完善:

  1. 反序列化 Body:将 Body 字节流转换为具体类型(如 JSON → struct)
  2. 处理表单输入:表单可以看作一种特殊的序列化格式
  3. 处理查询参数:从 URL 中读取并转换类型
  4. 处理路径参数:从路由匹配结果中读取
  5. 重复读取 Body:解决 Body 只能读一次的问题
  6. 读取 Header:从 Header 中读取特定值
  7. 模糊读取:按一定顺序从多个来源尝试获取值

4.2 BindJSON:反序列化 Body

JSON 是最常见的输入格式,率先支持:

// BindJSON 将 Body 中的 JSON 数据反序列化到 val 指向的结构体
//
// 参数 val 必须是指针(如 &user),因为需要填充结构体的字段
// 如果 val 是 nil,直接返回不做处理
//
// 使用示例:
//   var user struct { Name string `json:"name"` }
//   err := ctx.BindJSON(&user) // 注意传 &user,不是 user
func (c *Context) BindJSON(val interface{}) error {
	if val == nil {
		return nil
	}
	// 读取 Body 内容
	// 注意:这是基础版,Body 只能读一次
	// 完善版会用 readBody() 带缓存地读取,支持多次调用
	body, err := io.ReadAll(c.Req.Body)
	if err != nil {
		return err
	}
	// json.Unmarshal 将 JSON 字节流解析为 Go 结构体
	// 解析结果会写入 val 指向的结构体
	return json.Unmarshal(body, val)
}

使用示例:

type LoginReq struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

h.Post("/login", func(ctx *Context) {
	var req LoginReq
	if err := ctx.BindJSON(&req); err != nil {
		_ = ctx.JSON(400, map[string]string{"error": "参数格式错误"})
		return
	}
	// 处理登录逻辑...
	_ = ctx.JSON(200, map[string]string{"token": "xxx"})
})

4.3 重复读取 Body:缓存机制

上一章提到 Body 只能读取一次。如果中间件先读了 Body 做 AccessLog,业务函数里的 BindJSON 就读不到数据了。解决方案是在第一次读取后缓存:

type Context struct {
	// ... 其他字段

	// bodyCache 缓存已读取的 Body 内容
	// 解决 io.ReadAll 只能读一次的问题
	bodyCache []byte
	// hasReadBody 标记是否已读取过 Body
	hasReadBody bool
}

// readBody 读取 Body 内容,并缓存
// 第一次调用时读取原始 Body,后续调用直接返回缓存
func (c *Context) readBody() ([]byte, error) {
	if c.hasReadBody {
		return c.bodyCache, nil
	}
	body, err := io.ReadAll(c.Req.Body)
	if err != nil {
		return nil, err
	}
	// 关闭原始 Body(已经读完)
	_ = c.Req.Body.Close()
	// 缓存内容
	c.bodyCache = body
	c.hasReadBody = true
	// 同时恢复 r.Body,让标准库的其他方法也能读
	c.Req.Body = io.NopCloser(bytes.NewReader(body))
	return body, nil
}

原理:第一次读取后,把内容存到 bodyCache。同时用 io.NopCloser(bytes.NewReader(body)) 重建一个可读的 Body,这样标准库的其他方法(如 r.FormValue)也不会受影响。

修改 BindJSON 使用缓存版本:

// 修改后的 BindJSON:使用 readBody() 带缓存读取
func (c *Context) BindJSON(val interface{}) error {
	if val == nil {
		return nil
	}
	// 改为调用 readBody(),支持多次读取
	// 中间件先读了 Body 做日志,业务函数再调用 BindJSON 也能正常读到
	body, err := c.readBody()
	if err != nil {
		return err
	}
	return json.Unmarshal(body, val)
}

4.4 JSON 控制选项:UseNumber 和 DisallowUnknownFields

Go 的 json 包在反序列化时有两个常用选项:

选项作用
UseNumber为 true 时,JSON 中的数字不会被解析为 float64,而是保留为 json.Number(字符串形式),避免大整数精度丢失
DisallowUnknownFields为 true 时,如果 JSON 中出现结构体没有定义的字段,会返回 error

要不要在框架核心中支持这两个选项?

答案是:不需要。

原因如下:

  • 绝大多数用户不需要控制这两个选项
  • 即便需要,用户完全可以自己写一个方法来处理:
// 用户自己实现的带选项的 JSON 绑定
func BindReqJSONOpt(c *Context, val interface{}, useNumber, disallowUnknown bool) error {
	decoder := json.NewDecoder(bytes.NewReader(c.bodyCache))
	if useNumber {
		decoder.UseNumber()
	}
	if disallowUnknown {
		decoder.DisallowUnknownFields()
	}
	return decoder.Decode(val)
}

设计原则:如果一个小众需求,用户可以自己解决,就不要在框架核心上支持。要克制自己!

4.5 查询参数缓存

上一章我们已经实现了查询参数缓存:

// queryValues 缓存的查询参数
queryValues url.Values

func (c *Context) Query(key string) string {
	if c.queryValues == nil {
		// 只在第一次调用时解析,后续直接从缓存取
		c.queryValues = c.Req.URL.Query()
	}
	return c.queryValues.Get(key)
}

为什么缓存是安全的? 因为 Web 框架收到请求后,请求内容就确定了,不会再变化。不存在缓存失效或数据不一致的问题。

4.6 路径参数的类型转换问题

路径参数从路由树匹配后注入,默认是 string 类型:

// 路由:GET /user/:id
// 请求:GET /user/123
ctx.PathParams["id"] // "123"(string)

用户需要 int 时得自己转:

id, err := strconv.ParseInt(ctx.PathParams["id"], 10, 64)

要不要提供 PathParamAsInt64 这样的方法?

面临的问题和表单一样:如果提供 AsInt64,要不要提供 AsInt32、AsInt16、AsInt8?全部基础类型都来一个?

4.7 StringValue 方案:参考 sql.Row

与其为每种类型都写一个方法,不如参考 database/sql 包中 sql.Row 的设计思路:

// StringValue 返回路径参数的字符串值
// 用户拿到字符串后自行转换为目标类型
func (c *Context) PathParamValue(key string) string {
	return c.PathParams[key]
}

使用方式:

h.Get("/user/:id", func(ctx *Context) {
	// 拿到字符串,自己转
	idStr := ctx.PathParamValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		_ = ctx.JSON(400, map[string]string{"error": "invalid id"})
		return
	}
	_ = ctx.JSON(200, map[string]int64{"id": id})
})

优势:StringValue 这种设计在大多数情况下不会发生内存逃逸(字符串本身就在堆上,不需要额外分配),性能更好。

关于泛型:能不能用泛型来解决这个问题?答案是不能。Go 泛型有一个限制:结构体本身可以是泛型的,但不能在泛型结构体上声明泛型方法。所以 func (c *Context[T]) PathParamAs[T any](key string) T 这样的写法会直接编译错误。


五、Context 核心职责:处理输出

5.1 JSON 响应

上一章已经实现了基础的 JSON 方法,进一步完善:

// RespJSON 返回 JSON 格式的响应
//
// 参数:
//   status: HTTP 状态码,如 200(成功)、400(参数错误)、500(服务器错误)
//   data:   要序列化为 JSON 的数据,可以是 map、struct 等
//
// 使用示例:
//   _ = ctx.JSON(200, map[string]string{"message": "hello"})
//   _ = ctx.JSON(400, map[string]string{"error": "参数错误"})
func (c *Context) JSON(status int, data interface{}) error {
	// 设置响应头,告诉客户端返回的是 JSON 格式
	c.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
	// 写入状态码(这里直接写到网络,完善版会缓存到 RespStatusCode)
	c.Resp.WriteHeader(status)
	// 用 json.Encoder 将 data 编码为 JSON 并写入响应
	// 相比 json.Marshal + Write,Encoder 是流式写入,不需要额外的内存暂存
	encoder := json.NewEncoder(c.Resp)
	return encoder.Encode(data)
}

// RespJSONOK 返回 200 的 JSON 响应,简化用户代码
// 是 ctx.JSON(http.StatusOK, data) 的便捷写法
//
// 使用示例:
//   _ = ctx.JSONOK(map[string]string{"token": "abc123"})
func (c *Context) JSONOK(data interface{}) error {
	return c.JSON(http.StatusOK, data)
}

问题:如果 data 已经是 string 或 []byte 了怎么办?显然不需要再调用 JSON 方法序列化一遍,用户直接操作 Resp 即可。

5.2 获取运行结果:RespData 机制

这里有一个关键问题:中间件如何在响应发送给客户端之前读取到最终的状态码和响应体?

原生的 http.ResponseWriter 一旦调用 WriteHeader 或 Write,数据就直接发到网络上了,取不回来。

解决方案:自己存一份。

类比:就像写邮件时先写在「草稿箱」里,而不是一发就飞走。所有中间件都还能翻看、修改草稿,等你点「发送」(flashResp)的那一刻,整封信才真正寄出去。这样日志记录、错误拦截都能在发送前完成。

这张图展示了「先缓存后统一发送」的协作关系:

graph TD
    Biz[业务函数] -->|写入| Cache[RespData + RespStatusCode
缓存在 Context] MW[中间件] -->|发送前可读取与修改| Cache Cache -->|flashResp 统一刷新| Net[真正写入网络]
type Context struct {
	// ... 其他字段

	// RespData 缓存响应体数据
	// 在 flashResp 之前,数据都存在这里,可以修改
	RespData []byte
	// RespStatusCode 缓存状态码
	RespStatusCode int
}

然后需要一个自定义的 ResponseWriter 来拦截写入操作:

// responseWriter 包装 http.ResponseWriter
// 拦截 Write 和 WriteHeader,数据先存到 Context,不直接写网络
//
// 为什么需要包装?
//   原生 http.ResponseWriter 调用 Write/WriteHeader 会立即把数据发到网络
//   一旦发出,中间件就无法读取状态码和响应体了
//   通过包装拦截,数据先存在 Context 里,flashResp 时才真正发送
type responseWriter struct {
	// 嵌入原始的 http.ResponseWriter
	// 未被重写的方法(如 Header())会直接调用原始 writer
	http.ResponseWriter
	// ctx 反向引用 Context,用于把拦截到的数据存进去
	ctx *Context
}

// Write 拦截响应体的写入
// 当业务函数调用 ctx.Resp.Write(data) 时,实际执行的是这个方法
// 数据不写网络,而是存到 ctx.RespData
func (w *responseWriter) Write(data []byte) (int, error) {
	// 先存到 RespData,不直接写到网络
	w.ctx.RespData = data
	// 返回写入的字节数(假装写入成功,实际上只是存到了内存)
	return len(data), nil
}

// WriteHeader 拦截状态码的写入
// 当业务函数调用 ctx.Resp.WriteHeader(200) 时,实际执行的是这个方法
// 状态码不写网络,而是存到 ctx.RespStatusCode
func (w *responseWriter) WriteHeader(statusCode int) {
	// 先存状态码,不直接写
	w.ctx.RespStatusCode = statusCode
}

然后在 serve 方法中使用这个包装器,最后统一刷新:

func (h *HTTPServer) serve(ctx *Context) {
	// 用自定义 writer 包装原始 writer
	ctx.Resp = &responseWriter{
		ResponseWriter: ctx.Resp,
		ctx:           ctx,
	}

	// 查找路由
	mi, ok := h.router.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
	if !ok || mi.n == nil || mi.n.handler == nil {
		ctx.RespStatusCode = http.StatusNotFound
		ctx.RespData = []byte("404 NOT FOUND")
		// 跳转到 flashResp 统一刷新
		h.flashResp(ctx)
		return
	}

	ctx.PathParams = mi.pathParams

	// 执行业务逻辑(此时数据都存在 ctx 里,还没写到网络)
	mi.n.handler(ctx)

	// 最后统一刷新:把缓存的状态码和响应体真正写到网络
	h.flashResp(ctx)
}

// flashResp 将缓存的状态码和响应体真正写入网络
func (h *HTTPServer) flashResp(ctx *Context) {
	// 如果用户没设状态码,默认 200
	if ctx.RespStatusCode == 0 {
		ctx.RespStatusCode = http.StatusOK
	}
	// 先写状态码
	ctx.Resp.(*responseWriter).ResponseWriter.WriteHeader(ctx.RespStatusCode)
	// 再写响应体
	if len(ctx.RespData) > 0 {
		_, _ = ctx.Resp.(*responseWriter).ResponseWriter.Write(ctx.RespData)
	}
}

这个 flashResp 机制是整个 AOP 方案的基础。因为有了它,中间件才能在响应发出去之前拿到状态码和响应体,从而实现记录日志、错误页面重定向等功能。

// SetCookie 设置 Cookie
// 其实用户可以直接调用 http.SetCookie(ctx.Resp, &cookie)
// 但提供这个方法对新手更友好
func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool) {
	http.SetCookie(c.Resp, &http.Cookie{
		Name:     name,
		Value:    value,
		MaxAge:   maxAge,
		Path:     path,
		Domain:   domain,
		Secure:   secure,
		HttpOnly: httpOnly,
	})
}

这些方法不是必须的——用户完全可以直接调用标准库的 http.SetCookie。但对新手来说,有这些方法会更方便,因为他们可能找不到标准库的对应方法。

5.4 错误页面问题

常见需求:如果响应返回了 404,重定向到首页。

但这里有个棘手的问题:不是所有的 404 都要重定向。比如异步加载数据的 AJAX 请求,404 了也不应该重定向,否则前端拿到的是一个 HTML 页面而不是 JSON 错误信息。

所以错误页面处理不能简单地在 Context 里实现,需要放到 AOP 层面 来设计——让用户可以针对不同路由注册不同的错误处理逻辑。这个问题我们留到 AOP 部分解决。


六、Context 设计思考

6.1 Context 是线程安全的吗?

不是。 但这不需要担心。

和路由树不需要线程安全的原因不同,Context 不需要线程安全是因为:在我们的预期里,Context 只会被用户在单个处理方法中使用,不应该被多个 goroutine 同时操作。

如果一个 Context 实例在多个 goroutine 之间共享,那说明设计上出了问题。如果真的需要线程安全的 Context,可以提供一个装饰器,让用户在使用前手动包装。

6.2 为什么不设计为接口?

目前看不出来必要性。Echo 设计为接口但只有一个实现,说明有点过度设计。即便 Iris 允许自定义实现,实际使用的人也极少。

设计原则:不要为了"可能"的扩展性而过度设计。先用结构体,将来有需求再抽象。

6.3 能不能用泛型?

不能。Go 泛型有一个限制:结构体可以是泛型的,但不能在结构体上声明泛型方法。

// 编译错误:不能在泛型结构体上声明泛型方法
type Context[T any] struct {
	// ...
}

func (c *Context[T]) BindJSON(val T) error { // ← 这行直接报错
	// ...
}

所以 StringValue 也不能声明为泛型方法,只能返回 string 让用户自己转换。


七、完善后的 Context 完整代码

综合以上设计,这是我们最终的 Context 实现:

package web

import (
	"bytes"
	"encoding/json"
	"io"
	"net/http"
	"net/url"
)

// Context 代表请求上下文
// 不是线程安全的,预期只在单个请求处理中使用
type Context struct {
	// ========== 原始请求和响应 ==========

	// Req 原始请求对象
	Req *http.Request
	// Resp 响应写入器(被 responseWriter 包装)
	Resp http.ResponseWriter

	// ========== 路由相关 ==========

	// PathParams 路径参数
	// 例如 /user/:id 匹配后,id 的值存在这里
	PathParams map[string]string

	// ========== 输入缓存 ==========

	// queryValues 缓存的查询参数
	// 第一次调用 Query 时解析,后续直接取缓存
	queryValues url.Values

	// bodyCache 缓存的 Body 内容
	// 解决 io.ReadAll 只能读一次的问题
	bodyCache    []byte
	hasReadBody  bool

	// ========== 输出缓存 ==========

	// RespData 缓存响应体数据
	// 在 flashResp 之前,数据都存在这里,可以修改
	RespData []byte
	// RespStatusCode 缓存状态码
	RespStatusCode int

	// ========== 扩展数据 ==========

	// UserValues 用户在中间件和业务逻辑之间传递数据
	// 类似 Gin 的 Keys
	UserValues map[string]any
}

// ==================== 输入相关方法 ====================

// readBody 读取 Body 内容并缓存
// 解决原生 r.Body 只能读一次的问题:第一次读取后缓存,后续直接返回缓存
func (c *Context) readBody() ([]byte, error) {
	// 如果已经读取过,直接返回缓存
	if c.hasReadBody {
		return c.bodyCache, nil
	}
	// 第一次读取:从网络流中读取全部 Body
	body, err := io.ReadAll(c.Req.Body)
	if err != nil {
		return nil, err
	}
	// 关闭原始 Body(数据已读完)
	_ = c.Req.Body.Close()
	// 缓存内容,供后续读取使用
	c.bodyCache = body
	c.hasReadBody = true
	// 恢复 Body 可读性:用 bytes.NewReader 创建新的可读流
	// 这样标准库的方法(如 r.FormValue)也能正常读取
	c.Req.Body = io.NopCloser(bytes.NewReader(body))
	return body, nil
}

// BindJSON 将 Body 中的 JSON 反序列化到 val
// 内部调用 readBody(),所以支持多次调用(第二次返回缓存的 Body)
func (c *Context) BindJSON(val interface{}) error {
	if val == nil {
		return nil
	}
	// 读取 Body(带缓存)
	body, err := c.readBody()
	if err != nil {
		return err
	}
	// json.Unmarshal 将 JSON 字节流解析为 Go 结构体
	return json.Unmarshal(body, val)
}

// Query 获取查询参数(带缓存)
// 第一次调用时解析 URL 的 query string,后续直接从缓存取
func (c *Context) Query(key string) string {
	// 延迟初始化:只有第一次调用时才解析
	if c.queryValues == nil {
		c.queryValues = c.Req.URL.Query()
	}
	return c.queryValues.Get(key)
}

// QueryAll 获取查询参数的所有值
// 一个 key 可能对应多个值,如 ?tag=go&tag=web
func (c *Context) QueryAll(key string) []string {
	if c.queryValues == nil {
		c.queryValues = c.Req.URL.Query()
	}
	// 返回该 key 对应的所有值(切片)
	return c.queryValues[key]
}

// PostForm 获取表单参数
// 内部调用标准库的 r.FormValue,会自动调用 ParseForm
func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}

// PathParamValue 获取路径参数的字符串值
// 例如 /user/:id 匹配 /user/123,则 PathParamValue("id") 返回 "123"
func (c *Context) PathParamValue(key string) string {
	return c.PathParams[key]
}

// Header 获取请求头
// Go 标准库会自动规范化 Header 名大小写
func (c *Context) Header(key string) string {
	return c.Req.Header.Get(key)
}

// ==================== 输出相关方法 ====================

// JSON 返回 JSON 格式的响应
// 注意:这里不直接写网络,而是把数据缓存到 RespData 和 RespStatusCode
// 真正写网络是在 flashResp 里完成的
func (c *Context) JSON(status int, data interface{}) error {
	// 缓存状态码(不直接写网络)
	c.RespStatusCode = status
	// 将 data 序列化为 JSON 字节流
	bytes, err := json.Marshal(data)
	if err != nil {
		return err
	}
	// 缓存响应体(不直接写网络)
	c.RespData = bytes
	// 设置 Content-Type 告诉客户端返回的是 JSON
	c.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
	return nil
}

// JSONOK 返回 200 的 JSON 响应
func (c *Context) JSONOK(data interface{}) error {
	return c.JSON(http.StatusOK, data)
}

// String 返回字符串响应
func (c *Context) String(status int, msg string) {
	// 缓存状态码
	c.RespStatusCode = status
	// 缓存响应体(string 转 []byte)
	c.RespData = []byte(msg)
	// 设置 Content-Type 为纯文本
	c.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
}

// SetCookie 设置 Cookie
// 用户也可以直接调用 http.SetCookie,这里提供便捷方法
func (c *Context) SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool) {
	http.SetCookie(c.Resp, &http.Cookie{
		Name:     name,     // Cookie 名
		Value:    value,    // Cookie 值
		MaxAge:   maxAge,   // 最大存活时间(秒),0 表示会话级
		Path:     path,     // 生效路径,如 "/"
		Domain:   domain,   // 生效域名
		Secure:   secure,   // true 表示只在 HTTPS 下发送
		HttpOnly: httpOnly, // true 表示 JS 无法读取(防 XSS)
	})
}

八、AOP 方案:面向切面编程

8.1 什么是 AOP?

AOP(Aspect Oriented Programming),即面向切面编程。核心思想是将横向关注点从业务逻辑中剥离出来。

什么是横向关注点?就是那些跟业务没关系,但每个业务接口都要处理的逻辑。常见的有:

类别具体例子
可观测性logging(日志)、metrics(指标)、tracing(链路追踪)
安全相关登录校验、鉴权、权限控制
错误处理错误页面、统一错误返回
可用性保证熔断、限流、降级

生活中的类比:想象一栋写字楼,每个楼层(业务逻辑)都在正常办公。但所有楼层都需要保安(安全)、电梯(基础设施)、消防(安全)。保安、电梯、消防就是"横向关注点"——它们不属于任何一个楼层,但每个楼层都需要。AOP 就是把这些公共设施统一管理。

基本上所有 Web 框架都会设计自己的 AOP 方案。

8.2 各框架 AOP 设计对比

Beego:三种机制并存

Beego 早期设计了三类回调:

  1. Middleware:本质是 http.Handler 的包装。缺陷是它脱离了 Beego 的控制——用户的 Handler 无法利用 Beego 内部的数据(如路由匹配结果)。

  2. Filter:允许注册不同时机运行的过滤器,但都是单向的(只能前置或后置),不是环绕式的。

  3. FilterChain:可以看作"能用 Beego 内部数据的 Middleware"。它将 Filter 组织成链,每个 Filter 要考虑调用下一个 Filter,否则中断执行。但失去了指定运行时机的能力。

Gin:Context 调度

Gin 用的是半集中式设计,由 Context 调度中间件。实现者在 HandlerFunc 里主动调用 ctx.Next() 来执行下一个中间件。

Echo 和 Iris

Echo 的设计和 Beego 的 FilterChain 基本一样,依赖于 MiddlewareFunc 返回的 HandlerFunc 主动调用 next。Iris 和 Echo 没有本质区别。

8.3 统一对比

框架核心机制环绕式利用内部数据
Beego Middlewarehttp.Handler 包装是否
Beego Filter时机注册否是
Beego FilterChain责任链是是
GinContext.Next()是是
Echonext() 调用是是
Irisnext() 调用是是

九、Middleware 核心设计

9.1 Middleware 定义

我们也叫 Middleware,它的定义非常简洁:接收一个 HandleFunc,返回一个 HandleFunc。

// Middleware 中间件
// 接收下一个处理函数,返回包装后的处理函数
type Middleware func(next HandleFunc) HandleFunc

本质:Middleware 既是一个责任链模式(每个环节决定是否调用下一个),也是一个洋葱模式(层层包裹,最里面是业务逻辑)。

9.2 洋葱模式详解

请求进来 →

┌─ Middleware A (panic recovery)
│  ┌─ Middleware B (logging)
│  │  ┌─ Middleware C (auth)
│  │  │  ┌─ 业务逻辑 ─┐
│  │  │  │             │
│  │  │  └─────────────┘  ← 业务执行完毕
│  │  └──────────────────  ← Middleware C 后置逻辑
│  └─────────────────────  ← Middleware B 后置逻辑
└────────────────────────  ← Middleware A 后置逻辑

← 响应出去

每一层 Middleware 可以在调用 next 之前做前置处理,在 next 返回之后做后置处理。这就是"洋葱"——一层包一层。

9.3 调用 next 的机制

// 正常调用 next:请求会一层层穿过中间件,最终到达业务逻辑
func LoggingMiddleware(next HandleFunc) HandleFunc {
	return func(ctx *Context) {
		// ===== 前置:请求到达时执行 =====
		start := time.Now()
		fmt.Println("收到请求:", ctx.Req.URL.Path)

		// 调用下一个,控制权交给下一层
		next(ctx)

		// ===== 后置:业务执行完毕后执行 =====
		duration := time.Since(start)
		fmt.Printf("请求完成: %s 耗时: %v 状态码: %d\n",
			ctx.Req.URL.Path, duration, ctx.RespStatusCode)
	}
}

如果不调用 next:

func AuthMiddleware(next HandleFunc) HandleFunc {
	return func(ctx *Context) {
		token := ctx.Header("Authorization")
		if token == "" {
			// 没有登录,直接返回 401,不调用 next
			_ = ctx.JSON(401, map[string]string{"error": "未登录"})
			return // ← 执行流程被打断,后面的中间件和业务逻辑都不会执行
		}
		// 验证通过,继续执行
		next(ctx)
	}
}

9.4 在 HTTPServer 中使用 Middleware

type HTTPServer struct {
	addr   string
	router

	// middlewares 中间件列表
	// 注意顺序:切片前面的在外层(最先执行),后面的在内层
	middlewares []Middleware
}

// Use 注册中间件
func (h *HTTPServer) Use(m Middleware) {
	h.middlewares = append(h.middlewares, m)
}

// serve 方法中应用中间件
func (h *HTTPServer) serve(ctx *Context) {
	// 用 responseWriter 包装
	ctx.Resp = &responseWriter{
		ResponseWriter: ctx.Resp,
		ctx:           ctx,
	}

	// 查找路由
	mi, ok := h.router.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
	if !ok || mi.n == nil || mi.n.handler == nil {
		ctx.RespStatusCode = http.StatusNotFound
		ctx.RespData = []byte("404 NOT FOUND")
		h.flashResp(ctx)
		return
	}

	ctx.PathParams = mi.pathParams

	// 把所有中间件层层包裹在业务 handler 外面
	// 从后往前遍历,让切片前面的中间件在最外层
	root := mi.n.handler
	for i := len(h.middlewares) - 1; i >= 0; i-- {
		root = h.middlewares[i](root)
	}

	// 执行整个洋葱
	root(ctx)

	// 统一刷新响应
	h.flashResp(ctx)
}

使用示例:

func main() {
	h := NewHTTPServer(":8081")

	// 注册中间件(顺序很重要!)
	h.Use(RecoveryMiddleware)   // 最外层:捕获 panic
	h.Use(LoggingMiddleware)     // 记录日志
	h.Use(AuthMiddleware)        // 登录校验

	// 注册路由
	h.Get("/user/:id", func(ctx *Context) {
		_ = ctx.JSONOK(map[string]string{
			"id":   ctx.PathParamValue("id"),
			"name": "xiaoming",
		})
	})

	_ = h.Start(":8081")
}

十、Middleware 实战:五大经典实现

10.1 AccessLog(访问日志)

记录每个请求的基本信息,方便调试。

import (
	"encoding/json" // json.Marshal 用于序列化日志结构体为 JSON
	"fmt"           // fmt.Println 输出日志到标准输出
	"time"          // time.Since 计算请求耗时
)

// accessLogEntry 访问日志记录的结构体
// 对应一条 JSON 日志,记录请求的关键信息
type accessLogEntry struct {
	Method       string `json:"method"`        // HTTP 方法(GET/POST 等)
	Path         string `json:"path"`          // 请求路径(如 /user/123)
	StatusCode   int    `json:"status_code"`   // 最终的 HTTP 状态码(如 200/404/500)
	Duration     string `json:"duration"`      // 请求耗时(如 "1.2ms")
	MatchedRoute string `json:"matched_route"` // 命中的路由模式(如 /user/:id)
}

// AccessLog 访问日志中间件
// 记录每个请求的方法、路径、状态码、耗时等信息,方便调试和监控
//
// 使用方式:h.Use(AccessLog)
func AccessLog(next HandleFunc) HandleFunc {
	return func(ctx *Context) {
		// 在业务逻辑执行前记录开始时间
		start := time.Now()

		// 用 defer 确保日志一定被输出,即使 next 发生 panic
		// defer 的执行时机:next(ctx) 返回后(无论正常返回还是 panic)
		defer func() {
			// 计算从请求开始到现在的耗时
			duration := time.Since(start)
			// 构建日志条目
			entry := accessLogEntry{
				Method:       ctx.Req.Method,        // 从原始请求获取 HTTP 方法
				Path:         ctx.Req.URL.Path,       // 从原始请求获取路径
				StatusCode:   ctx.RespStatusCode,     // 从 Context 缓存中获取状态码
				Duration:     duration.String(),      // 耗时转为可读字符串
				MatchedRoute: ctx.MatchRoute,          // 命中的路由模式
			}
			// 序列化为 JSON 并输出(生产环境可以替换为写入文件或发送到日志系统)
			data, _ := json.Marshal(entry)
			fmt.Println(string(data))
		}()

		// 调用下一个处理函数(可能是另一个中间件,也可能是业务逻辑)
		// next 返回后,defer 里的日志逻辑才会执行
		next(ctx)
	}
}

为什么在 defer 里输出日志?

  1. 确保即便 next 里发生了 panic,也能将请求记录下来
  2. 获得 MatchedRoute(命中的路由模板):它只有在执行了 next 之后才能获得,因为依赖于最终的路由树匹配结果

设计取舍:这个实现只记录寥寥几个字段,如果用户要记录更多数据怎么办?固定采用 JSON 序列化,要是用户想用 protobuf 怎么办?

答案是:让用户自己写。 默认提供的实现是给大多数普通用户使用的,也相当于一个示例,有需要的用户可以参考这个实现写自己的。

10.2 Panic Recovery(Panic 恢复)

捕获业务逻辑中的 panic,防止服务器崩溃。

import (
	"fmt"           // fmt.Printf 输出 panic 信息和堆栈
	"net/http"      // http.StatusInternalServerError 常量
	"runtime/debug" // debug.Stack 获取 panic 时的调用堆栈
)

// Recovery panic 恢复中间件
// 捕获业务逻辑或后续中间件中的 panic,防止服务器崩溃
//
// 工作原理:用 defer + recover() 捕获 panic
//   - 如果 next(ctx) 正常执行,defer 里的 recover() 返回 nil,什么都不做
//   - 如果 next(ctx) 发生 panic,defer 里的 recover() 返回 panic 值,执行错误处理
//
// 必须放在最外层(Use 第一个注册),才能捕获所有后续中间件和业务逻辑的 panic
// 使用方式:h.Use(Recovery) // 必须第一个注册
func Recovery(next HandleFunc) HandleFunc {
	return func(ctx *Context) {
		// defer 在 next(ctx) 返回后执行(无论正常返回还是 panic)
		defer func() {
			// recover() 只能在 defer 中调用
			// 如果没有 panic,返回 nil;如果有 panic,返回 panic 的值
			if err := recover(); err != nil {
				// 打印 panic 值和完整的调用堆栈,方便调试
				fmt.Printf("panic recovered: %v\n%s\n", err, debug.Stack())

				// 设置 500 状态码和错误响应体
				// 注意:这里是写到 ctx 的缓存字段,不是直接写网络
				// flashResp 时才真正写入网络
				ctx.RespStatusCode = http.StatusInternalServerError // 500
				ctx.RespData = []byte(`{"error":"Internal Server Error"}`)
				// 设置响应头,告诉客户端返回的是 JSON
				ctx.Resp.Header().Set("Content-Type", "application/json")
			}
		}()

		// 执行下一个处理函数
		// 如果这里面的代码 panic 了,上面的 defer + recover 会捕获到
		next(ctx)
	}
}

注意:Recovery 中间件应该放在最外层(即 Use 第一个注册),这样它才能包裹住所有后续的中间件和业务逻辑。

10.3 错误处理(错误页面)

解决之前在 Context 章节遗留的问题:根据状态码做不同处理。

// ErrorHandler 错误处理中间件
// 允许用户为不同的 HTTP 状态码注册回调函数
// 例如:404 重定向到首页、500 返回统一的错误页面
//
// 参数 statusHandlers 是一个 map,key 是状态码,value 是对应的处理函数
// 当业务逻辑返回的响应状态码在 map 中有注册时,执行对应的处理函数
//
// 使用方式:
//   errorHandlers := map[int]HandleFunc{
//       404: func(ctx *Context) { /* 重定向到首页 */ },
//       500: func(ctx *Context) { /* 返回错误页面 */ },
//   }
//   h.Use(ErrorHandler(errorHandlers))
func ErrorHandler(statusHandlers map[int]HandleFunc) Middleware {
	// 返回一个 Middleware(闭包捕获了 statusHandlers)
	return func(next HandleFunc) HandleFunc {
		return func(ctx *Context) {
			// 先执行业务逻辑(业务函数会设置 RespStatusCode)
			next(ctx)

			// 业务执行完毕后,检查状态码是否在 statusHandlers 中注册了
			if handler, ok := statusHandlers[ctx.RespStatusCode]; ok {
				// 找到了对应的处理器,执行它
				// 例如:状态码是 404,执行重定向到首页的逻辑
				handler(ctx)
			}
			// 如果没找到对应的处理器,什么都不做,保持原始响应
		}
	}
}

使用示例:

// 创建状态码到处理函数的映射
errorHandlers := map[int]HandleFunc{
	// 404 时重定向到首页
	// 修改状态码为 302(Found = 重定向),设置 Location 头
	404: func(ctx *Context) {
		ctx.RespStatusCode = http.StatusFound // 302,告诉浏览器跳转
		ctx.Resp.Header().Set("Location", "/") // 跳转到首页
	},
	// 500 时返回 JSON 格式的错误信息
	500: func(ctx *Context) {
		_ = ctx.JSON(500, map[string]string{"error": "服务器内部错误"})
	},
}

// 注册错误处理中间件
h.Use(ErrorHandler(errorHandlers))

注意:不是所有 404 都要重定向。比如 AJAX 请求 404 了,前端期望拿到 JSON 错误信息,而不是被重定向到首页。所以这个方案让用户自己决定哪些状态码需要特殊处理。

10.4 Tracing(链路追踪)

Tracing 记录从收到请求到返回响应的整个过程。在分布式环境下,它代表请求从 Web 收到,沿着微服务链条传递,得到响应再返回前端的完整链路。

关键概念

概念含义
tracer记录 trace 的实例,一般对应一个接口
trace一次完整的请求链路
spantrace 中的一段。trace 本身也是一个 span。span 有父子关系,构成多叉树
trace(根 span)
├── span: HTTP 请求处理
│   ├── span: 数据库查询
│   ├── span: 调用微服务 A
│   │   └── span: 微服务 A 内部处理
│   └── span: 发送消息到 Kafka

使用 OpenTelemetry

我们采用 OpenTelemetry API 作为抽象层,而不是自己定义 tracing API。因为:

  • OpenTelemetry 是 OpenTracing 和 OpenCensus 合并而来
  • 同时支持 logging、tracing 和 metrics
  • 适配了各种开源框架(Zipkin、Jaeger、Prometheus)
  • 自定义 API 大概率不如 OpenTelemetry 设计得好
import (
	"context" // context.Context 用于在中间件和业务逻辑间传递 trace 信息
	"fmt"     // fmt.Sprintf 拼接 span 名称
	"net/http" // http.Header 用于读取请求头中的 trace 信息
	"time"    // time.Since 计算请求耗时

	"go.opentelemetry.io/otel"        // otel 全局 API 入口
	"go.opentelemetry.io/otel/attribute" // attribute.String/Int 创建 span 属性
	"go.opentelemetry.io/otel/trace"  // trace.Tracer, trace.Span 接口
)

// tracingBuilder 链路追踪中间件的构建器
// 采用 Builder 模式,允许用户自定义 tracer(但大多数用户用默认的全局 tracer)
type tracingBuilder struct {
	// tracer 用户自定义的 tracer 实例
	// 如果为 nil,使用全局的 otel.GetTracerProvider() 创建
	tracer trace.Tracer
}

// Build 构建 Tracing 中间件
// 返回 Middleware 类型,可以直接用 h.Use 注册
func (b *tracingBuilder) Build() Middleware {
	// 如果用户没有指定 tracer,使用全局 tracer provider 创建一个
	tracer := b.tracer
	if tracer == nil {
		// instrumentationName 一般用包名,保证唯一即可
		// 这里用 "web-framework" 标识是我们的框架在创建 trace
		tracer = otel.GetTracerProvider().Tracer("web-framework",
			trace.WithInstrumentationVersion("v1.0.0"))
	}

	// 返回中间件
	return func(next HandleFunc) HandleFunc {
		return func(ctx *Context) {
			// Step 1: 从请求头中提取上游传递的 span context
			// 这一步把当前请求和上游调用方的链路连接起来
			// 例如:用户调用 API A → API A 调用 API B
			// API A 在请求头里放了 traceparent 头,API B 提取后就能连成一条完整链路
			otelCtx := otel.GetTextMapPropagator().Extract(
				ctx.Req.Context(),               // 从请求的 context 开始
				&httpHeaderCarrier{header: ctx.Req.Header}, // 从 HTTP 头里提取 trace 信息
			)

			// Step 2: 开启一个新 span
			// spanName 是 "GET /user/123" 这样的格式,方便在 tracing 系统中查看
			spanName := fmt.Sprintf("%s %s", ctx.Req.Method, ctx.Req.URL.Path)
			// tracer.Start 如果传入的 otelCtx 里已经有 span,新 span 就是它的子 span
			// 这就是父子关系的来源,最终构成一棵 span 树
			otelCtx, span := tracer.Start(otelCtx, spanName)
			// defer span.End() 确保 span 一定被结束(否则 trace 数据不会上报)
			defer span.End()

			// Step 3: 设置 span 属性(请求阶段的信息)
			// 这些属性会显示在 tracing 系统的 span 详情中
			span.SetAttributes(
				attribute.String("http.method", ctx.Req.Method),    // GET/POST 等
				attribute.String("http.url", ctx.Req.URL.String()),  // 完整 URL
				attribute.String("http.scheme", "http"),             // 协议
				attribute.String("http.host", ctx.Req.Host),         // 主机名
			)

			// Step 4: 把 otel context 存到 Context 里
			// 业务函数可以通过 ctx.UserValues["otel_ctx"] 取到 otel context
			// 用于在业务逻辑中创建子 span(如记录数据库查询耗时)
			if ctx.UserValues == nil {
				ctx.UserValues = make(map[string]any)
			}
			ctx.UserValues["otel_ctx"] = otelCtx

			// 同时把 otel context 设置到 http.Request 里
			// 这样标准库和第三方库(如数据库驱动)也能从 request 里取到 trace 信息
			ctx.Req = ctx.Req.WithContext(otelCtx)

			// Step 5: 执行业务逻辑
			next(ctx)

			// Step 6: 业务执行完毕后,记录响应阶段的信息
			// 注意:用 MatchRoute(路由模式)而非实际路径,因为 URL 可能很长很复杂
			// MatchRoute = "/user/:id" 比 "/user/123" 更利于在监控系统中聚合统计
			span.SetAttributes(
				attribute.String("http.route", ctx.MatchRoute),        // 命中的路由模式
				attribute.Int("http.status_code", ctx.RespStatusCode),  // 最终状态码
			)

			// 记录请求总耗时(毫秒)
			span.SetAttributes(
				attribute.Int("http.duration_ms", int(time.Since(span.StartTime().Time())/time.Millisecond)),
			)
		}
	}
}

// httpHeaderCarrier 适配 http.Header 到 OpenTelemetry 的 TextMapCarrier 接口
// OpenTelemetry 的 Extract 方法需要一个实现了 Get/Set/Keys 的对象来读取 HTTP 头
// 这个结构体就是把 http.Header 适配成 OpenTelemetry 能理解的格式
type httpHeaderCarrier struct {
	header http.Header
}

// Get 读取指定 key 的请求头值
func (c *httpHeaderCarrier) Get(key string) string {
	return c.header.Get(key)
}

// Set 设置指定 key 的请求头值
func (c *httpHeaderCarrier) Set(key, value string) {
	c.header.Set(key, value)
}

// Keys 返回所有请求头的 key 列表
func (c *httpHeaderCarrier) Keys() []string {
	keys := make([]string, 0, len(c.header))
	for k := range c.header {
		keys = append(keys, k)
	}
	return keys
}

为什么要从请求头提取上游 span context? 因为在分布式系统中,一个请求可能经过多个服务。上游服务在发请求时会把 trace 信息放在 HTTP 头里(如 traceparent),下游服务提取后就能把上下游的链路连起来,形成完整调用链。

10.5 Prometheus Metrics(指标监控)

Prometheus 是最流行的开源监控指标系统,一般和 Grafana 配合使用。

Prometheus 四种指标类型

类型含义应用场景
Counter计数器,只增不减请求总次数、错误总次数
Gauge度量,可增可减当前正在处理的请求数
Histogram柱状图,分桶采样响应时间分布
Summary百分位统计99 线、999 线

什么是 99 线、999 线? 99% 的请求响应时间在多少以内。比如 99 线是 200ms,意味着 99% 的请求在 200ms 内完成。999 线同理,99.9% 的请求在多少以内。

import (
	"strconv" // strconv.Itoa 把状态码(int)转为字符串,用作 Prometheus 标签
	"time"    // time.Since 计算请求耗时

	"github.com/prometheus/client_golang/prometheus"   // prometheus 核心库:创建和注册指标
	"github.com/prometheus/client_golang/prometheus/promhttp" // promhttp.Handler 暴露 /metrics 端点
)

// prometheusBuilder 构建 Prometheus 监控中间件
// 采用 Builder 模式,允许用户自定义 namespace 和 subsystem(一般按公司规范设定)
//
// 什么是 namespace 和 subsystem?
//   namespace = "web"     → 指标前缀第一段,代表系统名
//   subsystem = "http"   → 指标前缀第二段,代表子系统名
//   最终指标名 = web_http_request_duration_ms(三段用下划线连接)
type prometheusBuilder struct {
	namespace  string // 指标命名空间,如 "web"
	subsystem  string // 指标子系统,如 "http"
	help       string // 指标帮助文本,在 /metrics 页面显示
}

// NewPrometheusBuilder 创建构建器
// 返回带有默认值的构建器,用户可以链式调用修改
//
// 使用示例:
//   builder := NewPrometheusBuilder()
//   builder.namespace = "myapp"  // 可选:修改命名空间
//   middleware, _ := builder.Build()
//   h.Use(middleware)
func NewPrometheusBuilder() *prometheusBuilder {
	return &prometheusBuilder{
		namespace: "web",               // 默认命名空间
		subsystem: "http",             // 默认子系统
		help:      "HTTP request metrics", // 默认帮助文本
	}
}

// Build 构建 Prometheus 指标向量
// 返回 Middleware 类型和可能的 error(注册失败时返回 error)
//
// 为什么用 Build 方法而不是直接返回 Middleware?
//   因为需要先创建和注册指标向量(histogram、counter),
//   然后才能在中间件闭包里引用它们。Build 把这两步合在一起。
func (b *prometheusBuilder) Build() (Middleware, error) {
	// 创建 Histogram 向量,记录响应时间(毫秒)
	//
	// 什么是 Histogram?
	//   Histogram 把数据分到一个个"桶"里,统计每个桶有多少个数据点
	//   比如桶 [1, 5, 10, 25, 50, 100, 250, 500, 1000](单位毫秒)
	//   一个耗时 30ms 的请求会被分到 50ms 这个桶里
	//   最终可以算出 99 线、999 线等百分位数据
	//
	// 什么是 Vector(向量)?
	//   普通 Histogram 只有一个指标,无法按维度区分
	//   HistogramVec 支持通过标签(labels)区分不同维度
	//   比如 route="GET /user/:id" 和 route="POST /login" 各自统计
	histogram := prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Namespace: b.namespace, // 命名空间前缀
			Subsystem: b.subsystem, // 子系统前缀
			Name:      "request_duration_ms", // 指标名
			Help:      "HTTP request duration in milliseconds", // 帮助文本
			// Buckets 定义分桶边界(单位:毫秒)
			// 一个耗时 30ms 的请求会被分到 ≤50ms 的桶里
			// 一个耗时 200ms 的请求会被分到 ≤250ms 的桶里
			Buckets: []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000},
		},
		// 标签列表:每个标签是一个维度,用于聚合统计
		// 比如:按路由+方法+状态码三个维度统计响应时间
		// route="GET /user/:id", method="GET", status="200"
		[]string{"route", "method", "status"},
	)

	// 创建 Counter 向量,记录请求次数
	//
	// 什么是 Counter?
	//   Counter 是只增不减的计数器,适合统计"累计总量"
	//   比如总请求数、总错误数
	//   不能用 Counter 统计"当前活跃请求数"(那个要用 Gauge)
	counter := prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Namespace: b.namespace,
			Subsystem: b.subsystem,
			Name:      "request_total", // 指标名:web_http_request_total
			Help:      "Total number of HTTP requests",
		},
		// 标签和 Histogram 一致,方便关联查询
		[]string{"route", "method", "status"},
	)

	// 必须注册到 Prometheus 默认注册器
	// 注册后,Prometheus 抓取 /metrics 时才能发现这些指标
	// MustRegister 如果注册失败(如重复注册)会 panic
	prometheus.MustRegister(histogram)
	prometheus.MustRegister(counter)

	// 返回中间件
	return func(next HandleFunc) HandleFunc {
		return func(ctx *Context) {
			// ===== 前置:记录开始时间 =====
			start := time.Now()

			// 执行业务逻辑(中间件、路由处理函数等)
			// next 返回后,ctx.RespStatusCode 和 ctx.MatchRoute 已被设置
			next(ctx)

			// ===== 后置:记录指标 =====

			// 计算请求耗时(毫秒)
			duration := time.Since(start).Milliseconds()

			// 获取命中的路由模式,作为标签
			// 用 MatchRoute 而非实际路径,因为实际路径可能含参数值
			// 例如 /user/123 和 /user/456 都归为 /user/:id 一个标签
			route := ctx.MatchRoute
			if route == "" {
				// 没匹配到路由(比如 404),用 "unknown" 兜底
				route = "unknown"
			}

			// 把状态码转为字符串,作为标签
			// Prometheus 的标签值必须是字符串
			status := strconv.Itoa(ctx.RespStatusCode)

			// 记录响应时间到 Histogram
			// WithLabelValues 按 route/method/status 三个维度获取具体的指标实例
			// Observe 把耗时值写入对应的桶
			histogram.WithLabelValues(route, ctx.Req.Method, status).
				Observe(float64(duration))

			// 请求计数 +1
			// Inc 让 Counter 加 1
			counter.WithLabelValues(route, ctx.Req.Method, status).Inc()
		}
	}, nil
}

如何暴露指标:

// 注册 /metrics 端点供 Prometheus 抓取
h.Get("/metrics", func(ctx *Context) {
	// 利用 promhttp.Handler() 返回指标数据
	handler := promhttp.Handler()
	handler.ServeHTTP(ctx.Resp, ctx.Req)
})

注意:/metrics 这个路由需要在所有中间件之外,否则也会被 AccessLog、Tracing 等中间件处理,造成不必要的开销。


十一、flashResp 与中间件的协作

11.1 完整的请求处理流程

把所有部分拼起来,一个请求的完整处理流程如下:

1. 请求到达
   ↓
2. ServeHTTP 构建 Context(用 responseWriter 包装 Resp)
   ↓
3. 查找路由 → 获取 MatchedRoute
   ↓
4. 依次包裹中间件(从后往前)
   Recovery → Logging → Auth → ... → 业务 handler
   ↓
5. 执行最外层中间件(洋葱从外到内)
   ↓
6. 到达业务 handler,写入 RespData 和 RespStatusCode
   ↓
7. 从内到外返回,各中间件执行后置逻辑
   (记录日志、记录 trace span、记录 metrics)
   ↓
8. flashResp:将缓存的 StatusCode 和 RespData 真正写入网络
   ↓
9. 响应返回给客户端

11.2 HTTPServer 完整实现

package web

import (
	"net/http"
)

// HTTPServer 基于 net/http 的服务器实现
type HTTPServer struct {
	addr        string         // 默认监听地址
	router                      // 路由树(嵌入)
	middlewares []Middleware    // 中间件列表,顺序决定执行顺序
}

// NewHTTPServer 创建服务器实例
// 必须用工厂方法创建,避免 router 为 nil 导致 panic
func NewHTTPServer(addr string) *HTTPServer {
	return &HTTPServer{
		addr:   addr,
		router: newRouter(),
	}
}

// Use 注册中间件
// 先注册的在洋葱最外层(最先执行)
func (h *HTTPServer) Use(m Middleware) {
	h.middlewares = append(h.middlewares, m)
}

// Start 启动服务器
// addr 为空时使用 NewHTTPServer 时传入的地址
func (h *HTTPServer) Start(addr string) error {
	if addr == "" {
		addr = h.addr
	}
	// h 实现了 http.Handler,可以直接传入
	return http.ListenAndServe(addr, h)
}

// ServeHTTP 是 http.Handler 接口实现
// 每个请求到达时,Go 标准库会调用这个方法
func (h *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 创建 Context,用 responseWriter 包装原始 writer
	// responseWriter 拦截 Write/WriteHeader,把数据先存到 Context
	ctx := &Context{
		Req:  r,
		Resp: &responseWriter{
			ResponseWriter: w,
			ctx:            nil, // 下面会设置
		},
	}
	// 设置 responseWriter 的 ctx 引用(循环引用:ctx 持有 Resp,Resp 又需要持有 ctx)
	ctx.Resp.(*responseWriter).ctx = ctx

	// 进入核心处理逻辑
	h.serve(ctx)
}

// serve 核心处理逻辑(小写,不对外暴露)
func (h *HTTPServer) serve(ctx *Context) {
	// Step 1: 查找路由
	mi, ok := h.router.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
	if !ok || mi.n == nil || mi.n.handler == nil {
		// 路由未命中,返回 404
		ctx.RespStatusCode = http.StatusNotFound
		ctx.RespData = []byte("404 NOT FOUND")
		h.flashResp(ctx)
		return
	}

	// Step 2: 注入路径参数和命中的路由模式
	ctx.PathParams = mi.pathParams
	ctx.MatchRoute = mi.n.path

	// Step 3: 构建中间件洋葱
	// 从后往前遍历,让先注册的中间件在最外层
	// 假设 middlewares = [A, B, C]
	// 遍历:C → B → A,最终 root = A(B(C(handler)))
	// 执行顺序:A → B → C → handler(从外到内)
	root := mi.n.handler
	for i := len(h.middlewares) - 1; i >= 0; i-- {
		root = h.middlewares[i](root)
	}

	// Step 4: 执行整个洋葱
	// 响应数据存在 ctx.RespData 和 ctx.RespStatusCode 里
	root(ctx)

	// Step 5: 统一刷新响应(把缓存的数据真正写入网络)
	h.flashResp(ctx)
}

// flashResp 将缓存的状态码和响应体真正写入网络
// 这是"先缓存后发送"机制的最后一环
func (h *HTTPServer) flashResp(ctx *Context) {
	// 如果用户没设状态码(RespStatusCode 为 0),默认 200
	if ctx.RespStatusCode == 0 {
		ctx.RespStatusCode = http.StatusOK
	}
	// 取出原始的 ResponseWriter(被 responseWriter 包装的那个)
	inner := ctx.Resp.(*responseWriter).ResponseWriter
	// 先写状态码(必须在 Write 之前调用)
	inner.WriteHeader(ctx.RespStatusCode)
	// 再写响应体
	if len(ctx.RespData) > 0 {
		_, _ = inner.Write(ctx.RespData)
	}
}

十二、Middleware 设计思考

12.1 要不要考虑执行时机?

Beego 的 Filter 支持在不同阶段运行(请求前、请求后等)。我们要不要也设计这种机制?

这个问题和其他问题不同——因为用户完全没办法自己支持,必须侵入式修改框架才能实现。

但大多数场景不需要。已有的 Middleware 设计(洋葱模式的前置+后置)完全能满足需求。我们可以推迟到用户真正需要的时候再评估。

12.2 Middleware 的顺序问题

理论上每个 Middleware 都应该不依赖其他 Middleware。但这只是美好的愿望。

Recovery(最外层)→ Logging → Tracing → Metrics → Auth → 业务逻辑

实际约束:

  • Recovery 必须在最外层:紧接着 flashResp 的位置,确保能捕获所有 panic
  • 错误处理应该在可观测性之后:先记录日志,再做错误重定向
  • 鉴权应该在靠前的位置:没权限就直接返回了,不需要执行后面的逻辑
  • 限流可以在鉴权前面也可以在后面:取决于业务——如果限流是防止未登录用户刷接口,放在鉴权前;如果是防止已登录用户过度调用,放在鉴权后

12.3 要不要支持分路由 Middleware?

目前所有 Middleware 对所有请求生效。但常见场景是:公开页面不需要登录,部分页面需要登录。

这是你们的课后作业——可以思考以下方向:

// 思路1:在路由注册时指定中间件
h.Get("/public", handler, LoggingMiddleware)
h.Get("/admin", handler, AuthMiddleware, LoggingMiddleware)

// 思路2:按路径前缀分组
h.Group("/admin", func(g *Group) {
	g.Use(AuthMiddleware)
	g.Get("/dashboard", handler)
	g.Get("/users", handler)
})

// 思路3:在 Middleware 内部判断路径
func SelectiveAuth(next HandleFunc) HandleFunc {
	return func(ctx *Context) {
		if strings.HasPrefix(ctx.Req.URL.Path, "/admin") {
			// 需要鉴权
		}
		next(ctx)
	}
}

十三、面试要点

13.1 Context 相关

  • 能不能重复读取 HTTP 协议的 Body 内容? 原生 API 不可以,因为 Body 是 Stream 设计,只能读一次。但可以通过封装来允许重复读取:第一次读取后将内容缓存,后续都从缓存读取。

  • 能不能修改 HTTP 协议的响应? 原生 API 不可以,因为 WriteHeader 和 Write 会直接把数据发到网络。但可以用 RespData 这种缓存机制,在最后 flashResp 时才把数据刷新到网络,刷新之前都可以修改。

  • Form 和 PostForm 的区别? Form 包含 URL 查询参数 + Body 表单数据;PostForm 仅包含 Body 中的表单数据,且要求 Content-Type 为 application/x-www-form-urlencoded。正常使用优先用 Form 不会出错。

  • Web 框架是怎么支持路径参数的? 框架在发现匹配上了某个路径参数后,将这段路径记录下来作为路径参数的值,值默认是 string 类型,用户需要时自行转换。

  • Context 是线程安全的吗? 不是,但不需要。Context 预期只在单个请求处理中使用,不应该被多个 goroutine 操作。如果需要,可以提供装饰器模式。

  • Context 为什么不用泛型? Go 泛型限制:结构体可以是泛型的,但不能声明泛型方法。所以 func (c *Context[T]) BindJSON(val T) 这样的写法会编译错误。

13.2 AOP 相关

  • 什么是 AOP? 面向切面编程,用于解决横向关注点问题(可观测性、安全、错误处理等)。

  • 什么是洋葱模式? 形如洋葱,有一个核心(业务逻辑),外面层层包裹,每一层就是一个 Middleware。用洋葱模式无侵入式地增强核心功能。

  • 什么是责任链模式? 不同的 Handler 组成一条链,链条上每一环有自己的功能。可以灵活地在链条上添加新的 Handler。

  • Middleware 怎么实现? 最简单的方案是函数式方案:type Middleware func(next HandleFunc) HandleFunc。实现者决定何时调用 next,不调用就中断链条。

13.3 可观测性相关

  • 什么是可观测性? logging(日志)、metrics(指标)和 tracing(链路追踪)。

  • 常用框架有哪些? OpenTelemetry、SkyWalking、Prometheus、Zipkin、Jaeger。

  • 怎么集成可观测性框架? 利用 Middleware 机制,几乎所有的框架都有类似 Middleware 的机制。

  • Prometheus 的 Histogram 和 Summary? Histogram 是分桶采样; Summary 是百分位统计(99 线、999 线)。

  • 全链路追踪的概念? tracer 是记录 trace 的实例;trace 是一次完整的请求链路;span 是 trace 中的一段,有父子关系,构成多叉树。

  • 什么是 99 线、999 线? 99% 的请求响应时间在多少以内;99.9% 的请求响应时间在多少以内。


十四、完整代码汇总

以下是本章涉及的所有核心代码,按文件组织:

context.go

package web

import (
	"bytes"       // bytes.NewReader 用于恢复 Body 可读性
	"encoding/json" // json.Marshal/Unmarshal 用于 JSON 序列化反序列化
	"io"           // io.ReadAll 读取 Body,io.NopCloser 恢复 Body
	"net/http"     // http.Request, http.ResponseWriter 标准库的 HTTP 类型
	"net/url"      // url.Values 存储解析后的查询参数
)

// HandleFunc 业务处理函数类型
// 每个路由注册的处理函数都是这个类型,接收 Context 作为参数
// 比如 h.Get("/user/:id", func(ctx *Context) { ... }) 里的函数就是 HandleFunc
type HandleFunc func(ctx *Context)

// Context 代表请求上下文
// 每一个 HTTP 请求到达时,框架会创建一个 Context 实例
// Context 贯穿整个请求处理流程:从输入解析到响应输出
// 注意:Context 不是线程安全的,预期只在单个请求处理中使用,不要跨 goroutine 共享
type Context struct {
	// ========== 原始请求和响应 ==========

	// Req 原始的 HTTP 请求对象(来自 Go 标准库 http.Request)
	// 包含 URL、Method、Header、Body 等所有请求数据
	Req *http.Request
	// Resp 响应写入器,被 responseWriter 包装
	// 注意:这里类型是 http.ResponseWriter,但实际运行时是 *responseWriter
	// responseWriter 拦截了 Write/WriteHeader,把数据先存到 Context 里而非直接发网络
	Resp http.ResponseWriter

	// ========== 路由相关 ==========

	// PathParams 路径参数,由路由树匹配后注入
	// 例如路由模式 /user/:id,请求 /user/123,则 PathParams = {"id": "123"}
	// 值始终是 string 类型,用户需要 int 等类型时自行用 strconv 转换
	PathParams map[string]string
	// MatchRoute 命中的路由模式,用于日志和监控
	// 例如请求 /user/123 命中了路由 /user/:id,则 MatchRoute = "/user/:id"
	// 记录路由模式而非实际路径,是因为实际路径可能很长很复杂,不利于聚合统计
	MatchRoute string

	// ========== 输入缓存(私有字段,外部不可访问)==========

	// queryValues 缓存解析后的查询参数
	// 第一次调用 Query() 时才解析,后续直接从缓存取,避免重复解析
	// 为什么缓存安全?因为请求收到后内容不会变
	queryValues url.Values
	// bodyCache 缓存已读取的 Body 内容
	// 原生 r.Body 是 Stream,只能读一次,读完就空了
	// 缓存后可以多次读取,解决中间件和业务函数都要读 Body 的问题
	bodyCache []byte
	// hasReadBody 标记是否已经读取过 Body
	// true 表示 bodyCache 里已经有数据,直接返回缓存即可
	hasReadBody bool

	// ========== 输出缓存 ==========

	// RespData 缓存响应体数据
	// 业务函数调用 ctx.JSON() 时,数据先存到这里,不直接写网络
	// 直到 flashResp 被调用时,才真正写到网络
	// 这样中间件在 flashResp 之前可以读取或修改响应体
	RespData []byte
	// RespStatusCode 缓存 HTTP 状态码
	// 业务函数调用 ctx.JSON(200, ...) 时,200 存到这里
	// flashResp 时才真正调用 WriteHeader 写到网络
	RespStatusCode int

	// ========== 扩展数据 ==========

	// UserValues 用户在中间件和业务逻辑之间传递自定义数据
	// 类似 Gin 的 Keys map
	// 例如认证中间件可以把当前用户信息存进去,业务函数再取出来
	UserValues map[string]any
}

// ==================== 输入相关方法 ====================

// readBody 读取 Body 内容并缓存
// 这是解决 "Body 只能读一次" 问题的核心方法
//
// 工作原理:
// 第一次调用 → 从 r.Body 读取全部内容 → 存到 bodyCache → 用 bytes.Reader 重建一个可读的 Body
// 后续调用 → 直接返回 bodyCache,不再读取网络
func (c *Context) readBody() ([]byte, error) {
	// 如果已经读取过,直接返回缓存
	if c.hasReadBody {
		return c.bodyCache, nil
	}
	// 第一次读取:从网络流中读取全部 Body 内容
	body, err := io.ReadAll(c.Req.Body)
	if err != nil {
		return nil, err
	}
	// 关闭原始 Body(已经读完数据,不再需要)
	_ = c.Req.Body.Close()
	// 缓存内容,供后续读取使用
	c.bodyCache = body
	c.hasReadBody = true
	// 用 bytes.NewReader 创建一个新的可读流,赋值给 r.Body
	// 这样标准库的方法(如 r.FormValue)也能正常读取
	// io.NopCloser 把 Reader 包装成 ReadCloser(Body 的类型是 io.ReadCloser)
	c.Req.Body = io.NopCloser(bytes.NewReader(body))
	return body, nil
}

// BindJSON 将 Body 中的 JSON 反序列化到 val 指向的结构体
//
// 使用示例:
//   var user struct { Name string `json:"name"` }
//   err := ctx.BindJSON(&user) // 注意传指针,这样才能填充结构体
//
// 内部调用 readBody(),所以支持多次调用(第二次返回缓存的 Body)
func (c *Context) BindJSON(val interface{}) error {
	// 如果传 nil,说明用户不需要绑定,直接返回
	if val == nil {
		return nil
	}
	// 读取 Body(带缓存,支持多次读取)
	body, err := c.readBody()
	if err != nil {
		return err
	}
	// 将 JSON 字节流反序列化为 Go 结构体
	// val 必须是指针,否则无法填充数据
	return json.Unmarshal(body, val)
}

// Query 获取查询参数的值(带缓存)
//
// 使用示例:
//   // 请求:GET /search?name=xiaoming&age=18
//   name := ctx.Query("name") // 返回 "xiaoming"
//   age := ctx.Query("age")   // 返回 "18"(string 类型,需要自己转 int)
//
// 第一次调用时解析 URL 的 query string,后续从缓存取
func (c *Context) Query(key string) string {
	// 延迟初始化:只有第一次调用时才解析
	if c.queryValues == nil {
		c.queryValues = c.Req.URL.Query()
	}
	return c.queryValues.Get(key)
}

// QueryAll 获取查询参数的所有值(一个 key 可能对应多个值)
//
// 使用示例:
//   // 请求:GET /search?tag=go&tag=web&tag=framework
//   tags := ctx.QueryAll("tag") // 返回 ["go", "web", "framework"]
func (c *Context) QueryAll(key string) []string {
	if c.queryValues == nil {
		c.queryValues = c.Req.URL.Query()
	}
	return c.queryValues[key]
}

// PostForm 获取表单参数
// 内部调用标准库的 r.FormValue,会自动调用 ParseForm
//
// 使用示例:
//   // 表单提交:name=xiaoming&age=18
//   name := ctx.PostForm("name") // 返回 "xiaoming"
func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}

// PathParamValue 获取路径参数的字符串值
//
// 使用示例:
//   // 路由:GET /user/:id
//   // 请求:GET /user/123
//   id := ctx.PathParamValue("id") // 返回 "123"
//
// 返回 string,用户需要 int 时自行用 strconv.ParseInt 转换
func (c *Context) PathParamValue(key string) string {
	return c.PathParams[key]
}

// Header 获取请求头的值
// Go 标准库会自动规范化 Header 名的大小写,所以 "Content-Type" 和 "content-type" 等效
//
// 使用示例:
//   token := ctx.Header("Authorization") // 获取认证 token
func (c *Context) Header(key string) string {
	return c.Req.Header.Get(key)
}

// ==================== 输出相关方法 ====================

// JSON 返回 JSON 格式的响应
//
// 注意:这里不直接写网络,而是把数据存到 RespData 和 RespStatusCode
// 真正写网络是在 flashResp 里完成的
//
// 使用示例:
//   _ = ctx.JSON(200, map[string]string{"message": "hello"})
func (c *Context) JSON(status int, data interface{}) error {
	// 缓存状态码(不直接写网络)
	c.RespStatusCode = status
	// 将 data 序列化为 JSON 字节流
	bytes, err := json.Marshal(data)
	if err != nil {
		return err
	}
	// 缓存响应体(不直接写网络)
	c.RespData = bytes
	// 设置 Content-Type 告诉客户端返回的是 JSON
	c.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
	return nil
}

// JSONOK 返回 200 状态码的 JSON 响应
// 是 ctx.JSON(http.StatusOK, data) 的便捷写法
//
// 使用示例:
//   _ = ctx.JSONOK(map[string]string{"token": "abc123"})
func (c *Context) JSONOK(data interface{}) error {
	return c.JSON(http.StatusOK, data)
}

// String 返回纯文本格式的响应
//
// 使用示例:
//   ctx.String(200, "Hello, World!")
func (c *Context) String(status int, msg string) {
	// 缓存状态码
	c.RespStatusCode = status
	// 缓存响应体(string 转 []byte)
	c.RespData = []byte(msg)
	// 设置 Content-Type 为纯文本
	c.Resp.Header().Set("Content-Type", "text/plain; charset=utf-8")
}

middleware.go

package web

// Middleware 中间件类型
// 接收下一个 HandleFunc,返回一个包装后的 HandleFunc
//
// 本质是函数装饰器:在 next 的前后插入额外逻辑
//
// 使用方式:
//   func MyMiddleware(next HandleFunc) HandleFunc {
//       return func(ctx *Context) {
//           // ===== next 之前:前置逻辑 =====
//           fmt.Println("请求开始")
//
//           next(ctx) // 调用下一个处理函数
//
//           // ===== next 之后:后置逻辑 =====
//           fmt.Println("请求结束,状态码:", ctx.RespStatusCode)
//       }
//   }
type Middleware func(next HandleFunc) HandleFunc

// responseWriter 包装 http.ResponseWriter
//
// 作用:拦截业务函数的 Write/WriteHeader 调用
// 把数据先存到 Context 里,不直接发到网络
// 等 flashResp 时才真正写入网络
//
// 为什么需要这样做?
//   因为原生 http.ResponseWriter 的 Write/WriteHeader 会立即把数据发到网络
//   一旦发出,中间件就无法读取状态码和响应体了
//   通过拦截,中间件可以在 flashResp 之前读取和修改响应数据
type responseWriter struct {
	// 嵌入原始的 http.ResponseWriter
	// 未被重写的方法(如 Header())会直接调用原始 writer
	http.ResponseWriter
	// ctx 反向引用 Context,用于把拦截到的数据存进去
	ctx *Context
}

// Write 拦截响应体的写入
// 当业务函数调用 ctx.Resp.Write(data) 时,实际执行的是这个方法
// 数据不写网络,而是存到 ctx.RespData
func (w *responseWriter) Write(data []byte) (int, error) {
	// 把响应体数据存到 Context 里,不直接写到网络
	w.ctx.RespData = data
	// 返回写入的字节数(假装写入成功,实际上只是存到了内存)
	return len(data), nil
}

// WriteHeader 拦截状态码的写入
// 当业务函数调用 ctx.Resp.WriteHeader(200) 时,实际执行的是这个方法
// 状态码不写网络,而是存到 ctx.RespStatusCode
func (w *responseWriter) WriteHeader(statusCode int) {
	// 把状态码存到 Context 里,不直接写到网络
	w.ctx.RespStatusCode = statusCode
}

server.go(更新版)

package web

import "net/http" // 只用到了 http.ListenAndServe 和 http 常量

// HTTPServer 是 Server 接口基于 net/http 的实现
// 它是整个 Web 框架的核心:
//   1. 持有路由树(router)
//   2. 持有中间件列表(middlewares)
//   3. 实现 http.Handler 接口,与 Go 标准库的 http 包协作
type HTTPServer struct {
	// addr 服务器默认监听地址(可选,Start 时可覆盖)
	addr string
	// router 路由树,从上一章继承来的路由匹配引擎
	router
	// middlewares 中间件列表
	// 顺序很重要:切片前面的在洋葱最外层(最先执行),后面的在洋葱最内层
	// 例如 [Recovery, Logging, Auth] → Recovery 在最外层,Auth 在最内层
	middlewares []Middleware
}

// NewHTTPServer 创建 HTTPServer 实例
// 必须通过这个工厂方法创建,避免用户直接 &HTTPServer{} 导致 router 为 nil 而 panic
//
// 参数 addr 是默认监听地址,如 ":8081"
func NewHTTPServer(addr string) *HTTPServer {
	return &HTTPServer{
		addr:   addr,
		router: newRouter(), // 创建路由树
	}
}

// Use 注册中间件
// 每调用一次 Use 就往 middlewares 切片追加一个中间件
// 注意:注册顺序决定执行顺序——先注册的在最外层
//
// 使用示例:
//   h.Use(Recovery)  // 最外层
//   h.Use(Logging)    // 中间层
//   h.Use(Auth)       // 最内层(紧贴业务逻辑)
func (h *HTTPServer) Use(m Middleware) {
	h.middlewares = append(h.middlewares, m)
}

// Start 启动 HTTP 服务器
// addr 为空时使用 NewHTTPServer 时传入的地址
//
// 内部调用标准库的 http.ListenAndServe
// h 本身实现了 http.Handler(因为有 ServeHTTP 方法),所以可以作为参数传入
func (h *HTTPServer) Start(addr string) error {
	if addr == "" {
		addr = h.addr
	}
	// http.ListenAndServe 会阻塞当前 goroutine,直到服务器关闭
	return http.ListenAndServe(addr, h)
}

// ServeHTTP 是 http.Handler 接口的方法实现
// 这是 Go 标准库 http 包与我们 Web 框架的连接点
// 每当有 HTTP 请求到达时,Go 标准库会调用这个方法
//
// 流程:构建 Context → 包装 ResponseWriter → 调用 serve
func (h *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// 创建 Context,把原始请求存进去
	ctx := &Context{Req: r}
	// 用 responseWriter 包装原始 writer,拦截后续的 Write/WriteHeader
	// 注意:responseWriter 需要引用 ctx(把拦截的数据存到 ctx 里)
	ctx.Resp = &responseWriter{ResponseWriter: w, ctx: ctx}
	// 进入核心处理逻辑
	h.serve(ctx)
}

// serve 是核心处理逻辑(小写,不对外暴露)
//
// 完整流程:
//   1. 查找路由树,找到匹配的处理函数
//   2. 注入路径参数和命中的路由模式
//   3. 用中间件层层包裹业务处理函数(构建洋葱)
//   4. 执行洋葱(从最外层中间件开始,一层层进入业务逻辑)
//   5. flashResp 把缓存的响应数据真正写入网络
func (h *HTTPServer) serve(ctx *Context) {
	// Step 1: 在路由树中查找匹配的处理函数
	// mi = matchInfo,包含命中的节点和路径参数
	mi, ok := h.router.findRoute(ctx.Req.Method, ctx.Req.URL.Path)
	if !ok || mi.n == nil || mi.n.handler == nil {
		// 路由未命中:设置 404 状态码和错误消息
		ctx.RespStatusCode = http.StatusNotFound
		ctx.RespData = []byte("404 NOT FOUND")
		// 直接刷新响应(不经过中间件,因为没匹配到路由)
		h.flashResp(ctx)
		return
	}

	// Step 2: 把路径参数注入到 Context
	// 例如 /user/:id 匹配 /user/123,则 PathParams = {"id": "123"}
	ctx.PathParams = mi.pathParams
	// 记录命中的路由模式,供中间件使用(日志、监控等)
	ctx.MatchRoute = mi.n.path

	// Step 3: 构建中间件洋葱
	// 从后往前遍历 middlewares,让切片前面的中间件包裹在更外层
	//
	// 假设 middlewares = [A, B, C],业务函数 = handler
	// 遍历顺序:C → B → A
	// 第一轮:root = C(handler)      → 洋葱内层是 C 包着 handler
	// 第二轮:root = B(C(handler))   → 中间是 B 包着 C 包着 handler
	// 第三轮:root = A(B(C(handler))) → 最外层是 A
	// 执行 root 时,调用顺序是 A → B → C → handler(从外到内)
	root := mi.n.handler
	for i := len(h.middlewares) - 1; i >= 0; i-- {
		root = h.middlewares[i](root)
	}

	// Step 4: 执行整个洋葱
	// 从最外层中间件开始执行,一层层进入,最终到达业务逻辑
	// 执行完毕后,响应数据已经存在 ctx.RespData 和 ctx.RespStatusCode 里
	root(ctx)

	// Step 5: 把缓存的响应数据真正写入网络
	// 这一步是整个机制的关键:中间件在 root(ctx) 返回后、flashResp 之前
	// 可以读取 ctx.RespStatusCode 和 ctx.RespData,做日志记录、错误处理等
	h.flashResp(ctx)
}

// flashResp 将缓存的响应数据真正写入网络(小写,不对外暴露)
//
// 这是 "先缓存后发送" 机制的最后一环:
//   1. 业务函数调用 ctx.JSON(200, data) → 数据存到 ctx.RespData,状态码存到 ctx.RespStatusCode
//   2. 中间件可以读取/修改这些数据
//   3. flashResp 把最终的数据写入真正的网络
//
// flashResp 是 "flash"(刷新)+ "Resp"(响应)的缩写
func (h *HTTPServer) flashResp(ctx *Context) {
	// 如果用户没有设置状态码(RespStatusCode 为 0),默认 200
	if ctx.RespStatusCode == 0 {
		ctx.RespStatusCode = http.StatusOK
	}
	// 取出原始的 ResponseWriter(被 responseWriter 包装的那个)
	// 我们需要直接操作原始 writer,把数据真正写到网络
	inner := ctx.Resp.(*responseWriter).ResponseWriter
	// 先写状态码(必须在 Write 之前调用 WriteHeader)
	inner.WriteHeader(ctx.RespStatusCode)
	// 再写响应体
	if len(ctx.RespData) > 0 {
		_, _ = inner.Write(ctx.RespData)
	}
}

使用示例

package main

import (
	"fmt"
	"net/http"
	"runtime/debug"
	"time"

	"yourpkg/web"
)

func main() {
	h := web.NewHTTPServer(":8081")

	// 注册中间件(顺序很重要!外层在前)
	h.Use(Recovery)       // 最外层:捕获 panic
	h.Use(AccessLog)      // 记录访问日志

	// 注册路由
	h.Get("/user/:id", func(ctx *web.Context) {
		_ = ctx.JSONOK(map[string]string{
			"id":   ctx.PathParamValue("id"),
			"name": "xiaoming",
		})
	})

	h.Post("/login", func(ctx *web.Context) {
		var req struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}
		if err := ctx.BindJSON(&req); err != nil {
			_ = ctx.JSON(400, map[string]string{"error": "参数错误"})
			return
		}
		_ = ctx.JSONOK(map[string]string{"token": "xxx"})
	})

	fmt.Println("Server started on :8081")
	_ = h.Start(":8081")
}

// Recovery panic 恢复中间件
func Recovery(next web.HandleFunc) web.HandleFunc {
	return func(ctx *web.Context) {
		defer func() {
			if err := recover(); err != nil {
				fmt.Printf("panic recovered: %v\n%s\n", err, debug.Stack())
				ctx.RespStatusCode = http.StatusInternalServerError
				ctx.RespData = []byte(`{"error":"Internal Server Error"}`)
			}
		}()
		next(ctx)
	}
}

// AccessLog 访问日志中间件
func AccessLog(next web.HandleFunc) web.HandleFunc {
	return func(ctx *web.Context) {
		start := time.Now()
		defer func() {
			fmt.Printf("[%s] %s %s -> %d (%v)\n",
				start.Format("2006-01-02 15:04:05"),
				ctx.Req.Method,
				ctx.Req.URL.Path,
				ctx.RespStatusCode,
				time.Since(start),
			)
		}()
		next(ctx)
	}
}

十五、总结

本章覆盖了 Web 框架中两个核心模块的设计:

Context 模块负责封装请求输入和响应输出:

  • 通过 bodyCache 解决 Body 只能读一次的问题
  • 通过 queryValues 缓存查询参数避免重复解析
  • 通过 RespData + RespStatusCode 缓存响应数据,使中间件能在响应发出前读取和修改
  • 坚持"用户能自己解决的需求不进框架核心"的设计理念

AOP 方案通过 Middleware 机制解决横向关注点:

  • 洋葱模式:中间件层层包裹业务逻辑,支持前置和后置处理
  • 责任链模式:每个中间件决定是否调用 next
  • flashResp 机制是关键:让响应数据先缓存后发送,中间件才能读取运行结果
  • 五大实战中间件:Recovery、AccessLog、错误处理、Tracing、Prometheus

下一章我们将讨论路由树的高级特性:通配符匹配、参数路由以及正则路由的支持。

自测题与动手练习

面试官
原生 http.ResponseWriter 为什么让中间件无法在响应发出后读取状态码和响应体?框架用 responseWriter 包装解决了什么问题?
候选人
原生 http.ResponseWriter 一旦 Write 就直接发往连接,中间件拿不到"已写的状态码 / body",也无法拦截。
框架用 responseWriter(实现 http.ResponseWriter)接管 Write / WriteHeader,把状态码和 body 先存进 RespData,发送前中间件可读可改,最后由 flashResp 真正写出。
面试官
Body 为什么只能读一次?readBody 的缓存机制如何用 io.NopCloser(bytes.NewReader(body)) 让标准库方法也能再次读取?
候选人
请求 Body 是 io.ReadCloser,底层流读完即被消费,第二次读是空(标准库 BindJSON 读一次后就没了)。
框架在 Context 里 readBody 先全读进 []byte 缓存,再用 io.NopCloser(bytes.NewReader(body)) 重建一个"可重复读"的 ReadCloser 塞回 c.Req.Body,使后续 BindJSON 能再次读取同一份字节。
面试官
「洋葱模式」和「责任链模式」分别指 Middleware 的什么特征?什么情况下一个中间件会「中断链条」?
候选人
洋葱模型:请求从外到内进、响应从内到外出,每个中间件有"前置(进)+ 后置(出)“两段逻辑。
责任链:中间件串成链依次处理。
当中间件不调用 next(如鉴权失败直接写 401 返回),链条在此中断,后续中间件与 handler 都不执行。
面试官
为什么 Recovery 中间件必须第一个注册(最外层)?如果把 ErrorHandler 放在最内层会怎样?
候选人
Recovery 用 defer recover() 兜住整条链(含所有下游中间件与 handler)的 panic,所以必须最外层才能覆盖全部。
若 ErrorHandler 放最内层,它只能兜住自己之后的 handler,外层中间件一旦 panic 就逃逸出它的 recover 范围,进程崩溃或 500 前已失控;且它负责把 error 转成响应,放最内层也来不及统一处理外层错误。
面试官
ctx.JSON 在我们的实现里其实没真正写网络,它把数据放到了哪里?flashResp 在什么时候才真正写入?
候选人
ctx.JSON 只是把数据(状态码 + body)暂存进 RespData(RespStatusCode / RespData),不触网。
真正写入发生在所有中间件与 handler 执行完毕、回到最外层之后,flashResp 被调用时才把 RespData 一次性写向 http.ResponseWriter——这就是"先缓存后发送",中间件得以在发送前读写结果。

动手练习(建议真做一遍):

  1. 实现 AuthMiddleware:从 Authorization 头取 token,为空直接返回 401 且不调用 next,验证它确实阻断了后续逻辑。
  2. 基于文中 tracingBuilder,为某个接口开启 OpenTelemetry,观察 span 的父子关系与 MatchRoute 标签。
  3. 用 prometheusBuilder 接入 /metrics,用 curl 打几次请求后查看 Histogram 分桶与 99 线数据。

本章小结

复习提示:
  • Context 封装请求的输入与输出:用 bodyCache / queryValues 缓存解决「Body 只能读一次」「Query 重复解析」问题。
  • RespData + RespStatusCode + flashResp 是 AOP 的基石:响应先缓存、最后统一发送,中间件才能在发送前读写结果。
  • AOP 用 Middleware(函数式装饰器)剥离横向关注点;洋葱模型支持前置 / 后置,不调用 next 即中断。
  • 五大实战中间件(Recovery / AccessLog / ErrorHandler / Tracing / Prometheus)都建立在「先缓存后发送」之上,注册顺序决定执行顺序。
  • 框架坚持「用户能自己解决的需求不进核心」「不过度设计为接口」的克制设计。

下一章深入路由树高级特性:通配符、参数路由与正则路由,把「找到函数」这一步做得更强大。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!