本日关键词(实战):中间件、Handler 包装、日志中间件、鉴权、API Key、请求超时、context.WithTimeout、handler→service→repository
本日语法/概念(实战):
| 语法/概念 | 实战用途 | 本日示例 |
|---|---|---|
func(next http.Handler) http.Handler |
中间件:包装下一层,先做自己的事再调 next | Logging、APIKeyAuth、Timeout |
r.Header.Get("X-API-Key") |
从 Header 取 API Key,做简单鉴权 | auth.go |
context.WithTimeout(ctx, d) |
给请求设超时,下游用 r.Context() 可被取消 | context_timeout.go |
r.WithContext(ctx) |
把带超时的 ctx 放进请求,下一层 r.Context() 即此 ctx | 超时中间件 |
http.StripPrefix("/api", h) |
去掉 URL 前缀再交给子 mux,便于挂 /api/hello 等 | main.go |
| handler → service → repository | 分层:接口、业务、数据;本日以 handler+中间件为主 | Day 7 展开 |
获取实战代码 :如需在本地跑通本文示例,请克隆仓库 WenSongWang/go-quickstart-7days,本文示例在 day5 目录,克隆后在项目根目录执行下文中的命令即可。
一、本篇目标
学完本文并跑通本目录示例,你将掌握:
| 模块 | 内容 |
|---|---|
| 中间件 | 包装 http.Handler:日志、鉴权、请求超时(context) |
| 链式顺序 | 请求依次经过:日志 → 超时 → 鉴权 → 业务 |
| 鉴权 | 从 Header 读取 X-API-Key,未通过返回 401(示例级) |
| Context | 超时中间件注入带取消的 context,面试常问 |
二、前置要求
- 已完成 Day 1~4。
- 命令在项目根目录执行。
三、本日目录结构与示例(先混个眼熟)
目录结构
day5/
├── cmd/
│ └── server/
│ └── main.go # 入口:路由、中间件链、ListenAndServe
├── internal/
│ └── middleware/
│ ├── logging.go # 日志中间件:记耗时、调 next、打日志
│ ├── auth.go # 鉴权中间件:校验 X-API-Key,不对则 401
│ └── context_timeout.go # 超时中间件:WithTimeout、r.WithContext
├── README.md
└── csdn.md
| 目录/文件 | 说明 |
|---|---|
| cmd/server/ | 可执行入口,一个 main 包;挂路由、拼中间件链、起 8080 服务。 |
| internal/middleware/ | 仅本项目内部使用的中间件包,对外不暴露;放 Logging、APIKeyAuth、Timeout 等可复用中间件。 |
| main.go | 用 NewServeMux 挂 /health 与 /api/;/api 下用 StripPrefix + 超时→鉴权→api 链,最外层再包 Logging。 |
示例与知识点
| 示例 | 主要知识点 |
|---|---|
cmd/server/main.go |
NewServeMux、StripPrefix、中间件链(Logging → Timeout → APIKeyAuth → api) |
internal/middleware/logging.go |
中间件写法:先记时间,调 next,再打日志 |
internal/middleware/auth.go |
取 X-API-Key,不对则 401 并 return,不调 next |
internal/middleware/context_timeout.go |
WithTimeout、r.WithContext(ctx),下游用 r.Context() |
四、核心概念与最小示例(不看代码也能懂)
什么是中间件?为什么用「包装」?
中间件就是在业务 Handler 外面再包一层 :请求先经过你这层(例如打日志、查 API Key、设超时),再决定是否调用「下一层」next.ServeHTTP(w, r)。这样日志、鉴权、超时可以和业务解耦,改一处、全站生效,不用在每个 Handler 里重复写。
中间件为什么有「一层 return」和「两层 return」?
- Logging :
func Logging(next http.Handler) http.Handler,一层 return ,直接返回一个 Handler;因为next已经作为参数传进来了。 - Timeout / APIKeyAuth :
func Timeout(d) func(http.Handler) http.Handler,两层 return :先返回「一个函数」,调用时再传next得到 Handler;这样可以在「造中间件」时带入参数(超时时间 d、API Key 字符串),再拿去包装不同的 next。总结:要带参数就用「两层」(返回函数),不带参数就「一层」(直接返回 Handler)。
中间件顺序有什么讲究?
先包装的先执行 。本日顺序是:Logging(mux) 最外层,所以请求先打日志;对 /api/ 再包一层 Timeout(APIKeyAuth(api)),所以进 API 后先超时、再鉴权、再进业务。若把鉴权放在最外层,未带 Key 的请求就不会经过超时和日志(看需求而定)。
请求超时怎么做?为什么用 context?
超时要做两件事:给请求一个「最多执行多久」的 deadline ,以及超时后能让下游(DB、RPC)一起停 。做法是:在中间件里用 context.WithTimeout(r.Context(), 5*time.Second) 得到带超时的 ctx,再用 r.WithContext(ctx) 把新 ctx 放进请求;下一层用 r.Context() 拿到的就是这个 ctx。下游在调 DB/RPC 时把这个 ctx 传进去,超时后 ctx 被取消,下游就能收到取消信号并退出。面试常问:如何做请求超时?答:中间件里 WithTimeout + r.WithContext,业务里用 r.Context()。
鉴权这里为什么只做「示例」?API Key 从哪来?
本日只用 Header 里的 X-API-Key 和固定字符串 "secret" 比对,适合理解「中间件如何拦截请求」。生产环境 :API Key 应从配置(如 Day 3 的 viper/cfg)或环境变量读取,不要写死在代码里。真实项目里还会做 JWT、OAuth、Session 等,但拦截方式一样 :在中间件里校验,不通过就 WriteHeader(401) 并 return,不调用 next。延伸阅读 :接口鉴权综述:签名、AK/SK、加解密与常见方式(附 Python 示例)。
易踩坑小结
| 坑 | 原因 | 解法 |
|---|---|---|
| 中间件里已 401 又调了 next | 会再写一次响应,可能重复 WriteHeader | 校验不通过时直接 return,不要调 next.ServeHTTP |
| 业务里拿不到「带超时的 ctx」 | 没用 r.WithContext 传给下一层 | 超时中间件里必须 next.ServeHTTP(w, r.WithContext(ctx)) |
| 顺序搞反 | 先包装的后执行(洋葱模型) | 记清:Logging(mux) 表示请求先经 Logging,再进 mux |
| API Key 写死 | 示例里 "secret" 硬编码,上线难改 | 从配置(Day 3 cfg)或环境变量读取 |
五、Day 5 示例代码与逐段解读
1. cmd/server/main.go
go
package main
import (
"net/http"
"time"
"github.com/go-quickstart-7days/day5/internal/middleware"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
api := http.NewServeMux()
api.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":"hello"}`))
})
// 链:超时 5s → 鉴权(secret) → api;"secret" 示例用,生产应从配置读取
apiHandler := middleware.Timeout(5 * time.Second)(middleware.APIKeyAuth("secret")(api))
mux.Handle("/api/", http.StripPrefix("/api", apiHandler))
// 最外层:先打日志,再进 mux(再进超时、鉴权、业务)
handler := middleware.Logging(mux)
http.ListenAndServe(":8080", handler)
}
解读 :NewServeMux 替代默认 mux,便于挂子路由。/api/ 用 StripPrefix("/api", apiHandler),请求进来时会把路径前缀 /api 去掉再交给 apiHandler,所以 api 里注册的是 /hello。中间件链从里到外是:api → APIKeyAuth → Timeout;再整体交给 Logging(mux)。所以实际顺序是:Logging →(若 /api)Timeout → APIKeyAuth → 业务。
2. internal/middleware/logging.go
go
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
解读 :典型中间件签名「接收 next,返回新 Handler」。只有一层 return :直接返回 http.HandlerFunc(...)(一个 Handler),不像 Timeout/APIKeyAuth 那样「返回一个函数」。先记开始时间,调 next.ServeHTTP 把请求交给下一层,等下一层返回后再打日志(这样耗时包含业务执行时间)。
3. internal/middleware/auth.go
go
func APIKeyAuth(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" || key != apiKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
解读 :APIKeyAuth("secret") 返回「中间件函数」。在 Handler 里取 X-API-Key,不对就设 401、写 JSON、直接 return 不调 next ;对才 next.ServeHTTP(w, r)。
4. internal/middleware/context_timeout.go
go
func Timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
解读 :两层 return :Timeout(d) 返回「一个函数」,这个函数接收 next 再返回 Handler;所以第一层 return 造「中间件函数」,第二层 return 造「包装后的 Handler」。逻辑上:用 r.Context() 作为父 context,WithTimeout 得到带超时的 ctx,defer cancel() 确保资源释放;关键 :next.ServeHTTP(w, r.WithContext(ctx)) 把带超时的 ctx 放进请求,业务里 r.Context() 就是它,超时后 ctx 被取消,下游可检测。
六、运行当天代码
在项目根目录执行:
bash
go run ./day5/cmd/server
- 健康检查:
curl http://localhost:8080/health→ 返回ok;或浏览器打开http://localhost:8080/health - 带鉴权:
curl -H "X-API-Key: secret" http://localhost:8080/api/hello→{"message":"hello"} - 不带 Key:
curl http://localhost:8080/api/hello→ 401;或浏览器直接打开/api/hello会得到 401(因无法带自定义 Header)
浏览器里带 Key 测试 :F12 → Console,先输入 allow pasting 回车(若提示不要粘贴代码),再执行:
fetch("http://localhost:8080/api/hello", { headers: { "X-API-Key": "secret" } }).then(r=>r.json()).then(console.log)
预期输出 {message: 'hello'}。
可对比带 Key 与不带 Key 的响应,理解中间件如何拦截请求。
七、学习建议
- 理解链式顺序:请求依次经过「日志 → 超时 → 鉴权 → 业务」,对应代码里的包装顺序。
- 重点看 context :超时中间件如何创建带超时的 context、如何用
r.WithContext传给下一层,面试常考。 - 改一改:改 API Key、超时时间,或加一个「打印 Header」的中间件,加深印象。
- 延伸阅读 :想了解签名、AK/SK、加解密等更多鉴权方式可参考 接口鉴权综述。
八、小结
Day 5 的中间件和分层是写可维护 HTTP 服务的标配;Day 7 综合实战会复用「日志 + 可选鉴权 + 优雅关闭」。建议把中间件执行顺序和 context 传递搞清再进入 Day 6。