Go 博客教程 · 第 5 课 / 10
第 5 课:中间件与统一错误处理
前四课打通了「请求 → SQL → JSON」的主链路,但现在的代码有四个问题:每个 handler 各写各的日志;任何一处 nil 指针会让整个进程退出;线上拿到用户反馈没法和日志对上;错误响应格式各不相同。
这四件事的共同点是:不属于任何一个具体 handler,但每个 handler 都需要。这就是中间件。
① 本课目标
写出 Middleware 类型 + Chain 组合函数,以及 RequestID / Logger / Recover / CORS / MaxBody / Timeout 六个中间件;写出包装 http.ResponseWriter 的 statusRecorder;建立统一错误响应机制,保证给客户端和给日志的消息是两份。
并能回答:为什么中间件签名是 func(http.Handler) http.Handler?Chain(h, A, B, C) 谁先执行?为什么 context key 不能用 string?为什么 r = r.WithContext(ctx) 的 r = 不能省?为什么一个请求 panic 会搞挂整个进程?
② 前置检查
cd ~/go-blog && go build ./... && go run ./cmd/server
# 另一个终端
curl -s localhost:8080/api/posts | head -c 200; echo
看到文章 JSON 才继续。本课新建:
internal/middleware/{middleware,context,requestid,logger,recover,cors}.go
internal/handler/response.go # 第 2 课已有,本课追加 writeErrorFrom
本课不碰数据库。
③ 核心概念
3.1 从 http.Handler 推导中间件签名
第 2 课讲过,net/http 只认一个接口:
type Handler interface { ServeHTTP(w http.ResponseWriter, r *http.Request) }
服务器只认这个接口,不管你内部干什么。 所以想给 handler 加「打印耗时」,不必改它——造一个新的 Handler,内部持有原来那个:
type timedHandler struct{ next http.Handler }
func (t *timedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
t.next.ServeHTTP(w, r) // 调用里面那层
log.Println(time.Since(start))
}
服务器拿到 *timedHandler,它满足 Handler,服务器满意;它内部又调了真正的业务 handler。业务代码一行没改。
把「造一个包装了 X 的新 Handler」抽象成函数,就是:输入一个 Handler,输出一个 Handler。再用 http.HandlerFunc(第 2 课讲过,函数类型自带 ServeHTTP)消掉结构体:
func Timed(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Println(time.Since(start))
})
}
这个签名不是谁规定的语法,是从「服务器只认 Handler 接口」推导出的唯一自然形状。
为什么它能实现洋葱模型:输入输出类型完全相同,所以输出可以再喂给下一个中间件,A(B(C(h))) 类型上永远成立,可以无限套娃。「同类型输入输出」是自由组合的全部来源。
3.2 洋葱模型与执行顺序
next.ServeHTTP(w, r) 把中间件劈成两半:它之前的代码在请求进入时跑,之后的在响应返回时跑。
Chain(handler, RequestID, Logger, Recover)
注册顺序: 1st 2nd 3rd
┌──────────────── RequestID ──────────────────┐
│ 生成 id 存进 context ← 请求阶段 │
│ ┌────────────── Logger ─────────────────┐ │
│ │ start := time.Now() ← 请求阶段 │ │
│ │ ┌──────────── Recover ────────────┐ │ │
│ │ │ defer recover() ← 请求阶段 │ │ │
│ │ │ ┌────── 业务 handler ───────┐ │ │ │
│ │ │ │ 查库 / 组装 JSON / 写响应 │ │ │ │
│ │ │ └───────────┬───────────────┘ │ │ │
│ │ └──────────────┼──────────────────┘ │ │
│ │ log.Printf(耗时) ← 响应阶段 │ │
│ └─────────────────┼─────────────────────┘ │
└────────────────────┼────────────────────────┘
▼
请求阶段: RequestID → Logger → Recover → handler (同注册顺序)
响应阶段: handler → Recover → Logger → RequestID (逆注册顺序)
新手最容易搞反的地方:注册顺序 = 请求阶段顺序,= 响应阶段的倒序。
记忆锚点:先注册的在外层——像穿衣服,先穿的内衣最后脱。
第 ⑤ 节我用真实代码打印出来了:mw1-in -> mw2-in -> mw3-in -> handler -> mw3-out -> mw2-out -> mw1-out
顺序影响正确性,不只是好看:
| 想要的效果 | 必须的顺序 | 反过来会怎样 |
|---|---|---|
| 日志带 request id | RequestID 在 Logger 外面 |
Logger 取不到 id,打空串 |
| panic 也进访问日志 | Recover 在 Logger 里面 |
panic 穿透 Logger,那行日志永远打不出来 |
| 鉴权失败不浪费连接 | Auth 在业务 handler 外面 |
先查库再发现没登录,白干 |
第二行值得多说:Recover 若在 Logger 外面,panic 会从 handler 一路穿过 Logger 的 next.ServeHTTP——Logger 里那句 log.Printf 在它之后,被跳过了,这条请求在日志里凭空消失。放里面则 panic 先被转成 500,Logger 记录到的是一次普通 500。
所以本课顺序:Chain(h, RequestID, Logger, Recover)。
3.3 context.Context
是什么
一个只有 4 个方法的接口,同时干两件事:传递取消信号(Deadline/Done/Err)和携带请求作用域数据(Value)。
为什么每个函数第一个参数都是它
你在第 3、4 课已经写过 db.QueryRowContext(ctx, ...)。原因是:用户点了取消、或请求已超时,这个信号必须能传到最深处那条正在跑的 SQL,让它别白跑。 Go 没有「线程中断」,取消信号只能靠显式参数层层往下传。
官方约定:ctx 永远是第一个参数、名字就叫 ctx,不要塞进结构体字段(它是「每次请求」的,结构体通常是「整个进程」的)。
r.WithContext —— 一号坑
*http.Request 的 context 只能读不能改,加东西只能造新 request:
// ❌ 下游永远取不到值。没有编译错误、没有 panic、go vet 也不报
r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))
// ✅
r = r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id))
WithContext 返回的是浅拷贝的新 *http.Request,原来那个 r 一个字节都没变。实测:
忘记赋值 -> RequestIDFrom = ""
正确赋值 -> RequestIDFrom = "KEPT"
值悄悄丢了,没有任何提示。 凡是 context.WithXxx 系列,返回值必须接。
key 必须是自定义未导出类型
Value(key any) any 的 key 是 any,所以你可以传 "user_id"。但绝对不要——这是 Go 官方明确的规范。
问题不是恶意,是撞车:你的 middleware 包用 "user_id" 存当前用户,你引入的某个第三方追踪库也用 "user_id"。两个包互不知情却写进了同一个位置,后写的静默覆盖先写的。等你发现时是线上「偶发拿到别人身份」的诡异 bug。实测:
string key 冲突 -> 包B覆盖了
typed key 共存 -> k1=A k2=B
正确做法是未导出类型——别的包在语法上无法构造出你这个类型的值:
// internal/middleware/context.go
package middleware
// ctxKey 是本包私有的 context key 类型。
// 关键在【未导出】(小写 c):别的包连这个类型名都写不出来,
// 它们构造的 key 永远不可能和我们的相等,从根本上消除冲突。
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota // 0
ctxKeyUser // 1
)
ctxKey(0) 和 0 不是同一个 key:Value 内部用 == 比较接口值,要求动态类型和动态值都相等。ctxKey(0) 的动态类型是 middleware.ctxKey,0 的是 int。这正是类型安全的来源。
取值必须检查 ok
u := ctx.Value(ctxKeyUser).(*model.User) // ❌ 没登录时 panic: interface conversion: interface {} is nil
永远用双返回值,并封装成函数,别让调用方碰 key:
// CurrentUser 从 ctx 取出当前登录用户。
// 封装的意义:① key 不导出 ② 强制走 ok 检查 ③ 换存储方式只改这一处。
func CurrentUser(ctx context.Context) (*model.User, bool) {
u, ok := ctx.Value(ctxKeyUser).(*model.User)
return u, ok
}
WithTimeout 与 defer cancel()
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel() // 不能省
为什么必须写:WithTimeout 内部启了定时器,并把新 ctx 注册成父 ctx 的子节点。不调 cancel,这些会一直留到超时时刻才释放——请求量一大就是持续增长的内存和 goroutine,压测才暴露的泄漏。cancel() 是幂等的,无脑 defer 就对。go vet 的 lostcancel 会抓这个,漏写报 the cancel function is not used on all paths (possible context leak)。
取消向下传播:cancel() 父 ctx,所有派生的子 ctx 全部 Done()。这就是把 ctx 传给 db.QueryContext 能真正中断 SQL 的原因——database/sql 监听了 ctx.Done(),触发时向 MySQL 发 kill query。实测 ctx.Err()=context deadline exceeded。
④ 函数逐个精讲
4.1 Middleware 与 Chain
// internal/middleware/middleware.go
package middleware
import "net/http"
// Middleware 就是"拿一个 Handler、返回包装后的 Handler"的函数。
// 定义成具名类型,是为了让 Chain 的可变参数签名可读,
// 以及让"返回中间件的函数"(CORS、第 6 课的 RequireAuth)有个短名字可用。
type Middleware func(http.Handler) http.Handler
// Chain 把多个中间件按【注册顺序 = 请求阶段执行顺序】组合到 h 外面。
// Chain(h, A, B, C) 等价于 A(B(C(h)))。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
// 倒序包装是关键。目标结构是 A(B(C(h))),而构造只能从最内层开始:
// i=2: h = C(h)
// i=1: h = B(C(h))
// i=0: h = A(B(C(h))) ✓
// 写成正序 for i:=0.. 结果会是 C(B(A(h))),顺序整个反过来。
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
mws ...Middleware是可变参数,函数体内就是[]Middleware。也可以展开已有切片:Chain(h, list...)。h = mws[i](h)能成立,正是因为Middleware输入输出同类型(回到 3.1)。- 零个中间件时循环不执行,直接返回原
h。消除特殊情况,而不是加if len(mws) == 0——这就是好品味。
易错点:倒序写成正序。 不报任何错、服务照跑,但顺序完全颠倒:Recover 跑到最外层、RequestID 到最内层。症状是日志里 request id 全空、panic 不进访问日志。排查很费劲,因为「代码看起来完全合理」。写完立刻用 5.2 的测试验证,别靠脑补。
4.2 RequestID —— context 存取全套范式
// internal/middleware/requestid.go
package middleware
import (
"context"; "crypto/rand"; "encoding/hex"; "fmt"; "net/http"; "time"
)
// RequestID 给每个请求分配唯一 id,存进 context 并回写响应头。
// 为什么需要:线上一条"我刚才报错了"的反馈,你需要能凭一个 id
// 把这次请求在所有日志行里串起来。没有它,高并发下日志是一锅粥。
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 优先复用上游(网关/nginx/调用方)的 id,这样能跨服务串联链路
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newRequestID()
}
// 2. 存进 context —— 三步一个都不能少
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
r = r.WithContext(ctx) // ← 漏掉 r = 就前功尽弃
// 3. 回写响应头,用户截图里就带着 id,排查成本归零。
// 【必须】在下游写响应之前设置,见下方易错点。
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r) // 4. 传【新的】r
})
}
// newRequestID 生成 16 位十六进制随机串。
// 用 crypto/rand 而非 math/rand:后者默认种子同进程内可预测。
// request id 虽不是安全凭据,但 crypto/rand 成本极低,没理由用弱的。
func newRequestID() string {
b := make([]byte, 8) // 8 字节 → hex 后 16 字符
if _, err := rand.Read(b); err != nil {
// 几乎不可能(内核熵源故障),但返回了 error 就不能吞。
// 降级成时间戳,保证服务不因为生成 id 失败而挂。
return fmt.Sprintf("ts-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// RequestIDFrom 取 request id,取不到返回空串。
//
// 这里【故意】不返回 (string, bool):调用方全是日志语句,拿不到打空串就行,
// 强迫每处写 if 反而让日志代码变丑。对比第 6 课的 CurrentUser 必须返回 ok
// —— 因为那里"有没有用户"直接决定鉴权走向。
// 判断标准:ok 会不会影响控制流。会就返回 ok,不会就给安全零值。
func RequestIDFrom(ctx context.Context) string {
id, ok := ctx.Value(ctxKeyRequestID).(string)
if !ok {
return ""
}
return id
}
易错点:w.Header().Set 写在 next.ServeHTTP 之后。 HTTP 响应头必须先于响应体发送。下游一旦 WriteHeader 或 Write,header 已经序列化到网络上,之后改 map 毫无效果。不报错、不 panic,客户端就是收不到这个头。 所有设置响应头的操作一律放在 next.ServeHTTP 之前。
4.3 statusRecorder —— struct 嵌入与覆写
Logger 想记状态码,但 http.ResponseWriter 接口只有写,没有读——状态码写出去就拿不回来。办法:做个「中间人」,它也是 ResponseWriter,转发的路上把状态码抄一份。
// internal/middleware/logger.go
// statusRecorder 包装 http.ResponseWriter,转发写操作的同时记录状态码和字节数。
type statusRecorder struct {
http.ResponseWriter // ← 匿名字段,这就是 Go 的"嵌入"(embedding)
status int
bytes int
wrote bool // 防重复写 header
}
什么是嵌入:这一行只有类型没有字段名。Go 会做两件事——字段名默认是类型名(rec.ResponseWriter);被嵌入类型的方法自动提升到外层(rec.Header() 编译器转发成 rec.ResponseWriter.Header())。
这是 Go 替代继承的机制。关键区别:这是组合不是继承。statusRecorder 不是 ResponseWriter 的子类,只是「碰巧转发了所有方法,因此也满足那个接口」。没有虚函数表、没有多态派发。
好处:只覆写关心的那一个方法,其余全自动转发。不嵌入就得手写三个转发方法,接口有 10 个方法就写 10 个。
// WriteHeader 覆写嵌入的同名方法。外层有同名方法时会【遮蔽】(shadow)内层的,
// 于是所有 rec.WriteHeader(...) 调用都进这里。
func (rec *statusRecorder) WriteHeader(code int) {
if rec.wrote {
// 重复调用时标准库会打印 "http: superfluous response.WriteHeader call" 并忽略。
// 这里也直接返回,保证记录的是【第一次】的状态码,和实际发出去的一致。
return
}
rec.status, rec.wrote = code, true
rec.ResponseWriter.WriteHeader(code) // 【必须】转发,否则响应永远发不出去
}
// Write 也要覆写,两个原因:
// ① 统计字节数
// ② handler 可能【不调 WriteHeader 直接 Write】,此时 HTTP 隐式是 200,
// 必须自己补记,否则会漏掉所有正常响应
func (rec *statusRecorder) Write(b []byte) (int, error) {
if !rec.wrote {
rec.WriteHeader(http.StatusOK) // 调自己的方法,以便走上面的记录逻辑
}
n, err := rec.ResponseWriter.Write(b)
rec.bytes += n
return n, err
}
// Unwrap 让 http.ResponseController 能穿透这层包装,找到底层真正实现了
// Flusher/Hijacker 的 ResponseWriter。Go 1.20 引入的官方约定,
// 专门解决中间件包装导致的接口丢失,见下方。
func (rec *statusRecorder) Unwrap() http.ResponseWriter { return rec.ResponseWriter }
实测两条路径:statusRecorder 捕获: status=404 bytes=4 / 隐式 200: status=200 bytes=2。
易错点 1:忘了转发。 只写 rec.status = code 漏掉 rec.ResponseWriter.WriteHeader(code),编译通过,但客户端一直挂着等响应直到超时——你把写操作截胡了却没往下传。
易错点 2:status 初值是 0。 构造时显式给 status: http.StatusOK,否则日志里会出现 status=0 这种不存在的状态码。
固有坑:接口丢失。 标准库的 ResponseWriter 实现还悄悄实现了 http.Flusher(SSE 必需)、http.Hijacker(WebSocket 必需)、io.ReaderFrom。你的 statusRecorder 只嵌入了 ResponseWriter 接口,所以只有那 3 个方法:
Flusher: 包装前=true 包装后=false
症状:加了日志中间件后 SSE 不再实时推送(数据攒到最后一次性吐出),或 WebSocket 升级失败报 response does not implement http.Hijacker。业务代码一行没改,你完全想不到是日志中间件干的。
Go 1.20+ 解法就是上面那个 Unwrap,下游改用 http.NewResponseController(w).Flush() 而不是类型断言。实测这条路是通的(ResponseController.Flush 穿透 Unwrap -> <nil>)。
4.4 Logger
// internal/middleware/logger.go
// Logger 记录方法、路径、状态码、字节数、耗时。
// 必须在 RequestID 【里面】,否则取不到 id;必须在 Recover 【外面】,否则 panic 的请求不被记录。
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() // 请求阶段
// 用 statusRecorder 换掉原始 w 传下去。
// 从这里往内,所有中间件和 handler 拿到的都是这个包装对象。
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r) // ← 分界线:内层全跑完才返回
// 响应阶段:此时 rec.status / rec.bytes 已被内层填好
log.Printf("rid=%s %s %s %d %dB %v",
RequestIDFrom(r.Context()), r.Method, r.URL.Path,
rec.status, rec.bytes, time.Since(start).Round(time.Microsecond))
})
}
Round(time.Microsecond) 把 1.234567891ms 变成 1.235ms,日志好读得多。第 10 课会换成 log/slog 做结构化日志,现在不用管。
4.5 Recover
为什么 panic 会搞挂整个服务
第 1 课讲过 Go 用 error 处理错误、panic 只用于「程序进入不可能继续的状态」。但你依赖的库和你自己的疏忽随时可能 panic——nil map 写入、切片越界、类型断言失败、nil 指针。
关键机制:net/http 为每个请求开一个新 goroutine。而 Go 的规则是——
任何一个 goroutine 发生 panic 且未被 recover,整个进程立即终止。
不是「这个请求返回 500」,是进程退出,所有正在处理的其他请求全部断连。一个边缘接口的空指针能让整个服务下线。
(net/http 在 conn.serve 里确实有一层 recover,会打印堆栈并关闭连接,但它不写任何响应体——客户端拿到的是连接重置。而且它只覆盖 handler 同步路径;你在 handler 里 go func(){...}() 起的 goroutine panic,没有任何东西能救你,进程必挂。所以自己写 Recover 仍然必须。)
recover 只在 defer 里有效
语言规范:recover 只有在被延迟调用的函数中直接调用才捕获 panic。写在 defer 调用的函数再内层也无效。
// internal/middleware/recover.go
// Recover 捕获下游 panic,打印堆栈,返回 500,保证进程存活。
// 应放在链的【较内层】:Logger 在它外面 → panic 转成 500 后能被正常记录;
// RequestID 在它外面 → 堆栈日志能带上 request id。
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
v := recover()
if v == nil {
return
}
// http.ErrAbortHandler 是标准库约定的"静默中止"信号(如客户端断连),
// 不是真错误,按约定原样再 panic 出去交给标准库。
//
// 【必须】用双返回值断言:v 的类型是 any,写成 v.(error) 在
// panic("字符串") 场景下会当场二次 panic —— 而二次 panic 发生在
// defer 里,谁也救不了,进程照样挂。
if err, ok := v.(error); ok && errors.Is(err, http.ErrAbortHandler) {
panic(v)
}
// 1. 记日志:堆栈【只进日志】。堆栈含文件路径、包结构、行号,
// 泄露给攻击者等于送一份代码地图。
log.Printf("PANIC rid=%s %v\n%s", RequestIDFrom(r.Context()), v, debug.Stack())
// 2. 回响应:无信息量的通用错误 + request id。
// 用户把 id 报给客服,你就能精确定位那条堆栈。
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"code": "INTERNAL", "message": "服务器内部错误",
"request_id": RequestIDFrom(r.Context()),
})
}()
next.ServeHTTP(w, r)
})
}
debug.Stack() 返回调用它时该 goroutine 的完整堆栈,必须在 recover 的 defer 里同步调用——放别处拿到的是另一条堆栈,毫无价值。
易错点:panic 发生在响应已写了一半时。 若 handler 已 WriteHeader(200) 写了半个 JSON 才 panic,这里再 WriteHeader(500) 无效(标准库打印 superfluous response.WriteHeader call),客户端收到 200 + 残缺 JSON。彻底解决要先把响应缓冲到内存,代价是内存和流式能力。本课不做,但要知道这个边界。
4.6 统一错误响应
核心原则:两份消息
给客户端的消息和给日志的消息必须是两份。
违反它的经典写法 http.Error(w, err.Error(), 500),当 err 是数据库错误时,你直接把这种东西发给了公网:
Error 1054 (42S22): Unknown column 'passwrod_hash' in 'field list'
攻击者白拿:你用 MySQL、表里有 password_hash 列、你的 SQL 长什么样。配合报错差异,这是 SQL 注入探测的标准信息源。永远不要把 err.Error() 原文返回给客户端。
沿用第 2 / 4 课已定稿的东西,不要另起一套
第 2 课已经定好了响应层的三个函数和 APIError,第 4 课定好了状态码映射。本课不重新发明它们,只在上面补一层。 先把契约摆出来:
// internal/handler/response.go —— 第 2 课已定稿,原样沿用
type APIError struct {
Code string `json:"code"` // 机器读:INVALID_ID / POST_NOT_FOUND / VALIDATION_FAILED
Message string `json:"message"` // 人读
Field string `json:"field,omitempty"` // 校验错误才有
}
// 注意 writeJSON 【返回 error】:客户端提前断开时 Write 会失败,第 2 课讲过为什么不吞
func writeJSON(w http.ResponseWriter, status int, v any) error
// writeError 无返回值,内部记日志
func writeError(w http.ResponseWriter, status int, code, msg string)
// 注意 decodeJSON 【要 w】:因为 http.MaxBytesReader(w, r.Body, n) 必须拿到 w 才能切断连接
func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error
已在用的错误码:INVALID_PARAM / INVALID_ID(400)、BAD_REQUEST(400)、POST_NOT_FOUND(404)、VALIDATION_FAILED(422)、NOT_FOUND(404 兜底)。本课新增错误码可以,但 APIError 的三个字段不能动。
第 4 课写的 httpStatusFor(err error) int 是一个纯函数(领域错误 → HTTP 状态码,无副作用、可单测)。本课的职责是调用它,不是重写它:
// internal/handler/response.go —— 第 4 课已定稿,本课只补两处新分支
//
// APIError 只有三个字段(没有 Status),所以 handler 自己构造的 APIError
// 靠 code 反查状态码。
var codeStatus = map[string]int{
"INVALID_PARAM": http.StatusBadRequest, "INVALID_ID": http.StatusBadRequest,
"BAD_REQUEST": http.StatusBadRequest, "VALIDATION_FAILED": http.StatusUnprocessableEntity,
"POST_NOT_FOUND": http.StatusNotFound, "NOT_FOUND": http.StatusNotFound,
}
func httpStatusFor(err error) int {
var apiErr *APIError
if errors.As(err, &apiErr) { // APIError 优先:它自带 code,直接反查
if st, ok := codeStatus[apiErr.Code]; ok {
return st
}
return http.StatusBadRequest
}
switch {
// 兑现第 1 课正文许下的承诺:errors.Is(err, model.ErrValidation) → 422
case errors.Is(err, model.ErrValidation), errors.Is(err, model.ErrEmptyTitle):
return http.StatusUnprocessableEntity
case errors.Is(err, service.ErrPostNotFound):
return http.StatusNotFound
case errors.Is(err, model.ErrAlreadyPublished):
return http.StatusConflict
// ... 第 6 课会往这里加 401/403/409 的分支
default:
return http.StatusInternalServerError
}
}
model.ErrValidation 和 model.ErrAlreadyPublished 是第 1 课练习让你自己加的哨兵——如果你跳过了那两道题,现在补上,否则这里编译不过。
另外注意 model.ValidationError 的 Error() 是指针接收者,所以只有 *ValidationError 实现了 error:返回时必须写 &model.ValidationError{...},写成值类型会报 does not implement error (method Error has pointer receiver)。
本课新增:writeErrorFrom(替换掉第 4 课的 writeDomainError)
先说清楚这两个函数的关系,否则你项目里会躺着两个干同一件事的函数:
第 4 课 writeDomainError(w, err) |
第 5 课 writeErrorFrom(w, r, err) |
|
|---|---|---|
| 职责 | 领域错误 → HTTP 响应 | 同上 |
| 500 时脱敏 | ✅ | ✅ |
| 日志带 request_id | ❌ 拿不到 r,log.Printf 是裸的 |
✅ 从 r.Context() 取 |
handler 自造的 *APIError |
❌ 会被 errorCodeFor 覆盖掉 Code |
✅ 原样送出 |
writeErrorFrom 是 writeDomainError 的升级版,不是并列关系。 多出来的那个 r 参数就是升级的全部理由——本课给每个请求打了 request_id,错误日志不带上它就没法和访问日志对起来,而 request_id 存在 r.Context() 里,不接 r 就拿不到。
现在动手改:删掉第 4 课的 writeDomainError,把它的两个调用点(Get 和 Update handler)改成 writeErrorFrom(w, r, err)。httpStatusFor 和 errorCodeFor 保留不动——它们是纯函数,新版照样调。
// internal/handler/response.go —— 第 5 课新增
//
// 名字为什么不叫 writeError:第 2 课的 writeError(w, status, code, msg) 已经占用了这个名字,
// 而 Go 【没有函数重载】,同包同名不同签名会直接编译失败。
// 这个函数是它的上层:负责解包错误链、决定 code/message,最后仍然委托给 writeError 输出。
//
// 单一出口的价值:想改错误格式、加埋点、接告警,只改这一个函数。
func writeErrorFrom(w http.ResponseWriter, r *http.Request, err error) {
status := httpStatusFor(err) // ← 状态码映射【复用第 4 课的纯函数】,不在这里重写
// 1. handler 自己构造的 APIError:原样送出
//
// 为什么用 As 不用 Is:
// errors.Is(err, target) 判断链上有没有【等于】target 的错误 —— 比较值
// errors.As(err, &tgt) 判断链上有没有某个【类型】的错误并提取 —— 提取值
// 我们要读它的 Code/Message/Field,所以必须用 As。
// "错误链"指 fmt.Errorf("create post: %w", ...) 层层包裹后的结构,
// As 会顺着 Unwrap 一路挖到底。
var apiErr *APIError
if errors.As(err, &apiErr) {
_ = writeJSON(w, status, apiErr)
return
}
// 2. 字段级校验错误 → 正好填进 APIError.Field
var ve *model.ValidationError
if errors.As(err, &ve) {
_ = writeJSON(w, status, &APIError{
Code: "VALIDATION_FAILED", Message: ve.Reason, Field: ve.Field,
})
return
}
// 3. 已分类的领域哨兵 → 查表得 code
if status < 500 {
for _, c := range errCodes {
if errors.Is(err, c.err) {
writeError(w, status, c.code, c.err.Error())
return
}
}
writeError(w, status, "BAD_REQUEST", "请求无法处理")
return
}
// 4. 未分类 → 500 + 通用文案,底层错误【只】进日志。
// 这是【默认安全】:忘记分类的错误绝不会泄露内容,泄露必须是显式的。
log.Printf("ERROR rid=%s %s %s -> %v",
middleware.RequestIDFrom(r.Context()), r.Method, r.URL.Path, err)
writeError(w, status, "INTERNAL", "服务器内部错误")
}
// 用【有序切片】而不是 map —— 这是个真实的坑:
// 一个错误可能同时匹配多个哨兵(ValidationError 的 Unwrap 就指向 ErrValidation),
// 而 Go 的 map 遍历顺序是【随机】的,同一个错误会时而返回 A 码、时而返回 B 码,
// 变成一个"重跑就好了"的间歇性 bug。切片保证"先匹配到的赢",
// 顺序由我们决定:具体的排在通用的前面。
var errCodes = []struct {
err error
code string
}{
{service.ErrPostNotFound, "POST_NOT_FOUND"},
{model.ErrAlreadyPublished, "ALREADY_PUBLISHED"},
{model.ErrEmptyTitle, "VALIDATION_FAILED"},
{model.ErrValidation, "VALIDATION_FAILED"}, // 通用的排最后
}
service 层怎么配合——它不认识 HTTP,只返回领域错误:
// internal/service/post_service.go —— 片段
func (s *PostService) GetBySlug(ctx context.Context, slug string) (*model.Post, error) {
p, err := s.repo.GetBySlug(ctx, slug)
if errors.Is(err, sql.ErrNoRows) {
return nil, service.ErrPostNotFound // 明确分类 → httpStatusFor 给 404
}
if err != nil {
// 不分类,原样 %w 上抛。writeErrorFrom 兜成 500 + 通用文案,
// 同时完整错误链进日志。这就是"默认安全"的实际效果。
return nil, fmt.Errorf("get post by slug %q: %w", slug, err)
}
return p, nil
}
我把这套映射跑了 12 组真实断言,输出如下(注意最后两行——SQL 原文和数据库地址都没有出现在响应体里):
ErrValidation 哨兵 -> 422 {"code":"VALIDATION_FAILED","message":"校验失败"}
ValidationError 指针 -> 422 {"code":"VALIDATION_FAILED","message":"标题不能为空","field":"title"}
ValidationError 被 %w 包裹 -> 422 {"code":"VALIDATION_FAILED","message":"只能小写","field":"slug"}
ErrEmptyTitle -> 422 {"code":"VALIDATION_FAILED","message":"标题不能为空"}
POST_NOT_FOUND 深层包裹 -> 404 {"code":"POST_NOT_FOUND","message":"文章不存在"}
APIError 直接返回 -> 400 {"code":"INVALID_ID","message":"id 必须是正整数"}
未分类错误(SQL 原文) -> 500 {"code":"INTERNAL","message":"服务器内部错误"}
未分类错误被包裹 -> 500 {"code":"INTERNAL","message":"服务器内部错误"}
而服务端日志里才有 ERROR GET /x -> Error 1054 (42S22): Unknown column 'passwrod_hash'。
handler 就极其干净:
p, err := h.svc.GetBySlug(r.Context(), r.PathValue("slug"))
if err != nil {
writeErrorFrom(w, r, err) // 一行,不需要判断是什么错
return
}
_ = writeJSON(w, http.StatusOK, p) // 第 2 课定稿的 writeJSON 返回 error
4.7 其余中间件(要点版)
// internal/middleware/cors.go
// CORS 允许跨域。allowOrigin 传具体域名,生产环境【不要】用 "*"。
// 注意签名是 func(string) Middleware —— 它【返回】中间件,靠闭包捕获参数。
// 这是"带参数的中间件"的标准写法,第 6 课的 RequireAuth(svc) 同一个模式。
func CORS(allowOrigin string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400") // 缓存预检 24h,减少往返
// 关键:preflight 必须【立即返回,不往下传】。
// 浏览器发"非简单请求"(带 Authorization、Content-Type 是 json 等)前,
// 会先发 OPTIONS 探路。这个请求【没有请求体,也不该有业务副作用】。
// 放它进 next 的后果:
// 最好 —— ServeMux 返回 405,浏览器认为预检失败,真实请求根本不发
// 最坏 —— 命中了某个 DELETE 路由,数据没了
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) // 204
return // ← 全场重点
}
next.ServeHTTP(w, r)
})
}
}
// MaxBody 限制请求体大小,防止超大 POST 打爆内存。
// 不加这个,一个 curl --data-binary @10gb.bin 就能让解码器吃满内存。
//
// 和第 2 课 decodeJSON 的关系:decodeJSON 内部【已经】对自己读的那次
// 调了 MaxBytesReader —— 这正是它签名里要 w 的原因
// (http.MaxBytesReader(w, r.Body, n) 必须拿到 w 才能在超限时切断连接;
// 退回 io.LimitReader 会静默截断,超大请求体会被误报成「JSON 语法错误」)。
// 那为什么还要这个中间件?因为不是所有 handler 都走 decodeJSON:
// 文件上传、表单、自定义解析都会直接读 r.Body。中间件是【全局兜底】,
// 两者都要有,谁先执行谁的限额生效(中间件在外层,所以是它先)。
func MaxBody(n int64) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// http.MaxBytesReader 优于手写 io.LimitReader:
// ① 超限错误类型是 *http.MaxBytesError,可 errors.As 出来给 400
// ② 主动关闭连接,不让对方继续灌数据
r.Body = http.MaxBytesReader(w, r.Body, n)
next.ServeHTTP(w, r)
})
}
}
Timeout 留作练习 2。注意它只传递取消信号,不强行掐断 handler——真正生效靠下游把 ctx 传给 db.QueryContext。handler 里有个不检查 ctx.Done() 的死循环,这个中间件救不了你。
Access-Control-Allow-Origin: * 配合 Allow-Credentials: true 是浏览器明确禁止的组合。要带 cookie 就必须回具体域名。
4.8 装配
// cmd/server/main.go —— 片段
mux := http.NewServeMux()
mux.HandleFunc("GET /api/posts", postHandler.List)
mux.HandleFunc("GET /api/posts/{slug}", postHandler.GetBySlug)
// 故意留一个会 panic 的路由验证 Recover,验证完删掉
mux.HandleFunc("GET /debug/panic", func(w http.ResponseWriter, r *http.Request) {
var m map[string]string
m["boom"] = "x" // panic: assignment to entry in nil map
})
// 顺序即语义,不要随手调换
root := middleware.Chain(mux,
middleware.RequestID, // 最外:后面所有层都能拿到 id
middleware.Logger, // 能记录到 Recover 转换后的 500
middleware.Recover, // 保护业务 handler 和 CORS/MaxBody
middleware.CORS("http://localhost:3000"),
middleware.MaxBody(1<<20), // 1 MiB
)
srv := &http.Server{
Addr: ":8080",
Handler: root, // ← 传 root 不是 mux
ReadHeaderTimeout: 5 * time.Second, // 防 Slowloris,几乎零成本
}
log.Fatal(srv.ListenAndServe())
易错点 1:Handler: mux。 中间件链构造好了却传了原始 mux,所有中间件静默失效——服务照常工作,只是没日志、没 panic 保护。检查方法:打一个请求,看有没有日志输出。
易错点 2:别用 mux.HandleFunc("/", ...) 做统一 404 兜底。 第 2 课实测过:ServeMux 本来会为「路径匹配但方法不匹配」的请求自动生成 405 + Allow 头,而一个 "/" 兜底路由会把它整个吃掉。我复现了这个差异:
无兜底路由 DELETE /api/posts -> 405 Allow="GET, HEAD, POST"
有 mux.HandleFunc("/") DELETE /api/posts -> 404 Allow=""
405 变 404,Allow 头也没了。客户端再也无法知道「这个资源存在,只是不支持 DELETE」,调试 API 时非常难受。想要统一的 404 响应体,就别注册 "/"——让标准库自己回 404/405,或者在中间件里拦 statusRecorder 捕获到的状态码再改写响应(代价见 4.3 的接口丢失)。
⑤ 跑起来验证
5.1 编译与静态检查
cd ~/go-blog && go build ./... && go vet ./...
go vet 会抓 lostcancel 等问题,务必跑。预期无输出。
5.2 验证中间件顺序
// internal/middleware/order_test.go
package middleware
func TestChainOrder(t *testing.T) {
var order []string
trace := func(name string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
order = append(order, name+"-in")
next.ServeHTTP(w, r)
order = append(order, name+"-out")
})
}
}
h := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
order = append(order, "handler")
}), trace("mw1"), trace("mw2"), trace("mw3"))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/x", nil))
want := "mw1-in -> mw2-in -> mw3-in -> handler -> mw3-out -> mw2-out -> mw1-out"
if got := strings.Join(order, " -> "); got != want {
t.Fatalf("顺序错了\n got: %s\nwant: %s", got, want)
}
}
go test ./internal/middleware/ -run TestChainOrder -v
# --- PASS: TestChainOrder (0.00s)
把 Chain 的循环改成正序重跑一次,看它失败并打印颠倒的顺序。亲手制造一次这个 bug,比读十遍解释管用。
5.3 验证 panic 恢复
go run ./cmd/server
# 另一个终端
curl -i -s localhost:8080/debug/panic
预期 500 而不是连接重置,且响应体没有堆栈:
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
X-Request-Id: 30bed1d25e06e11d
{"code":"INTERNAL","message":"服务器内部错误","request_id":"30bed1d25e06e11d"}
服务端应该看到堆栈 + 一条正常的访问日志:
2026/09/06 09:28:16 PANIC rid=30bed1d25e06e11d assignment to entry in nil map
goroutine 23 [running]:
runtime/debug.Stack()
/usr/local/go/src/runtime/debug/stack.go:26 +0x64
blog/internal/middleware.Recover.func1.1()
/Users/you/go-blog/internal/middleware/recover.go:32 +0x8c
...
2026/09/06 09:28:16 rid=30bed1d25e06e11d GET /debug/panic 500 86B 375µs
三个确认点:
- 服务还活着 —— 再
curl localhost:8080/api/posts应该正常返回。这是 Recover 的全部价值; - 访问日志里有那条 500 —— 证明
Logger在Recover外面的顺序对了; - 响应体没有
nil map字样和文件路径 —— 堆栈只进了日志。
反面教材:把 Recover 从链里去掉再打这个路由。你会看到 curl: (52) Empty reply from server,服务端打印堆栈,进程退出,第二次 curl 就是 connection refused。
5.4 验证正常请求日志与 id 透传
curl -s -o /dev/null localhost:8080/api/posts
# 服务端: rid=a3f1b2c4d5e6f708 GET /api/posts 200 412B 3.214ms
# ↑ 非空(RequestID) ↑状态码正确 ↑非零(Write 覆写生效)
curl -i -s -H 'X-Request-ID: my-trace-42' localhost:8080/api/posts | grep -i x-request-id
# X-Request-Id: my-trace-42 ← 跨服务链路能串起来
5.5 验证 CORS 预检与体积限制
curl -i -s -X OPTIONS localhost:8080/api/posts \
-H 'Origin: http://localhost:3000' -H 'Access-Control-Request-Method: POST' | head -3
# HTTP/1.1 204 No Content ← 如果是 405,说明你的 return 漏了
head -c 2000000 /dev/zero | tr '\0' 'a' > /tmp/big.txt
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' --data-binary @/tmp/big.txt
# 400 或 500 —— 关键是服务没崩、没吃满内存
5.6 验证错误不泄露
curl -s localhost:8080/api/posts/this-slug-does-not-exist
# {"code":"POST_NOT_FOUND","message":"文章不存在"}
再把 repo 里某个列名故意拼错(如 passwrod_hash)重启后请求,确认客户端只拿到:
{"code":"INTERNAL","message":"服务器内部错误","request_id":"..."}
而服务端日志里才有 Error 1054 (42S22): Unknown column ...。这就是「两份消息」跑通了。 验证完改回来。
⑥ TODO 练习
练习 1:Unwrap 与 SSE 验证
// TODO(练习1): 新增 SSE 路由 GET /api/stream,每秒推一行、共 3 行,
// handler 里用 http.NewResponseController(w).Flush() 刷新。
验收:curl -N localhost:8080/api/stream 必须每秒逐行输出,而不是 3 秒后一次性吐 3 行。
额外要求:先注释掉 statusRecorder.Unwrap 跑一次,观察 Flush() 返回 feature not supported、数据被攒住;再加回来。亲眼看到这个差异,你就永远记得包装 ResponseWriter 的代价。
练习 2:Timeout 与真实取消
// internal/middleware/timeout.go
// TODO(练习2): 实现 Timeout 中间件,并新增慢路由 GET /debug/slow 执行 SELECT SLEEP(5)。
func Timeout(d time.Duration) Middleware { panic("TODO") }
验收:
- 用
Timeout(2*time.Second)包住,请求/debug/slow2 秒左右返回而不是 5 秒; - 日志里的错误链能看到
context deadline exceeded; - 在 MySQL 里
SHOW PROCESSLIST,确认那条SELECT SLEEP(5)在 2 秒后真的消失了——这条最重要,它证明取消信号真的穿透到了数据库层。
提示:writeError 需要能识别 context.DeadlineExceeded 并映射到 504。
练习 3:字段级校验错误
// internal/service/post_service.go
// TODO(练习3): 创建文章时校验 title 非空且 ≤200 字符、slug 匹配 ^[a-z0-9-]+$。
// 失败时返回 &model.ValidationError{Field: ..., Reason: ...}
// (注意 Error() 是【指针接收者】,必须取地址,否则不满足 error 接口)。
// writeErrorFrom 已经会把它翻成 422 + {"code":"VALIDATION_FAILED","field":...}。
验收:-d '{"title":"","slug":"Bad Slug!","content":"x"}' 返回 422:
{"code":"VALIDATION_FAILED","message":"标题不能为空","field":"title"}
第二问(重点):APIError 只有 Code/Message/Field 三个字段,一次只能报一个字段。但用户填 5 个字段错 3 个,让他提交 3 次体验很差。
在不改动 APIError 三字段契约的前提下(这是第 2 课定稿的,全项目共用),设计一个能一次返回多个字段错误的方案,并说明你的取舍。三条可行路线,任选一条实现:
- 顶层响应换一个只用于校验失败的结构体(
{"code":"VALIDATION_FAILED","errors":[...]}),APIError不动; - 定义
ValidationErrors []*model.ValidationError实现error,在writeErrorFrom里加一条errors.As分支单独处理; - 保持一次一个,但按表单顺序返回第一个错误,前端逐个提示。
写 3–5 句说明你选哪条、代价是什么。能意识到「契约是共享的,不能为了自己方便改公共结构」比选哪条更重要。
练习 4:只给 API 路由加中间件
/healthz 被探针每秒打一次,日志被刷屏;静态文件也不需要 CORS。
// TODO(练习4): 重构装配,让 /healthz 只经过 Recover,/api/* 经过完整五个中间件。
验收:curl localhost:8080/healthz 返回 200 且服务端不打访问日志;/api/posts 正常打日志。
提示:ServeMux 可以嵌套——建一个 apiMux 挂所有 /api/ 路由,Chain 包好后 rootMux.Handle("/api/", chained)。注意路径前缀怎么匹配、要不要 http.StripPrefix。
练习 5:中间件里的 panic 谁来救
思考 + 验证题,不写新功能。在 Logger 内部(next.ServeHTTP 之前)加一行 panic("logger boom"),然后:
- 先预测:当前顺序
Chain(mux, RequestID, Logger, Recover)下,这个 panic 会被Recover捕获吗? - 实际跑一遍验证;
- 改成
Chain(mux, RequestID, Recover, Logger)再跑; - 用 3–5 句话说明:为什么「
Recover放最外层」能保护更多代码,但「放Logger里面」能让 panic 请求出现在访问日志里?你选哪个,为什么?
验收:能准确说出两种顺序各自保护了什么、牺牲了什么。生产项目常见做法是放两层 Recover(最外兜底 + Logger 内保业务),能想到这个方案说明你真的理解了洋葱模型。
⑦ 自检清单
中间件与顺序
- 我能从
http.Handler接口推导出中间件签名为什么是func(http.Handler) http.Handler - 我知道
next.ServeHTTP之前是请求阶段、之后是响应阶段,并能不看笔记说出Chain(h, A, B, C)的请求顺序和响应顺序 - 我知道
Chain必须倒序循环,也知道写成正序的症状 - 我能解释为什么
RequestID在Logger外面、Recover在Logger里面
context
- 我知道它同时负责取消信号和请求作用域数据,
ctx是第一个参数且不该存进结构体字段 - 我理解
r = r.WithContext(ctx)的r =不能省,漏写是静默失效没有任何报错 - 我知道 key 必须用自定义未导出类型,并能说出
stringkey 的撞车场景 - 我知道
ctx.Value(k).(T)必须用双返回值,否则 nil 时 panic - 我知道
defer cancel()不写会泄漏,且go vet报lostcancel
ResponseWriter 包装
- 我理解 struct 嵌入会让被嵌入类型的方法自动提升,且这是组合不是继承
- 我知道覆写
WriteHeader后必须转发,否则响应永远发不出去;也知道要同时覆写Write(handler 可能不调WriteHeader直接写,隐式 200) - 我知道包装会丢
Flusher/Hijacker,并知道用Unwrap+http.NewResponseController解决
panic 与错误
- 我知道
recover()只在defer的函数里直接调用才有效,且每请求一个 goroutine、未恢复的 panic 会终止整个进程 - 我知道
recover()返回any,写v.(error)单返回值断言可能引发二次 panic - 我知道
debug.Stack()只能进日志,进响应体就是信息泄露 - 我理解「给客户端」和「给日志」是两份消息,能说出
http.Error(w, err.Error(), 500)泄露了什么 - 我知道
errors.As(提取类型)与errors.Is(比较值)的区别 - 我理解「未分类错误默认当 500」是一种默认安全设计
- 我沿用了第 2 课的
APIError(三字段)/writeJSON/writeError/decodeJSON,没有另起一套 - 我的
writeErrorFrom调用第 4 课的httpStatusFor,没有重写一份状态码映射 - 我兑现了第 1 课的许诺:
errors.Is(err, model.ErrValidation)→ 422 - 我知道
model.ValidationError的Error()是指针接收者,返回时必须& - 我知道错误码表要用有序切片而非 map(map 遍历随机 → 间歇性返回不同 code)
- 我知道
mux.HandleFunc("/", ...)兜底路由会吃掉标准库的 405 +Allow头
动手
-
go build ./... && go vet ./...无输出;TestChainOrder通过,且我故意改成正序看过它失败 -
curl /debug/panic返回 500、服务存活、日志有堆栈、响应体没有 - 正常请求日志里 request id、状态码、字节数、耗时四项都对,
-H 'X-Request-ID: my-trace-42'能透传 - OPTIONS 预检返回 204 而不是 405
- 列名写错时客户端只看到通用文案,日志里才有 SQL 原文
下一课预告
第 6 课把 users 表用起来:bcrypt 存密码、JWT 签发校验、鉴权中间件。本课的两个东西会被直接复用:context key 范式(ctxKeyUser 已经定义好了)和带参数的中间件(CORS(origin) 的模式,RequireAuth(svc) 一模一样)。
先想一个问题:JWT 签发之后,你怎么让它提前失效? 用户改了密码、点了「退出登录」,那个已发出的 token 还在有效期内——它还能用吗?
(剧透:能。这是 JWT 的固有缺陷,第 6 课讲清代价和缓解手段,第 9 课用 Redis 真正解决。)