学习目标
学完本章,你应该能够:
- 讲清一个 HTTP 请求的数据从哪来(Body / Query / Header / Form / Path)以及如何被 Context 封装、缓存、重复读取。
- 对照 Beego / Gin / Echo / Iris 的 Context 设计,解释我们「用结构体而非接口」「不为小众需求进核心」的设计取舍。
- 说清
RespData+flashResp机制为什么是 AOP 的基石:它让响应先缓存后发送,中间件才能读取运行结果。 - 用洋葱模型讲透 Middleware(
type Middleware func(next HandleFunc) HandleFunc):前置 / 后置逻辑、调用next、不调用即中断。 - 自己实现并注册五大经典中间件(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。
一、本章导读
在上一章中,我们实现了 Server 抽象和路由树,搭建了 Web 框架的骨架。当你注册了一条路由 GET /user/:id 后,框架已经能找到对应的处理函数了。但找到函数之后呢?
业务函数需要从请求中读取数据(比如用户的 id),处理之后再把结果写回给浏览器。这个"读请求"和"写响应"的过程,就是 Context 要解决的问题。
而当你有多个业务函数都需要做登录校验、记录日志、捕获 panic……这些跟业务无关但又每个接口都需要的逻辑,就轮到 AOP(面向切面编程) 出场了。
本章分两大板块:
- Context 模块——封装请求输入和响应输出
- 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 |
| Query | URL 问号后(id=123) | r.URL.Query() |
| Header | 请求头(X-My-Company-Token) | r.Header.Get(key) |
| Form | 表单编码的 Body | r.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.Form | URL 查询参数 + 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 内部维护了多个字段:
- 缓存数据(
Keysmap):避免重复读取和解析的开销 - 控制 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 等基础方法。现在需要进一步完善:
- 反序列化 Body:将 Body 字节流转换为具体类型(如 JSON → struct)
- 处理表单输入:表单可以看作一种特殊的序列化格式
- 处理查询参数:从 URL 中读取并转换类型
- 处理路径参数:从路由匹配结果中读取
- 重复读取 Body:解决 Body 只能读一次的问题
- 读取 Header:从 Header 中读取特定值
- 模糊读取:按一定顺序从多个来源尝试获取值
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 方案的基础。因为有了它,中间件才能在响应发出去之前拿到状态码和响应体,从而实现记录日志、错误页面重定向等功能。
5.3 设置 Cookie 和 Header
// 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 早期设计了三类回调:
Middleware:本质是
http.Handler的包装。缺陷是它脱离了 Beego 的控制——用户的 Handler 无法利用 Beego 内部的数据(如路由匹配结果)。Filter:允许注册不同时机运行的过滤器,但都是单向的(只能前置或后置),不是环绕式的。
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 Middleware | http.Handler 包装 | 是 | 否 |
| Beego Filter | 时机注册 | 否 | 是 |
| Beego FilterChain | 责任链 | 是 | 是 |
| Gin | Context.Next() | 是 | 是 |
| Echo | next() 调用 | 是 | 是 |
| Iris | next() 调用 | 是 | 是 |
九、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里输出日志?
- 确保即便
next里发生了 panic,也能将请求记录下来- 获得
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 | 一次完整的请求链路 |
| span | trace 中的一段。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包装解决了什么问题? - Body 为什么只能读一次?
readBody的缓存机制如何用io.NopCloser(bytes.NewReader(body))让标准库方法也能再次读取? - 「洋葱模式」和「责任链模式」分别指 Middleware 的什么特征?什么情况下一个中间件会「中断链条」?
- 为什么
Recovery中间件必须第一个注册(最外层)?如果把ErrorHandler放在最内层会怎样? ctx.JSON在我们的实现里其实没真正写网络,它把数据放到了哪里?flashResp在什么时候才真正写入?
动手练习(建议真做一遍):
- 实现
AuthMiddleware:从Authorization头取 token,为空直接返回 401 且不调用next,验证它确实阻断了后续逻辑。 - 基于文中
tracingBuilder,为某个接口开启 OpenTelemetry,观察 span 的父子关系与MatchRoute标签。 - 用
prometheusBuilder接入/metrics,用curl打几次请求后查看 Histogram 分桶与 99 线数据。
本章小结
- Context 封装请求的输入与输出:用
bodyCache/queryValues缓存解决「Body 只能读一次」「Query 重复解析」问题。 RespData+RespStatusCode+flashResp是 AOP 的基石:响应先缓存、最后统一发送,中间件才能在发送前读写结果。- AOP 用 Middleware(函数式装饰器)剥离横向关注点;洋葱模型支持前置 / 后置,不调用
next即中断。 - 五大实战中间件(Recovery / AccessLog / ErrorHandler / Tracing / Prometheus)都建立在「先缓存后发送」之上,注册顺序决定执行顺序。
- 框架坚持「用户能自己解决的需求不进核心」「不过度设计为接口」的克制设计。
下一章深入路由树高级特性:通配符、参数路由与正则路由,把「找到函数」这一步做得更强大。