Go 博客教程 · 第 2 课 / 10
第 2 课:net/http 与路由
上一课你写出了一个只会说 hello 的服务,这一课把它变成能 GET /api/posts、GET /api/posts/{id}、POST /api/posts 的真 JSON API——而整个过程里,你只会用到标准库。
上一课练习 4 的思考题答案:WithSummary 必须用指针接收者。写成 func (p Post) WithSummary(s string) Post 的话,方法拿到的是 p 的副本,改的是副本的 Summary,返回的也是副本;调用方不写 p = p.WithSummary(...) 就完全没效果,写了也是换了个新对象——链式调用中间任何一环忘了接返回值,改动就静默丢失。指针接收者从根上消灭了这个陷阱。
1. 本课目标
学完这一课,你能独立做到:
- 说清
http.Handler只有一个方法这件事为什么能撑起整个 Go Web 生态,以及http.HandlerFunc是怎么把普通函数变成 Handler 的; - 用 Go 1.22+ 的
ServeMux新路由语法写方法匹配、路径参数、通配符,并说出优先级规则和三种「路由明明注册了却不生效」的成因; - 正确处理
r.Body(只能读一次、必须限大小)和r.URL.Query(); - 说出
ResponseWriter的顺序铁律,并解释「我明明返回了 500 为什么客户端收到 200」; - 用 struct tag 控制 JSON 字段名、omitempty、忽略字段,并解释为什么小写字段序列化不出来;
- 用指针字段区分「JSON 里没传这个字段」和「传了零值」;
- 显式构造
http.Server并说出三个超时各防什么。
2. 前置检查
上一课的服务还能跑,且 model 包还在:
cd ~/go-blog && go build ./... && go run ./cmd/server &
sleep 2 && curl -s localhost:8080 && kill %1
预期看到 hello, go-blog! now=2026-...。看不到就回第 1 课第 5 节。
3. 核心概念
3.1 http.Handler:一个方法撑起整个生态
标准库里这个接口的定义只有三行:
// net/http(标准库源码,不用你写)
type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}
任何有 ServeHTTP(ResponseWriter, *Request) 方法的类型,就是一个 Handler。不需要写 implements,不需要继承任何基类——Go 的接口是隐式满足的(duck typing,但由编译器静态检查)。
这一个接口撑起了整个 Go Web 生态,因为下面这些东西全都是 Handler:你写的每个业务处理函数(包一层 http.HandlerFunc)、*http.ServeMux 路由器本身(它实现了 ServeHTTP,内部再分发)、中间件(接收一个 Handler,返回一个新 Handler)、http.FileServer、http.Server.Handler 字段。
所以「中间件链」的本质是:Handler → 包一层 → 还是 Handler → 再包一层 → 还是 Handler。俄罗斯套娃,每层类型都一样,所以能无限套。这就是为什么 Go 不需要框架也能写中间件——第 5 课你会亲手实现。
3.2 http.HandlerFunc:Go 里非常漂亮的一招
问题来了:你写的是函数
// internal/handler/post_handler.go
func handleGetPost(w http.ResponseWriter, r *http.Request) { ... }
但 mux.Handle 要的是接口。函数怎么会有方法?
标准库的答案是这两行:
// net/http(标准库源码,不用你写)
type HandlerFunc func(ResponseWriter, *Request) // ① 给函数签名起个类型名
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r) // ② 给这个类型加个方法:调用自己
}
拆开看:① Go 允许给任何类型定义方法,包括函数类型——HandlerFunc 就是「签名为 func(ResponseWriter, *Request) 的函数」这个类型的名字。② 给它加一个 ServeHTTP 方法,方法体就是「调用接收者自己」。于是 HandlerFunc 满足了 Handler 接口。
所以下面两行完全等价:
// internal/handler/post_handler.go
mux.HandleFunc("GET /api/posts/{id}", handleGetPost) // 语法糖
mux.Handle("GET /api/posts/{id}", http.HandlerFunc(handleGetPost)) // 糖化开的样子
http.HandlerFunc(handleGetPost) 是类型转换,不是函数调用——把一个普通函数转成 HandlerFunc 类型,它就白捡了一个 ServeHTTP 方法。零运行时开销。
易错点:http.HandlerFunc(大写 F,类型)和 mux.HandleFunc(方法)长得几乎一样但完全是两个东西。写错了报错通常是 cannot use handleGetPost (value of type func(...)) as http.Handler value——看到这个就是把该转换的地方漏了。
3.3 ServeMux:Go 1.22+ 的新路由语法
版本警告:下面的语法需要 Go ≥ 1.22,且 go.mod 里的 go 指令必须 ≥ 1.22。网上大量教程说「标准库路由太弱,必须上 gorilla/mux 或 chi」——那是 1.21 时代的结论,现在不成立了。
模式(pattern)的完整形式是 [METHOD ][HOST]/[PATH]:
// internal/handler/post_handler.go
mux.HandleFunc("GET /api/posts", handleListPosts) // 方法 + 精确路径
mux.HandleFunc("POST /api/posts", handleCreatePost) // 同一路径,不同方法,各自注册
mux.HandleFunc("GET /api/posts/{id}", handleGetPost) // 路径参数
mux.HandleFunc("GET /static/{path...}", handleStatic) // 尾部通配,能匹配多段
mux.HandleFunc("GET /admin/{$}", handleAdminHome) // {$} = 只匹配这个路径本身
mux.HandleFunc("/", handleNotFound) // 无方法前缀 = 匹配所有方法
取路径参数用 r.PathValue("id"),返回 string,永远不会 panic——参数不存在就返回 ""。
优先级规则:更具体的模式赢,与注册顺序无关。「具体」的定义是:如果模式 A 匹配的路径集合是 B 的真子集,A 更具体。
下面是实测结果(用四条模式跑九个路径):
| 请求路径 | 命中 | 说明 |
|---|---|---|
/api/posts/latest |
GET /api/posts/latest |
字面量赢过 {id} |
/api/posts/42 |
GET /api/posts/{id} |
id="42" |
/api/posts/ |
404 | {id} 不匹配空段 |
/api/posts/42/ |
404 | 尾部多一个 / 就不匹配了 |
/api/posts/a/b |
404 | {id} 只吃一段 |
/static/css/app.css |
GET /static/{path...} |
path="css/app.css",吃多段 |
/static/ |
GET /static/{path...} |
path="",能匹配 |
/files/x/y |
GET /files/ |
以 / 结尾 = 匹配整个子树 |
/exact/ |
GET /exact/{$} |
{$} 只匹配这一个路径 |
易错点 1:注册冲突会在启动时 panic
同时注册 GET /a/{x} 和 GET /{y}/b,进程一启动就崩:
panic: pattern "GET /{y}/b" conflicts with pattern "GET /a/{x}":
GET /{y}/b and GET /a/{x} both match some paths, like "/a/b".
But neither is more specific than the other.
根因:两个模式都能匹配 /a/b,但谁也不是谁的子集,ServeMux 无法决定优先级。正确写法:panic 信息里已经给了冲突样例路径,按业务意图给其中一个补上更具体的字面量段。这是好事——冲突在启动时暴露,不会变成线上的随机路由。
易错点 2:方法不匹配返回 405,但你的 / 兜底会把它吃掉
实测,只注册了 GET /api/posts/{id} 时:
DELETE /api/posts/1 → 405 Allow: GET, HEAD body: "Method Not Allowed"
GET /nope → 404 body: "404 page not found"
HEAD /api/posts/1 → 200 ← HEAD 自动匹配 GET 模式
ServeMux 自动处理了 405 并填好 Allow 头。但一旦你为了统一 JSON 错误响应注册了 mux.HandleFunc("/", handleNotFound):
DELETE /api/posts/1 → 404 Allow: "" body: {"code":"NOT_FOUND",...}
错误现象:所有方法不匹配的请求都变成 404,Allow 头消失,前端分不清「路径不存在」和「方法用错了」。
根因:/ 是一个能匹配所有路径的合法模式,它匹配上了,ServeMux 就不再走「路径匹配但方法不匹配 → 405」那条路。
正确写法:给每个资源路径显式补一条无方法前缀的兜底:
// internal/handler/post_handler.go
mux.HandleFunc("GET /api/posts/{id}", handleGetPost)
mux.HandleFunc("/api/posts/{id}", handleMethodNotAllowed) // 无方法前缀 = 更不具体
无方法前缀的模式比带方法的不具体,所以 GET 仍走上面那条,其他方法落到下面这条(实测:GET → 200,DELETE/PATCH → 405,/nope 仍走 / 兜底的 404,且不会触发冲突 panic)。这是练习 2 要你做的事。
易错点 3:PathValue 会自动 URL 解码
实测:GET /p/hello%20go 拿到 slug = "hello go";GET /p/a%2Fb 拿到 slug = "a/b"——一个带斜杠的值出现在单段参数里。如果你拿这个值去拼文件路径,就是一个路径穿越漏洞。正确写法:路径参数拿到手先校验(本课 handleGetPost 用 strconv.ParseInt 就自带了这层保护)。
3.4 *http.Request:只讲你会用到的
| 成员 | 类型 | 说明 |
|---|---|---|
r.Method |
string |
"GET" / "POST"。用了新路由语法后基本不用手判 |
r.URL.Query() |
url.Values |
查询参数。每次调用都重新解析一遍 URL,循环里别反复调 |
r.PathValue("id") |
string |
路径参数,Go 1.22+ |
r.Body |
io.ReadCloser |
请求体。只能读一次 |
r.Header.Get("X") |
string |
请求头,大小写不敏感 |
r.Context() |
context.Context |
客户端断开时会被取消。第 3 课传给 SQL 查询,第 5 课用来传用户身份 |
r.URL.Query() 的三种取法:
// internal/handler/post_handler.go
q := r.URL.Query() // 先存下来,别写 r.URL.Query().Get(...) 三次
q.Get("page") // 取一个值;键不存在返回 ""(和「传了空值」无法区分)
q.Has("page") // Go 1.17+:区分「没传」和「传了空值」
q["tag"] // []string,取 ?tag=go&tag=web 的全部值
实测 ?page=2&tag=go&tag=web&empty=:Get("page")="2"、Get("nope")=""、Get("empty")=""、Has("nope")=false、q["tag"]=[go web]。
r.Body 的两条铁律:
- 只能读一次。实测:第一次
io.ReadAll(r.Body)拿到 13 字节,第二次拿到 0 字节。用json.Decoder解码之后再读也是 0——它是一个流,读完就没了。想读两次必须自己buf, _ := io.ReadAll(r.Body)存下来,再用io.NopCloser(bytes.NewReader(buf))塞回去。 - 必须限制大小。不限的话,一个 10 GB 的 POST 就能把你的服务撑爆。用
http.MaxBytesReader—— 见 4.2。
r.Body.Close() 在服务端不用你写:http.Server 会在 handler 返回后自动关。需要你手动 defer resp.Body.Close() 的是客户端的 http.Response.Body,别搞混。
3.5 http.ResponseWriter:顺序铁律
接口也只有三个方法:Header() 拿到可写的响应头 map、Write([]byte) 写响应体、WriteHeader(int) 写状态码。
铁律:Header().Set(...) 必须在 WriteHeader(...) 之前;WriteHeader 只能调一次;第一次 Write 会隐式触发 WriteHeader(200)。
违反后果实测(在真实 http.Server 上跑,不是 httptest.Recorder——后者的行为不一样,会让你误以为没问题):
// internal/handler/response.go(错误示范)
w.Write([]byte("body first\n")) // ← 这里已经隐式发了 200
w.Header().Set("X-Too-Late", "1") // 太晚了,头已经发走
w.WriteHeader(http.StatusInternalServerError) // 太晚了
错误现象:客户端收到的是 200,X-Too-Late 头不存在;服务端日志里多一行 http: superfluous response.WriteHeader call from ...。
根因:HTTP 响应是「状态行 → 头 → 空行 → 体」的顺序流。第一个字节的响应体一旦写出去,状态码和头就已经在网络上了,改不回来。
正确写法:永远按 Header().Set → WriteHeader → Write 三步走,这就是 4.1 的 writeJSON 存在的全部理由——把顺序封进一个函数,让你没机会写错。
这个坑的典型翻车姿势:handler 里先 writeJSON(w, 200, data) 成功了,后面某个分支又 writeJSON(w, 500, err)。第二次调用的状态码被丢弃,但响应体会被追加,客户端收到两个 JSON 拼在一起、状态码 200。所以每个分支写完响应必须立刻 return。
顺带一提:如果你不设 Content-Type,Go 会从响应体前 512 字节自动嗅探。JSON 会被嗅探成 text/plain; charset=utf-8,前端 fetch().json() 多半仍能解析,但 Content-Type 敏感的客户端会拒绝。别赌,手动设。
3.6 JSON:Marshal vs Encoder
json.Marshal / Unmarshal |
json.NewEncoder(w) / NewDecoder(r) |
|
|---|---|---|
| 输入输出 | []byte |
io.Writer / io.Reader(流) |
| 内存 | 整个结果一次性在内存里 | 边编码边写,不留完整副本 |
| 错误发生时 | 还没写出任何字节,可以改状态码 | 可能已经写出半个 JSON,状态码改不了了 |
| 额外能力 | — | Decoder 有 DisallowUnknownFields()、可连续解多个对象 |
| 输出细节 | 不带尾部换行 | Encode 自动加 \n |
选型结论:
- 响应用
Marshal:先在内存里编码成功,再决定状态码、写头、写体。响应体大到值得流式(大文件导出)时才换Encoder。这就是 4.1writeJSON用Marshal的理由。 - 请求用
Decoder:因为要DisallowUnknownFields(),而Unmarshal没有这个开关。
3.7 struct tag 与导出规则
// internal/handler/post_handler.go
type PostDTO struct {
ID int64 `json:"id"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"` // 零值时整个字段消失
Content string `json:"-"` // 永远不进 JSON(比如密码 hash)
PublishedAt *time.Time `json:"published_at"` // nil → null
internalKey string // 未导出:json 完全看不见
}
四种写法的效果:
| tag | 序列化行为 |
|---|---|
json:"id" |
字段名用 id。不写 tag 的话用 Go 字段名 ID(大写!前端多半不想要) |
json:"summary,omitempty" |
值是零值("" / 0 / nil / 空 slice)时,整个键不出现 |
json:"-" |
永不序列化,也不反序列化 |
| 无 tag + 小写字段名 | 完全不参与 JSON,静默 |
新手高频坑:小写字段序列化不出来。实测 json.Marshal(PostDTO{ID: 1, Title: "t", internalKey: "secret"}) 得到 {"id":1,"title":"t","slug":"","content":"","status":0,"published_at":null}——internalKey 消失得无影无踪,没有报错、没有警告。反过来把 {"internalKey":"hack"} 反序列化进去也不报错,只是什么都不写。
根因:encoding/json 靠反射工作,Go 的反射读不到未导出字段(语言级限制,不是 json 包偷懒)。正确写法:所有要进出 JSON 的字段必须首字母大写,用 tag 控制小写的对外名字。这就是第 1 课那条「首字母大写 = 导出」规则的现实后果。
关于 omitempty 的一个陷阱:它判断的是零值,不是「没设置」。Status: 0(草稿)加了 omitempty 就会从 JSON 里消失,前端拿不到 status 字段,只能猜。结论:omitempty 只用在「零值和缺失语义相同」的字段上(summary、可选的 tags),不要用在数字状态、计数、布尔开关上。
用指针区分「没传」和「传了零值」
第 4 课写 PATCH /api/posts/{id} 时会遇到:前端传 {"title": ""} 是「把标题清空」,不传 title 是「别动标题」。用 string 两者都是 "",分不开。用 *string 就分得开——实测:
| 请求体 | Title *string 解出来 |
|---|---|
{} |
nil(没传) |
{"title":""} |
指向 "" 的指针(传了空值) |
{"title":null} |
nil |
注意最后一行:指针区分不了「没传」和「显式传 null」。真要区分得上 json.RawMessage 或 map[string]json.RawMessage。绝大多数 API 不需要,知道边界在哪就行。
3.8 http.Server:为什么不用 ListenAndServe
第 1 课那行 http.ListenAndServe(":8080", mux) 是个便利函数,它内部构造了一个所有超时都是 0(即永不超时)的 http.Server。
生产上这是个洞:一个客户端连上来、发一个字节的请求头然后不说话,这条连接就永远占着(这就是 Slowloris 攻击)。攒够几千条,服务的文件描述符就耗尽了。
// cmd/server/main.go
srv := &http.Server{
Addr: ":8080",
Handler: handler.NewRouter(),
ReadHeaderTimeout: 5 * time.Second, // 读完请求头的上限:防 Slowloris
ReadTimeout: 15 * time.Second, // 读完整个请求(头 + 体)的上限
WriteTimeout: 15 * time.Second, // 从读完头到写完响应的上限:防慢客户端拖死 goroutine
IdleTimeout: 60 * time.Second, // keep-alive 连接的空闲上限:回收闲置连接
MaxHeaderBytes: 1 << 20, // 请求头总大小上限 1 MiB
}
显式构造还有第二个理由:srv 这个变量拿在手里,第 10 课才能调 srv.Shutdown(ctx) 做优雅关闭。ListenAndServe 那种写法连 server 对象都拿不到。
注意:如果你的接口里有 SSE、长轮询或大文件上传,WriteTimeout / ReadTimeout 会把它们掐断。那类接口要单独处理(用 http.ResponseController 按连接调整超时),不能靠全局值。
4. 函数逐个精讲
cd ~/go-blog && mkdir -p internal/handler
4.1 func writeJSON(w http.ResponseWriter, status int, v any) error
签名为什么这么设计:
w放第一个参数:Go 的惯例是「被写入/被操作的对象放前面」,和fmt.Fprintf(w, ...)一致。status放中间而不是最后:读代码时writeJSON(w, 201, post)一眼看出状态码,塞在最后会被长长的数据表达式淹没。v any:any是 Go 1.18 起interface{}的别名,完全等价,只是短。空接口能装任何类型,代价是编译器帮不上忙——传错了要到运行时json.Marshal才报错。- 返回
error而不是内部吞掉:写响应是会失败的(客户端提前断开)。返回给调用方,让它决定记日志还是忽略。
// internal/handler/response.go
package handler
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
// APIError 是所有错误响应的统一形状,前端只需要认这一种结构。
type APIError struct {
Code string `json:"code"` // 机器读:INVALID_ID / POST_NOT_FOUND
Message string `json:"message"` // 人读
Field string `json:"field,omitempty"` // 校验错误才有,其他时候整个键消失
}
// writeJSON 把任意值序列化成 JSON 写回客户端。
// 所有响应都必须走它 —— 它是 3.5 那条顺序铁律的唯一执行点。
func writeJSON(w http.ResponseWriter, status int, v any) error {
// 第一步:先序列化到内存。
//
// 为什么不用 json.NewEncoder(w).Encode(v) 直接写:Encoder 边编码边往 w 写,
// 第一个字节出去的瞬间就隐式发了 200。如果 v 里有字段序列化失败(含 chan、func,
// 或自定义 MarshalJSON 报错),客户端收到的是 "200 + 半个残缺 JSON",
// 而你**没有任何机会**改成 500。先 Marshal 到 []byte,失败了什么都没写出去。
buf, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("writeJSON: 序列化失败: %w", err)
}
// 第二步:设置头,必须在 WriteHeader 之前(3.5)。
// charset=utf-8 显式写出来,省得老客户端猜编码把中文猜成乱码。
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 第三步:写状态码,只能调一次。
w.WriteHeader(status)
// 第四步:写响应体。这里的 err 通常是"客户端已经断开连接"——
// 不是你的 bug,但也不该静默,返回出去由调用方记日志。
if _, err := w.Write(buf); err != nil {
return fmt.Errorf("writeJSON: 写响应体失败: %w", err)
}
return nil
}
// writeError 是 writeJSON 的错误快捷方式。
// 它不返回 error:错误响应写失败时已经没有 plan B 了,直接记日志。
func writeError(w http.ResponseWriter, status int, code, msg string) {
if err := writeJSON(w, status, APIError{Code: code, Message: msg}); err != nil {
log.Printf("writeError: %v", err)
}
}
易错点:writeJSON(w, 200, nil)。json.Marshal(nil) 返回字节串 null,客户端收到的响应体就是四个字母 null,不是 {}。想返回空对象要传 struct{}{} 或 map[string]any{}。
4.2 func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error
先说签名里的一个「反直觉」:解码请求体,为什么参数里有 w?
因为 http.MaxBytesReader(w, r.Body, n) 需要 w——超限时它要通过 w 通知服务器「这条连接别再读了,直接断」。没有 w 就只能用 io.LimitReader,那个超限时只是静默截断,你会得到一个「JSON 语法错误」而不是「请求体太大」,排查时一脸问号。签名的丑陋来自真实约束,这比签名好看重要。
为什么用泛型:不用泛型的话签名是 decodeJSON(w, r, dst any) error,调用方传错东西(比如传了值而不是指针)要到运行时才报 json: Unmarshal(non-pointer)。用 [T any] + dst *T,编译器直接保证拿到的是指针。这是泛型最轻量、收益最直接的一种用法——不是为了抽象,是为了把运行时错误提前到编译期。
// internal/handler/response.go
const maxBodyBytes = 1 << 20 // 1 MiB。博客正文再长也够了
// decodeJSON 把请求体解码进 dst,并把所有可能的失败翻译成人能看懂的中文。
// T 是类型参数(Go 1.18+ 泛型),调用时不用写,编译器从 dst 推导。
func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error {
// 校验 1:Content-Type。用 HasPrefix 而不是 ==:真实请求头常带参数,
// 比如 "application/json; charset=utf-8",用 == 会全部拒掉。
// ct == "" 时放行,兼容不带这个头的简单客户端。
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
return fmt.Errorf("decodeJSON: Content-Type 必须是 application/json,收到 %q", ct)
}
// 校验 2:限制体积,必须在开始读之前包上。
// 注意是把 r.Body 整个换掉,后面所有读操作都会经过这层限制。
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
// DisallowUnknownFields:请求里出现结构体没有的字段就报错。为什么要开:
// 前端把 "title" 拼成 "titel" 时默认行为是**静默忽略**,症状是"我明明传了标题,
// 保存的却是空的",能查一下午。开了它前端立刻收到 400 + 字段名。
// 这是 Decoder 独有的能力,Unmarshal 没有。
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
// errors.As 把各种底层错误翻译成人话。
// 每个 var 都要先声明再传 &:As 需要一个可写的目标。
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
var maxErr *http.MaxBytesError
switch {
case errors.As(err, &syntaxErr):
return fmt.Errorf("decodeJSON: JSON 语法错误,位于第 %d 字节", syntaxErr.Offset)
case errors.As(err, &typeErr):
// typeErr.Field 是出错的字段名,Type 是期望的类型 —— 直接告诉前端哪错了
return fmt.Errorf("decodeJSON: 字段 %q 类型错误,期望 %s", typeErr.Field, typeErr.Type)
case errors.As(err, &maxErr):
return fmt.Errorf("decodeJSON: 请求体超过 %d 字节", maxErr.Limit)
case errors.Is(err, io.EOF):
// 空 body。默认报的是 "EOF",对前端毫无信息量
return errors.New("decodeJSON: 请求体为空")
default:
return fmt.Errorf("decodeJSON: %w", err)
}
}
// 校验 3:确认后面没有第二个 JSON 对象。Decoder 是流式的,
// '{"a":1}{"b":2}' 只解第一个就返回成功,第二个被静默丢弃。
// 再 Decode 一次,期望拿到 EOF —— 拿不到就说明有多余内容。
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("decodeJSON: 请求体只能包含一个 JSON 对象")
}
return nil
}
(import 补上 errors、io、strings。)
4.3 func handleListPosts(w http.ResponseWriter, r *http.Request)
签名为什么固定成这样:因为它要能被 http.HandlerFunc 转换(见 3.2)。handler 的签名没有自由度——需要额外依赖(数据库、service)时不能加参数,只能靠闭包或方法接收者带进来。第 4 课会把它改成 func (h *PostHandler) List(w, r),h 里装着 service。现在先用包级假数据。
// internal/handler/post_handler.go
const (
defaultPageSize = 10
maxPageSize = 100
)
func handleListPosts(w http.ResponseWriter, r *http.Request) {
// 存下来。r.URL.Query() 每次调用都重新解析一遍 URL 字符串,
// 写三次 r.URL.Query().Get(...) 就解析三次。
q := r.URL.Query()
// 分页参数:不传给默认值,传了必须是合法整数。
page, err := intParam(q.Get("page"), 1)
if err != nil || page < 1 {
writeError(w, http.StatusBadRequest, "INVALID_PARAM", "page 必须是 >= 1 的整数")
return // ← 写完响应必须立刻 return,见 3.5
}
size, err := intParam(q.Get("size"), defaultPageSize)
if err != nil || size < 1 {
writeError(w, http.StatusBadRequest, "INVALID_PARAM", "size 必须是 >= 1 的整数")
return
}
// 超上限**截断**而不是报错:前端传 size=99999 时给它 100 条比返回 400 友好。
// 但不设上限就等于把 "SELECT 全表" 的开关交给了客户端。
if size > maxPageSize {
size = maxPageSize
}
// TODO(练习1): 支持 ?status=0|1|2 过滤,非法值返回 400
items, total := listFromStore(page, size)
resp := ListResponse{
Items: items,
Page: page,
Size: size,
Total: total,
HasMore: page*size < total,
}
// 关键一行:nil slice 序列化成 JSON 的 null 而不是 [],
// 前端写 data.items.map(...) 会直接崩在 "null is not iterable"。
if resp.Items == nil {
resp.Items = []PostDTO{}
}
if err := writeJSON(w, http.StatusOK, resp); err != nil {
log.Printf("handleListPosts: %v", err)
}
}
// intParam 把查询参数转成 int,空值给默认值。
// 抽出来是因为 page 和 size 逻辑一样 —— 两处重复就该抽,不用等三处。
func intParam(raw string, def int) (int, error) {
if raw == "" {
return def, nil
}
return strconv.Atoi(raw)
}
易错点:nil slice → null。这是 Go 后端和 JS 前端之间最高频的摩擦。实测:json.Marshal 一个 nil slice 得到 null,json.Marshal([]PostDTO{}) 才得到 []。防御写法:构造 slice 一律用 make([]T, 0, n),别用 var。
4.4 func handleGetPost(w http.ResponseWriter, r *http.Request)
// internal/handler/post_handler.go
func handleGetPost(w http.ResponseWriter, r *http.Request) {
// PathValue 的键必须和路由模式里的 {id} 完全一致(大小写敏感)。
// 写错了不报错,返回空字符串 —— 然后你会得到一个莫名其妙的 400。
raw := r.PathValue("id")
// ParseInt 而不是 Atoi:Post.ID 是 int64,Atoi 返回 int。64 位机器上一样宽,
// 但 ParseInt(raw, 10, 64) 才是与目标类型对齐的写法,换平台不会静默溢出。
// id <= 0 也要挡:ParseInt("-5") 合法,但没有 id 为负的文章。
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "INVALID_ID", "id 必须是正整数")
return
}
p, ok := findInStore(id)
if !ok {
// 404 用 Not Found 而不是 200 + {"data":null}:
// HTTP 状态码是给机器看的,缓存、监控、客户端重试逻辑都依赖它。
writeError(w, http.StatusNotFound, "POST_NOT_FOUND",
fmt.Sprintf("文章 %d 不存在", id))
return
}
if err := writeJSON(w, http.StatusOK, p); err != nil {
log.Printf("handleGetPost: %v", err)
}
}
关于 400 还是 404:/api/posts/abc 返回 400(参数格式错),/api/posts/999 返回 404(格式对但不存在)。分开的好处是前端能区分「我的代码拼错了 URL」和「这篇文章被删了」。
4.5 func handleCreatePost(w http.ResponseWriter, r *http.Request)
// internal/handler/post_handler.go
// CreatePostRequest 是**入参专用**结构体,和 PostDTO(出参)分开。
// 为什么不复用一个:入参不该让客户端设 ID、ViewCount、CreatedAt。复用的话,
// 前端 POST 一个 {"id":999,"view_count":100000} 你就得逐字段防。这个问题在
// Rails/Laravel 里叫 mass assignment 漏洞,Go 用独立结构体从源头消灭它。
type CreatePostRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Summary *string `json:"summary"` // 指针:区分"没传"和"传了空字符串",见 3.7
}
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
var req CreatePostRequest
// 泛型参数 T 由 &req 推导出来,不用写 decodeJSON[CreatePostRequest](...)
if err := decodeJSON(w, r, &req); err != nil {
// 400 = 请求本身就不是合法 JSON / 太大 / 字段类型不对
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
return
}
// 校验和解码分开:400 是"我读不懂你说的话",422 是"话听懂了,但内容不合法"。
// 前端据此决定是"我代码的 bug"还是"该给用户弹表单错误提示"。
req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
writeError(w, http.StatusUnprocessableEntity, "VALIDATION_FAILED", "title 不能为空")
return
}
if strings.TrimSpace(req.Content) == "" {
writeError(w, http.StatusUnprocessableEntity, "VALIDATION_FAILED", "content 不能为空")
return
}
// TODO(练习3): 这里应该调 model.NewPost(req.Title, req.Content),
// 把第 1 课写的校验复用起来,而不是在 handler 里重复写
p := saveToStore(req)
// Location 头指向新建资源,这是 201 的标准配套。
// 必须在 writeJSON 之前设 —— writeJSON 里会 WriteHeader,之后再设就晚了(3.5)。
w.Header().Set("Location", fmt.Sprintf("/api/posts/%d", p.ID))
// 201 Created 而不是 200:告诉客户端"新资源产生了"。
if err := writeJSON(w, http.StatusCreated, p); err != nil {
log.Printf("handleCreatePost: %v", err)
}
}
4.6 func NewRouter() *http.ServeMux
签名为什么这么设计:返回 *http.ServeMux 而不是 http.Handler——现在返回具体类型,调用方能继续 mux.HandleFunc 追加路由(测试里很有用);第 5 课加中间件后会改成返回 http.Handler,因为那时返回的已经是包了几层的东西。叫 NewRouter 而不是 Setup——NewXxx 是构造函数惯例,一眼知道它返回新对象而不是改全局状态。无参数——现在没依赖;第 4 课会变成 NewRouter(h *PostHandler) http.Handler,依赖注入在 Go 里就是"多一个参数",没有容器、没有注解。
// internal/handler/post_handler.go
// NewRouter 集中注册所有路由。
// 集中的价值:想知道这个服务有哪些接口,看这一个函数就够了。
func NewRouter() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", handleHealth)
// 同一路径、不同方法分开注册。
// 对比 Go 1.22 之前的写法:只能注册 "/api/posts",然后在 handler 里
// switch r.Method {case "GET": ... case "POST": ...},
// 每个 handler 开头都得来一段样板。现在路由层就分掉了。
mux.HandleFunc("GET /api/posts", handleListPosts)
mux.HandleFunc("POST /api/posts", handleCreatePost)
mux.HandleFunc("GET /api/posts/{id}", handleGetPost)
// TODO(练习4): 加 PATCH /api/posts/{id} 和 DELETE /api/posts/{id}
// 兜底:把标准库的纯文本 404 换成统一 JSON。
// 代价见 3.3 易错点 2 —— 它会吃掉自动的 405。
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "NOT_FOUND", "路由不存在: "+r.URL.Path)
})
return mux
}
cmd/server/main.go 换成 3.8 那段显式 http.Server 构造,Handler 字段填 handler.NewRouter()。
5. 跑起来验证
cd ~/go-blog && gofmt -l . && go vet ./... && go run ./cmd/server
另开一个终端,逐条跑。下面每一条的输出都是实测结果。
① 列表 + 分页 + 单篇 + 404 + 400
curl -s localhost:8080/api/posts
curl -s "localhost:8080/api/posts?size=2&page=1" | grep -o '"has_more":[a-z]*'
curl -s localhost:8080/api/posts/1
curl -s -i localhost:8080/api/posts/999 | head -1
curl -s localhost:8080/api/posts/999
curl -s localhost:8080/api/posts/abc
{"items":[{"id":1,...},{"id":2,"title":"database/sql 入门","slug":"database-sql-101","content":"连接池不是你想的那样。","status":1,"published_at":"2026-09-02T10:00:00Z"},{"id":3,...}],"page":1,"size":10,"total":3,"has_more":false}
"has_more":true
{"id":1,"title":"Hello Go","slug":"hello-go","summary":"Go 的第一课","content":"# Hello Go","status":1,"published_at":"2026-09-01T10:00:00Z"}
HTTP/1.1 404 Not Found
{"code":"POST_NOT_FOUND","message":"文章 999 不存在"}
{"code":"INVALID_ID","message":"id 必须是正整数"}
看第二篇文章(database-sql-101)没有 summary 键——它的 summary 是 "",被 omitempty 吃掉了;而第三篇的 published_at 为 nil 时是 null 而不是消失,因为它没加 omitempty。这就是 3.7 那两种行为的实物对照。分页那条把 page 改成 2 再跑,has_more 变成 false。
② 创建
curl -s -i -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Go 路由到底怎么匹配","content":"ServeMux 1.22 新语法","summary":"精确度优先"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/posts/4
Content-Length: 153
{"id":4,"title":"Go 路由到底怎么匹配","slug":"go","summary":"精确度优先","content":"ServeMux 1.22 新语法","status":0,"published_at":null}
三个点对照着看:201、Location: /api/posts/4、Content-Type 是我们手动设的(不是嗅探出来的)。
注意 "slug":"go" —— 这是练习 3 要修的 bug:假数据仓库里用的是纯 ASCII 版 slugify,把中文全丢了。第 1 课 model.Slugify() 的输出会是 go-路由到底怎么匹配。
③ 六种坏请求,逐个看 decodeJSON 的翻译
B=localhost:8080/api/posts; H='Content-Type: application/json'
curl -s -X POST $B -H "$H" -d '{"title":"a","content":"b","titel":"typo"}'
curl -s -X POST $B -H "$H" -d '{"title":123,"content":"b"}'
curl -s -X POST $B -H "$H" -d '{"title":"a",}'
curl -s -X POST $B -H "$H" -d ''
curl -s -X POST $B -H "$H" -d '{"title":"a","content":"b"}{"title":"c"}'
curl -s -X POST $B -d '{"title":"a","content":"b"}'
{"code":"BAD_REQUEST","message":"decodeJSON: json: unknown field \"titel\""}
{"code":"BAD_REQUEST","message":"decodeJSON: 字段 \"title\" 类型错误,期望 string"}
{"code":"BAD_REQUEST","message":"decodeJSON: JSON 语法错误,位于第 14 字节"}
{"code":"BAD_REQUEST","message":"decodeJSON: 请求体为空"}
{"code":"BAD_REQUEST","message":"decodeJSON: 请求体只能包含一个 JSON 对象"}
{"code":"BAD_REQUEST","message":"decodeJSON: Content-Type 必须是 application/json,收到 \"application/x-www-form-urlencoded\""}
第一条最值钱:titel 这个拼写错误被当场抓住了。不开 DisallowUnknownFields 的话,这个请求会成功,创建出一篇 title 为空的文章。
最后一条是 curl -d 不带 -H 时默认发 application/x-www-form-urlencoded 造成的——这也是你自己 debug 时最常见的「为什么我的 POST 不生效」。
④ 请求体过大 + 校验失败
python3 -c "import json;print(json.dumps({'title':'x','content':'y'*2000000}))" > /tmp/big.json
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' --data-binary @/tmp/big.json
curl -s -i -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":" ","content":"b"}' | head -1
{"code":"BAD_REQUEST","message":"decodeJSON: 请求体超过 1048576 字节"}
HTTP/1.1 422 Unprocessable Entity
第二条是 422 不是 400:JSON 本身合法(读懂了),只是内容不合业务规则。
⑤ 亲眼看 405 被兜底吃掉
curl -s -i -X DELETE localhost:8080/api/posts/1 | head -3
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
现在把 NewRouter 里 mux.HandleFunc("/", ...) 那三行注释掉,重启,再跑同一条命令:
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
这就是 3.3 易错点 2 的现场。改完记得把兜底加回来(或按练习 2 修好)。
6. TODO 练习
练习 1:给列表加状态过滤
在 handleListPosts 里支持 ?status=0|1|2。
验收标准:
curl -s "localhost:8080/api/posts?status=1" | grep -o '"total":[0-9]*' # "total":2
curl -s "localhost:8080/api/posts?status=0" | grep -o '"total":[0-9]*' # "total":1
curl -s "localhost:8080/api/posts?status=9" | head -c 60 # 400 + INVALID_PARAM
curl -s "localhost:8080/api/posts" | grep -o '"total":[0-9]*' # "total":3(不传=不过滤)
提示:q.Get("status") 返回 "" 表示没传。用 q.Has("status") 还是 != "" 区分?想想 ?status= 这种请求该怎么处理,两种都说得通,选一个并在注释里写明理由。
练习 2:让 405 回到统一 JSON 格式
按 3.3 易错点 2 给的思路,给 /api/posts 和 /api/posts/{id} 各补一条无方法前缀的兜底路由,返回 JSON 格式的 405。
验收标准:
curl -s -i -X DELETE localhost:8080/api/posts/1 | head -1 # HTTP/1.1 405 Method Not Allowed
curl -s -X DELETE localhost:8080/api/posts/1 # {"code":"METHOD_NOT_ALLOWED",...}
curl -s -i localhost:8080/api/posts/1 | head -1 # HTTP/1.1 200 OK(GET 不受影响)
curl -s -i localhost:8080/nope | head -1 # HTTP/1.1 404 Not Found(兜底还在)
别忘了手动设 Allow 头——405 响应不带 Allow 是违反 HTTP 规范的。
练习 3:接回第 1 课的 model.NewPost
handleCreatePost 现在在 handler 里重复写校验,而第 1 课的 model.NewPost 已经有了更好的版本(还会生成中文 slug)。改成调用它,并用 errors.As 把 *model.ValidationError 里的 Field 填进 APIError.Field。
验收标准:
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":"Go 路由到底怎么匹配","content":"x"}' | grep -o '"slug":"[^"]*"'
# 期望:"slug":"go-路由到底怎么匹配"(不再是 "go")
curl -s -X POST localhost:8080/api/posts -H 'Content-Type: application/json' \
-d '{"title":"标题","content":" "}'
# 期望:{"code":"VALIDATION_FAILED","message":"...","field":"content"} —— field 键必须出现
提示:APIError.Field 加了 omitempty,所以只有校验错误才会出现这个键,别的错误响应形状不变。
练习 4:PATCH /api/posts/{id} —— 用指针区分「没传」和「传零值」
新增 UpdatePostRequest,三个字段全用指针(*string / *int8),nil 表示「别动这个字段」。
验收标准:
# 只改标题,summary 和 status 不动
curl -s -X PATCH localhost:8080/api/posts/1 -H 'Content-Type: application/json' \
-d '{"title":"改过的标题"}'
# 期望:summary 仍是 "Go 的第一课",status 仍是 1
# 显式把 summary 清空
curl -s -X PATCH localhost:8080/api/posts/1 -H 'Content-Type: application/json' \
-d '{"summary":""}'
# 期望:响应里 summary 键消失(omitempty 生效),且 title 保持上一步改过的值
# 空 body
curl -s -i -X PATCH localhost:8080/api/posts/1 -H 'Content-Type: application/json' -d '{}' | head -1
# 期望:200,什么都没改(或者你也可以设计成 400,但要在注释里写明理由)
这道题是本课的压轴:做完你就完全理解了 3.7 那张「{} / {"title":""} / {"title":null}」对照表为什么重要。
7. 自检清单
- 我能默写出
http.Handler接口的定义,并说出「中间件链」为什么能无限套 - 我能解释
http.HandlerFunc(f)是类型转换而不是函数调用,以及它凭什么有ServeHTTP方法 - 我知道
mux.HandleFunc("GET /posts/{id}", h)需要 Go ≥ 1.22,且go.mod的版本号也要够 - 我能说出
/api/posts/、/api/posts/42/、/api/posts/a/b为什么都匹配不上GET /api/posts/{id} - 我知道路由冲突会在启动时 panic,而不是变成线上的随机行为
- 我知道注册
mux.HandleFunc("/", ...)会吃掉标准库自动的 405 +Allow头 - 我知道
r.Body只能读一次,且必须用http.MaxBytesReader限大小 - 我能说出为什么
w.Header().Set必须在w.WriteHeader之前,以及违反后客户端会收到什么 - 我知道每个 handler 分支写完响应必须立刻
return,否则响应体会被追加 - 我知道响应用
Marshal(错了还能改状态码)、请求用Decoder(能开DisallowUnknownFields) - 我知道未导出字段不会被序列化,而且不报错
- 我知道
omitempty判断的是零值,不能用在status这种数字状态字段上 - 我知道 nil slice 序列化成
null不是[],会让前端.map()崩掉 - 我能用
*string区分「JSON 里没传」和「传了空字符串」,也知道它区分不了「显式传null」 - 我能说出
ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout各防什么 - 第 5 节所有 curl 命令我都跑过,输出和文档一致
下一课:把 store 那个内存 slice 换成真的 MySQL。你会遇到 Go 后端最经典的一道坎——数据库里的 NULL 怎么塞进 Go 的 string(第 1 课埋的 sql.NullString 伏笔到期),以及 db.Query 忘了 rows.Close() 会怎么让连接池悄悄干死你的服务。