二、Context和AOP方案

2021-02-12T14:21:02+08:00 | 44分钟阅读 | 更新于 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.Requesthttp.ResponseWriter、Handler 模型
  • 了解 io.Reader / io.Writer 与「流只能读一次」的直觉
  • 基本的可观测性概念(日志、指标、链路追踪,后文会展开)

本章你会动手做的事

  • Context 加上 readBody 缓存,验证中间件读了 Body 后业务函数仍能 BindJSON。
  • 写一个 LoggingMiddleware,用 defer 在响应发出前打印状态码与耗时。
  • 注册 Recovery 为最外层中间件,故意在 handler 里 panic,观察它如何兜住并返回 500。

一、本章导读

在上一章中,我们实现了 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=123r.URL.Query()
Header请求头(X-My-Company-Tokenr.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

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

3.2 Gin 的 Context 设计

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

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

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

3.3 Echo 的 Context 设计

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

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

3.4 Iris 的 Context 设计

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

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

3.5 对比总结

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

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


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

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

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

  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 包在反序列化时有两个常用选项:

选项作用
UseNumbertrue 时,JSON 中的数字不会被解析为 float64,而是保留为 json.Number(字符串形式),避免大整数精度丢失
DisallowUnknownFieldstrue 时,如果 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,要不要提供 AsInt32AsInt16AsInt8?全部基础类型都来一个?

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 一旦调用 WriteHeaderWrite,数据就直接发到网络上了,取不回来。

解决方案:自己存一份

类比:就像写邮件时先写在「草稿箱」里,而不是一发就飞走。所有中间件都还能翻看、修改草稿,等你点「发送」(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 不可以,因为 WriteHeaderWrite 会直接把数据发到网络。但可以用 RespData 这种缓存机制,在最后 flashResp 时才把数据刷新到网络,刷新之前都可以修改。

  • Form 和 PostForm 的区别? Form 包含 URL 查询参数 + Body 表单数据;PostForm 仅包含 Body 中的表单数据,且要求 Content-Typeapplication/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

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

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 原生 http.ResponseWriter 为什么让中间件无法在响应发出后读取状态码和响应体?框架用 responseWriter 包装解决了什么问题?
  2. Body 为什么只能读一次?readBody 的缓存机制如何用 io.NopCloser(bytes.NewReader(body)) 让标准库方法也能再次读取?
  3. 「洋葱模式」和「责任链模式」分别指 Middleware 的什么特征?什么情况下一个中间件会「中断链条」?
  4. 为什么 Recovery 中间件必须第一个注册(最外层)?如果把 ErrorHandler 放在最内层会怎样?
  5. ctx.JSON 在我们的实现里其实没真正写网络,它把数据放到了哪里?flashResp 在什么时候才真正写入?

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

  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,加油!加油!