Go HTTP、JSON 与中间件

# Go HTTP、JSON 与中间件

本篇目标

使用标准库构建一个最小 JSON API,理解 Handler、请求 Context、中间件、输入限制和服务器超时。

理解 Handler校验 JSON编写中间件配置服务超时

# 先记住一句话

Go HTTP 服务的核心是 http.Handler:服务器把请求交给 Handler,Handler 通过请求 Context 调用业务层,再把稳定的状态码、Header 和响应体写回客户端。

客户端
  → Server 超时与连接管理
  → 中间件:请求 ID、日志、鉴权、恢复
  → Handler:解析并校验 HTTP 输入
  → Service:执行业务规则
  → Handler:映射错误并编码 JSON
1
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)
}
1
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))
	})
}
1
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) // 其他错误表示服务无法继续运行:记录后立即退出进程。
	}
}
1
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 和优雅关闭。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27