13.1 Handler、ServeMux 与路由
前面十二章,TaskAPI 一直活在你的终端里:命令行解析参数、后台 worker 处理任务、Ctrl-C 优雅退出。它能干活,却没法被别的程序调用。本节开始,我们给它装上一扇 HTTP 门面——这是绝大多数后端服务的标准入口,也是把「一个 Go 程序」变成「一个服务」的分水岭。
本节把 TaskAPI 推进到:用标准库
net/http暴露 REST 风格的/tasks路由,实现列表、创建、查询、更新、删除五个端点,为下一节的 JSON 编解码和再下一节的中间件打底。
13.1.1 一切从 http.Handler 开始
Go 的 HTTP 服务抽象只有一句话:接收请求,写回响应。它的载体是一个只有一个方法的接口:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
http.ResponseWriter 是你写响应的地方,*http.Request 是读请求的地方。任何类型,只要实现了 ServeHTTP,就是一个合法的处理器。这个接口小到极致,正是 Go 接口哲学的样本:先定义需求,再让类型去满足它。
每次都写一个 struct 太啰嗦,于是标准库提供了适配器 http.HandlerFunc,让普通函数也能当 Handler:
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
})
http.HandlerFunc 是一个函数类型,它自己实现了 ServeHTTP,在方法里调用函数本身。这是一个「让函数冒充接口」的经典技巧,你在第 5 章学过接口,在第 8 章写过 fake,这里是它在标准库里的真实应用。
13.1.2 ServeMux:路由表
有了处理器,还需要一张「哪个 URL 交给哪个处理器」的表。这就是 http.ServeMux——多路复用器,俗称路由器。
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
ServeMux 本身也是一个 http.Handler(它实现了 ServeHTTP),所以可以把它交给 http.Server,也可以被别的 Handler 包起来。这种「处理器套处理器」的组合能力,是下一节中间件的机制基础。
Go 1.22 给 ServeMux 引入了方法感知模式,模式字符串从「路径」升级为「[METHOD ]路径」:
mux.HandleFunc("GET /tasks", listTasks)
mux.HandleFunc("POST /tasks", createTask)
这条特性把过去要靠第三方路由库才能做的事,收回了标准库。卷一的原则是只用标准库,所以 TaskAPI 正好赶上这趟车。
13.1.3 路径参数与通配符
旧版 ServeMux 只有前缀匹配,取不到 /tasks/42 里的 42。新模式下,用 {name} 声明通配段,再用 r.PathValue(name) 取回:
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // "/tasks/42" -> "42"
_, _ = w.Write([]byte("id=" + id))
})
模式语法里几个要记住的点:
| 模式 | 含义 | 匹配示例 |
|---|---|---|
/tasks | 精确匹配该路径 | /tasks |
/tasks/ | 以 /tasks/ 开头的子树 | /tasks/, /tasks/42 |
/tasks/{id} | 单个路径段作为参数 | /tasks/42 |
/tasks/{rest...} | 剩余整段(可含 /) | /tasks/a/b/c |
GET /tasks | 限定方法 | 只匹配 GET |
/ | 兜底,匹配一切未命中的路径 | 任意 |
注意 {id} 只匹配一个路径段,不会跨 /;要跨段得用 {rest...}。r.PathValue 返回的永远是字符串,转数字要自己 strconv.ParseInt。
13.1.4 405 与 404:让标准库替你处理
方法感知模式带来一个好处:当路径存在但方法不对时,ServeMux 会自动回 405,并带上 Allow 头,无需你手写。我们来实测确认:
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", listTasks)
// 用 httptest 起一个真实的临时服务器
srv := httptest.NewServer(mux)
defer srv.Close()
req, _ := http.NewRequest("PATCH", srv.URL+"/tasks", nil)
resp, _ := http.DefaultClient.Do(req)
fmt.Println(resp.StatusCode) // 405
实测结果:PATCH /tasks 返回 405 Method Not Allowed。而请求一个完全不存在的路径,返回 404。这两件事你一行判别代码都不用写,标准库在路由层就完成了。
一个必须知道的坑:模式冲突会在注册时 panic。比如同时注册 GET /tasks/{id} 和 GET /tasks/{name},两者语义重叠,ServeMux 会在启动阶段直接崩溃。这其实是好事——把配置错误暴露在启动时,而不是等某个请求打进来才 500。
13.1.5 把 TaskAPI 的 REST 路由装上去
现在把前面学到的东西组装成一个能跑的 TaskAPI HTTP 层。为了聚焦路由本身,这里先用一个内存 map 存任务,数据库留到第 14 章:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type API struct {
tasks map[int64]Task
nextID int64
}
func NewAPI() *API { return &API{tasks: map[int64]Task{}, nextID: 1} }
func (a *API) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", a.list)
mux.HandleFunc("POST /tasks", a.create)
mux.HandleFunc("GET /tasks/{id}", a.get)
mux.HandleFunc("PUT /tasks/{id}", a.update)
mux.HandleFunc("DELETE /tasks/{id}", a.delete)
return mux
}
五个端点的处理器各自负责一小块逻辑。先看列表与创建:
func (a *API) list(w http.ResponseWriter, r *http.Request) {
out := make([]Task, 0, len(a.tasks))
for _, t := range a.tasks {
out = append(out, t)
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) create(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if strings.TrimSpace(in.Title) == "" {
writeErr(w, http.StatusBadRequest, "title required")
return
}
t := Task{ID: a.nextID, Title: in.Title}
a.nextID++
a.tasks[t.ID] = t
writeJSON(w, http.StatusCreated, t)
}
创建成功回 201 Created,这是 REST 的约定:新资源产生了。接着是按 ID 查询、更新与删除,注意每个都用 r.PathValue("id") 取参数并转成 int64:
func (a *API) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
t, ok := a.tasks[id]
if !ok {
writeErr(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (a *API) update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
t, ok := a.tasks[id]
if !ok {
writeErr(w, http.StatusNotFound, "task not found")
return
}
var in struct {
Title string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
t.Title, t.Done = in.Title, in.Done
a.tasks[id] = t
writeJSON(w, http.StatusOK, t)
}
func (a *API) delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
if _, ok := a.tasks[id]; !ok {
writeErr(w, http.StatusNotFound, "task not found")
return
}
delete(a.tasks, id)
w.WriteHeader(http.StatusNoContent)
}
删除成功回 204 No Content——没有响应体,所以不调用 writeJSON,只写状态码。最后是两个小工具函数,把「写 JSON」和「写错误」收敛到一处:
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
13.1.6 用 httptest 验证整条链路
httptest 让你不必真的监听端口就能测 HTTP 处理器。httptest.NewServer 起一个真实的临时服务器(随机端口),httptest.NewRecorder 则完全在内存里跑一次请求:
func main() {
a := NewAPI()
srv := httptest.NewServer(a.Routes())
defer srv.Close()
resp, _ := http.Post(srv.URL+"/tasks", "application/json",
strings.NewReader(`{"title":"写书"}`))
fmt.Println("POST status:", resp.StatusCode) // 201
resp.Body.Close()
resp2, _ := http.Get(srv.URL + "/tasks/999")
fmt.Println("missing status:", resp2.StatusCode) // 404
resp2.Body.Close()
}
实测输出:
POST status: 201
missing status: 404
NewRecorder 版本更适合单元测试,因为它不需要网络:
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/tasks", nil)
a.Routes().ServeHTTP(rec, req)
fmt.Println(rec.Code) // 200
httptest.NewRequest 构造的是 *http.Request,ServeHTTP 直接调用路由器,rec.Code 与 rec.Body 就是你断言的对象。第 8 章讲过表驱动测试,把这套 NewRecorder 逻辑放进 for range 循环,就是一份标准的路由测试。
13.1.7 把服务器真正跑起来
最后一步,把 mux 交给 http.Server。生产环境务必设置各类超时——裸 http.ListenAndServe 没有任何超时保护,一个慢连接就能拖住服务:
srv := &http.Server{
Addr: ":8080",
Handler: a.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
四个超时各有分工,用一张表记住它们:
| 字段 | 约束的阶段 | 典型值 |
|---|---|---|
ReadHeaderTimeout | 读完请求头 | 5s |
ReadTimeout | 读完整个请求(含 body) | 10s |
WriteTimeout | 写完响应 | 15s |
IdleTimeout | keep-alive 空闲等待 | 60s |
ListenAndServe 正常关闭时返回 http.ErrServerClosed,所以要用 errors.Is 把它排除掉——这正是第 6 章 errors.Is 判别的实战应用。
至于如何响应 SIGTERM 并调用 srv.Shutdown(ctx) 优雅排空连接,第 12.3 节已经完整讲过,这里不再重复,只提醒一句:Shutdown 不打断进行中的请求,Close 才会。生产用 Shutdown。
13.1.8 小结
本节把 TaskAPI 从「命令行程序」升级成了「HTTP 服务」:
http.Handler是唯一的抽象,HandlerFunc让函数冒充它。ServeMux是路由器,Go 1.22 起支持"GET /tasks/{id}"这种方法感知模式。r.PathValue("id")取路径参数,返回字符串。- 方法不匹配自动 405,路径不存在自动 404,模式冲突启动即 panic。
httptest.NewServer跑真实链路,NewRecorder跑内存单元测试。http.Server的四个超时必须设,关闭时用errors.Is(err, http.ErrServerClosed)放行。
下一节,我们把这里的 writeJSON / writeErr 展开:请求体怎么安全解析、JSON 编解码有哪些默认行为会咬人、响应头与状态码怎么统一管理。如果你只想查 net/http 的 API 速查,见本卷附录 B。
阅读导航:上一节:12.3 优雅退出与信号处理 · 下一节:13.2 请求解析、JSON 与响应 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。