《Go 语言编程入门》13.1 Handler、ServeMux 与路由

本节让 TaskAPI 第一次长出 HTTP 门面。讲清 net/http 的两个核心抽象 http.Handler 与 http.ServeMux,用 Go 1.22 起的方法感知路由把 /tasks 的增删改查挂上,理解 r.PathValue 取路径参数、405/404 的自动行为,并给出用 httptest 验证的完整可运行示例。

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
IdleTimeoutkeep-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 与响应 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练