跳到正文
知识库 / Go 博客教程 / 第 5 课返回主站 ↗

Go 博客教程 · 第 5 课 / 10

第 5 课:中间件与统一错误处理

前四课打通了「请求 → SQL → JSON」的主链路,但现在的代码有四个问题:每个 handler 各写各的日志;任何一处 nil 指针会让整个进程退出;线上拿到用户反馈没法和日志对上;错误响应格式各不相同。

这四件事的共同点是:不属于任何一个具体 handler,但每个 handler 都需要。这就是中间件。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 4 课:分层架构与 CRUD下一课:第 6 课:用户与 JWT 认证 →

① 本课目标

写出 Middleware 类型 + Chain 组合函数,以及 RequestID / Logger / Recover / CORS / MaxBody / Timeout 六个中间件;写出包装 http.ResponseWriterstatusRecorder;建立统一错误响应机制,保证给客户端和给日志的消息是两份

并能回答:为什么中间件签名是 func(http.Handler) http.HandlerChain(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 RequestIDLogger 外面 Logger 取不到 id,打空串
panic 也进访问日志 RecoverLogger 里面 panic 穿透 Logger,那行日志永远打不出来
鉴权失败不浪费连接 Auth 在业务 handler 外面 先查库再发现没登录,白干

第二行值得多说:Recover 若在 Logger 外面,panic 会从 handler 一路穿过 Loggernext.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 不是同一个 keyValue 内部用 == 比较接口值,要求动态类型和动态值都相等。ctxKey(0) 的动态类型是 middleware.ctxKey0 的是 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
}

WithTimeoutdefer cancel()

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel() // 不能省

为什么必须写WithTimeout 内部启了定时器,并把新 ctx 注册成父 ctx 的子节点。不调 cancel,这些会一直留到超时时刻才释放——请求量一大就是持续增长的内存和 goroutine,压测才暴露的泄漏。cancel()幂等的,无脑 defer 就对。go vetlostcancel 会抓这个,漏写报 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 MiddlewareChain

// 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 响应头必须先于响应体发送。下游一旦 WriteHeaderWrite,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/httpconn.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.ErrValidationmodel.ErrAlreadyPublished第 1 课练习让你自己加的哨兵——如果你跳过了那两道题,现在补上,否则这里编译不过。

另外注意 model.ValidationErrorError()指针接收者,所以只有 *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 ❌ 拿不到 rlog.Printf 是裸的 ✅ 从 r.Context()
handler 自造的 *APIError ❌ 会被 errorCodeFor 覆盖掉 Code ✅ 原样送出

writeErrorFromwriteDomainError 的升级版,不是并列关系。 多出来的那个 r 参数就是升级的全部理由——本课给每个请求打了 request_id,错误日志不带上它就没法和访问日志对起来,而 request_id 存在 r.Context() 里,不接 r 就拿不到。

现在动手改:删掉第 4 课的 writeDomainError,把它的两个调用点(GetUpdate handler)改成 writeErrorFrom(w, r, err)httpStatusForerrorCodeFor 保留不动——它们是纯函数,新版照样调。

// 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

三个确认点

  1. 服务还活着 —— 再 curl localhost:8080/api/posts 应该正常返回。这是 Recover 的全部价值;
  2. 访问日志里有那条 500 —— 证明 LoggerRecover 外面的顺序对了;
  3. 响应体没有 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") }

验收

  1. Timeout(2*time.Second) 包住,请求 /debug/slow 2 秒左右返回而不是 5 秒;
  2. 日志里的错误链能看到 context deadline exceeded
  3. 在 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 课定稿的,全项目共用),设计一个能一次返回多个字段错误的方案,并说明你的取舍。三条可行路线,任选一条实现:

  1. 顶层响应换一个只用于校验失败的结构体({"code":"VALIDATION_FAILED","errors":[...]}),APIError 不动;
  2. 定义 ValidationErrors []*model.ValidationError 实现 error,在 writeErrorFrom 里加一条 errors.As 分支单独处理;
  3. 保持一次一个,但按表单顺序返回第一个错误,前端逐个提示。

写 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"),然后:

  1. 先预测:当前顺序 Chain(mux, RequestID, Logger, Recover) 下,这个 panic 会被 Recover 捕获吗?
  2. 实际跑一遍验证;
  3. 改成 Chain(mux, RequestID, Recover, Logger) 再跑;
  4. 用 3–5 句话说明:为什么「Recover 放最外层」能保护更多代码,但「放 Logger 里面」能让 panic 请求出现在访问日志里?你选哪个,为什么?

验收:能准确说出两种顺序各自保护了什么、牺牲了什么。生产项目常见做法是放两层 Recover(最外兜底 + Logger 内保业务),能想到这个方案说明你真的理解了洋葱模型。

⑦ 自检清单

中间件与顺序

  • 我能从 http.Handler 接口推导出中间件签名为什么是 func(http.Handler) http.Handler
  • 我知道 next.ServeHTTP 之前是请求阶段、之后是响应阶段,并能不看笔记说出 Chain(h, A, B, C) 的请求顺序和响应顺序
  • 我知道 Chain 必须倒序循环,也知道写成正序的症状
  • 我能解释为什么 RequestIDLogger 外面、RecoverLogger 里面

context

  • 我知道它同时负责取消信号请求作用域数据ctx 是第一个参数且不该存进结构体字段
  • 我理解 r = r.WithContext(ctx)r = 不能省,漏写是静默失效没有任何报错
  • 我知道 key 必须用自定义未导出类型,并能说出 string key 的撞车场景
  • 我知道 ctx.Value(k).(T) 必须用双返回值,否则 nil 时 panic
  • 我知道 defer cancel() 不写会泄漏,且 go vetlostcancel

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.ValidationErrorError()指针接收者,返回时必须 &
  • 我知道错误码表要用有序切片而非 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 真正解决。)