Go HTTP、JSON 与中间件
# Go HTTP、JSON 与中间件
使用标准库构建一个最小 JSON API,理解 Handler、请求 Context、中间件、输入限制和服务器超时。
# 先记住一句话
Go HTTP 服务的核心是 http.Handler:服务器把请求交给 Handler,Handler 通过请求 Context 调用业务层,再把稳定的状态码、Header 和响应体写回客户端。
客户端
→ Server 超时与连接管理
→ 中间件:请求 ID、日志、鉴权、恢复
→ Handler:解析并校验 HTTP 输入
→ Service:执行业务规则
→ Handler:映射错误并编码 JSON
2
3
4
5
6
# 一个最小 JSON API
下面使用 Go 1.22 起增强的 ServeMux 路由模式。
// 文件位置:internal/api/handler.go
package api
import (
"encoding/json" // 把 Go 值编码成 JSON 响应。
"errors" // 沿错误链判断稳定领域错误。
"net/http" // 提供 Server、路由、Handler、请求和响应类型。
"strings" // 清理路径参数两侧的空白。
)
// Note 是返回给客户端的响应模型,Struct Tag 决定 JSON 字段名。
type Note struct {
// 反引号中的 Struct Tag 决定 JSON 编码后的字段名。
ID string `json:"id"`
Title string `json:"title"`
}
// ErrNotFound 是供 Handler 稳定判断的示例领域错误。
// 完整项目中应把它与 Note 类型一起定义在业务包,而不是 API 包。
var ErrNotFound = errors.New("note not found")
type NoteService interface {
// 接口只列出 Handler 需要的方法,具体 Service 不必显式声明 implements。
Get(id string) (Note, error)
}
type Handler struct {
service NoteService // Handler 只依赖接口,由组装层注入具体业务实现。
}
// NewHandler 保存 Service 依赖并返回 Handler 指针。
func NewHandler(service NoteService) *Handler {
return &Handler{service: service} // 这里不启动 Server,只完成依赖组装。
}
// Routes 注册当前模块的路由,并返回已经包裹请求 ID 中间件的根 Handler。
func (h *Handler) Routes() http.Handler {
// ServeMux 根据方法和路径模式把请求交给对应函数。
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.health) // GET /health 用于健康检查。
mux.HandleFunc("GET /notes/{id}", h.getNote) // {id} 是可通过 PathValue 读取的路径参数。
return requestID(mux) // 所有进入 mux 的请求都会先经过 requestID。
}
// health 不需要读取请求,因此第二个参数用 _ 明确忽略。
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
// 返回 HTTP 200 和 {"status":"ok"}。
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// getNote 校验路径参数、调用业务层,并把业务结果或错误映射成 HTTP 响应。
func (h *Handler) getNote(w http.ResponseWriter, r *http.Request) {
// PathValue 读取路由模式中的 {id},TrimSpace 去掉首尾空白。
id := strings.TrimSpace(r.PathValue("id"))
// 空 ID 或超过 100 字节的 ID 都在进入业务层前拒绝。
if id == "" || len(id) > 100 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
// 同时接收业务结果和错误;只有 err 为 nil 时才能使用 note。
note, err := h.service.Get(id)
if err != nil {
// 示例用哨兵错误简化映射;真实项目应使用 errors.Is 判断稳定领域错误。
if errors.Is(err, ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "note not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, note) // 成功时编码 Note,并返回 HTTP 200。
}
// writeJSON 统一设置 JSON 响应头、状态码并编码响应体。
func writeJSON(w http.ResponseWriter, status int, value any) {
// Header 必须在 WriteHeader 或首次写响应体之前设置。
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status) // 把 200、400、404 或 500 等状态码写入响应。
// Header 写出后不能再改变状态码,编码失败时应记录内部日志。
// _ 表示主动忽略 Encode 返回的错误;生产代码通常还应记录它。
_ = json.NewEncoder(w).Encode(value)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
这里省略 Service 实现,下一篇会给出完整目录和依赖关系。示例为了单文件可读性暂时把 ErrNotFound 放在 API 包,完整项目应把它定义在稳定的业务包中。
# 中间件是 Handler 的包装函数
package api
import (
"context" // 创建携带 Request ID 的子 Context。
"net/http" // 定义中间件输入输出使用的 Handler 和请求响应类型。
"strconv" // 把时间戳整数转换成十进制字符串。
"time" // 获取当前纳秒时间戳,教学示例用它生成 Request ID。
)
// contextKey 是当前包私有的新类型,避免与其他包直接使用 string 的 Context Key 冲突。
type contextKey string
// requestIDKey 是读取和写入 Request ID 时必须使用的同一个 Key。
const requestIDKey contextKey = "request-id"
// requestID 是标准中间件签名:接收下一个 Handler,返回包装后的 Handler。
func requestID(next http.Handler) http.Handler {
// HandlerFunc 把签名匹配的普通函数适配成 http.Handler。
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 示例使用时间戳生成可读 ID;生产环境应使用稳定的 UUID/ULID 实现。
id := strconv.FormatInt(time.Now().UnixNano(), 10)
// WithValue 返回携带请求 ID 的子 Context,不会修改原 Context。
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id) // 同时把 ID 返回给客户端,便于排查请求日志。
// r.WithContext 返回携带新 Context 的请求副本,再把它交给下游 Handler。
next.ServeHTTP(w, r.WithContext(ctx))
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
中间件适合请求级横切逻辑,不要把领域业务规则隐藏其中。Context Key 使用私有类型可以避免与其他包的字符串键冲突。
# Server 不能只写 ListenAndServe
// 文件位置:cmd/api/main.go
package main
import (
"log" // 输出启动信息和无法恢复的 Server 错误。
"net/http" // 创建并启动 HTTP Server。
"time" // 为连接读取、写入和空闲阶段配置超时。
)
func main() {
// buildHandler 是下文明确说明的组装占位函数,真实项目由它创建各层依赖。
handler := buildHandler()
// http.Server 集中保存监听地址、根 Handler 和连接级超时。
server := &http.Server{
Addr: ":8080", // 在所有网卡的 8080 端口监听。
Handler: handler, // 每个请求从这个根 Handler 进入。
ReadHeaderTimeout: 5 * time.Second, // 最多等待客户端发送完请求头 5 秒。
ReadTimeout: 10 * time.Second, // 最多等待读取完整请求 10 秒。
WriteTimeout: 30 * time.Second, // 普通响应最多允许写出 30 秒。
IdleTimeout: 60 * time.Second, // Keep-Alive 连接空闲 60 秒后关闭。
}
log.Printf("listening on %s", server.Addr)
// ListenAndServe 正常运行时会阻塞;Server 被正常关闭时会返回 http.ErrServerClosed。
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err) // 其他错误表示服务无法继续运行:记录后立即退出进程。
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
buildHandler 在这一段中是明确标出的组装占位函数,下一篇完整项目会实现它。Server 超时要结合流式响应和上游超时设计,不能机械复制固定数值。
请求体也要设上限
JSON Decoder 不会自动阻止超大请求体。创建写接口时应使用 http.MaxBytesReader 限制体积,校验 Content-Type,拒绝未知字段,并保证只解析一个 JSON 值,避免内存耗尽和输入歧义。
# 高频面试题与回答
1. Go HTTP 中间件的本质是什么?参考答案
中间件是接收一个 http.Handler 并返回新 Handler 的函数。它在调用下一个 Handler 前后加入日志、鉴权、恢复等请求级逻辑,因此顺序会直接影响行为。
2. 为什么要给 http.Server 配置超时?参考答案
默认缺少边界会让慢客户端或异常连接长期占用文件描述符、连接和 Goroutine。读取、写入和空闲超时提供基本保护,但流式接口需要单独设计,不能直接沿用普通 JSON API 的写超时。
# 接下来学什么
下一篇学习 Go 生产 HTTP 服务,继续补齐配置、认证、资源限制、SSE 和优雅关闭。