使用 Go 1.22 的 net/http 编写 REST API

1. 总览

Go 1.22 之前,http.ServeMux 只能按路径匹配。它不能区分 GET 和 POST,也无法读取 /orders/{id} 这样的路径参数。因此许多项目不得不使用额外的 gorilla/mux 或 chi。

从 Go 1.22 开始,ServeMux 支持按方法匹配、通配符和明确的优先级规则。本文指导您仅使用标准库构建一个 Order 管理 REST API。您还将使用 httptest 编写测试,并了解新语法的常见错误。

2. 准备项目

您需要 Go 1.22 或更高版本。go.mod 中的 go 行也必须是 1.22 或更高版本:

mkdir order-api && cd order-api
go mod init example.com/order-api

创建后的 go.mod 文件:

module example.com/order-api

go 1.22

注意: 新的路由行为取决于 go.mod 中的 go 行,而不是您安装的工具链版本。如果 go.mod 写的是 go 1.21,ServeMux 仍然按旧方式运行。那时 "GET /orders/{id}" 会被理解为包含空格的路径,您的请求会收到 404,没有任何错误消息。

本文不需要外部依赖,所以没有 go get 命令。

3. 新的模式语法

3.1. 按方法和路径参数匹配

main.go 中的最小示例:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
		name := r.PathValue("name")
		w.Write([]byte("你好," + name + "\n"))
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

上面的代码做了以下几件事:

  • "GET /hello/{name}":空格前面是方法,后面是路径。{name} 是通配符,恰好匹配一个段。
  • r.PathValue("name") 返回匹配的值,始终是 string 类型。如果通配符名称不存在,函数返回空字符串。

尝试运行:

go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
你好,Lan

HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed

当路径匹配但方法不匹配时,ServeMux 会自动返回 405 并加上 Allow 头。GET 模式也会自动匹配 HEAD。

3.2. 多段通配符和精确匹配

其他两种特殊语法:

mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("文件:" + r.PathValue("path") + "\n"))
})

mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("主页\n"))
})
  • {path...} 必须在模式的末尾。它匹配路径的其余部分,包括 /。对于 /files/docs/2024/report.pdf,值是 docs/2024/report.pdf。
  • {$} 只匹配以 / 结尾的路径。没有它,模式 "/" 会匹配所有路径。这是陌生路由不返回 404 的常见原因。

3.3. 优先级规则

当多个模式都匹配一个请求时,更具体的模式会胜出:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // 在 /orders/latest 时胜出

/orders/latest 比 /orders/{id} 更具体,因为所有与第二个模式匹配的请求都与第一个模式匹配,但反之则不然。注册顺序没有影响。

如果两个模式重叠且没有一个比另一个更具体,Go 会在注册时立即 panic:

mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...

错误出现在程序启动时,而不是处理请求时。这样您可以及早发现路由错误。

4. 构建 Order API

4.1. 模型和内存存储

创建文件 store.go:

package main

import (
	"sort"
	"sync"
)

type Order struct {
	ID       int     `json:"id"`
	Customer string  `json:"customer"`
	Total    float64 `json:"total"`
}

type OrderStore struct {
	mu     sync.RWMutex
	orders map[int]Order
	nextID int
}

func NewOrderStore() *OrderStore {
	return &OrderStore{orders: make(map[int]Order), nextID: 1}
}

func (s *OrderStore) Create(o Order) Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	o.ID = s.nextID
	s.nextID++
	s.orders[o.ID] = o
	return o
}

func (s *OrderStore) Get(id int) (Order, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	o, ok := s.orders[id]
	return o, ok
}

func (s *OrderStore) List() []Order {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make([]Order, 0, len(s.orders))
	for _, o := range s.orders {
		result = append(result, o)
	}
	sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
	return result
}

func (s *OrderStore) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.orders[id]; !ok {
		return false
	}
	delete(s.orders, id)
	return true
}

每个请求都在单独的 goroutine 上处理。因此 map 必须使用 sync.RWMutex 保护。如果缺少互斥锁,运行 go test -race 会立即报告数据竞争。List 按 ID 排序,因为在 Go 中遍历 map 的顺序是随机的。

4.2. JSON 助手

创建文件 json.go:

package main

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

调用顺序非常重要。您必须在 WriteHeader 之前调用 Header().Set。调用 WriteHeader 或 Write 后,任何标头更改都会被忽略。

4.3. 处理程序

创建文件 handler.go:

package main

import (
	"encoding/json"
	"net/http"
	"strconv"
)

type OrderHandler struct {
	store *OrderStore
}

func (h *OrderHandler) Routes(mux *http.ServeMux) {
	mux.HandleFunc("GET /orders", h.list)
	mux.HandleFunc("POST /orders", h.create)
	mux.HandleFunc("GET /orders/{id}", h.get)
	mux.HandleFunc("DELETE /orders/{id}", h.delete)
}

func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, h.store.List())
}

func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 最多 1 MB
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	var in Order
	if err := dec.Decode(&in); err != nil {
		writeError(w, http.StatusBadRequest, "body 无效:"+err.Error())
		return
	}
	if in.Customer == "" || in.Total <= 0 {
		writeError(w, http.StatusUnprocessableEntity, "customer 和 total 是必需的")
		return
	}

	created := h.store.Create(in)
	w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
	writeJSON(w, http.StatusCreated, created)
}

func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	order, found := h.store.Get(id)
	if !found {
		writeError(w, http.StatusNotFound, "找不到订单")
		return
	}
	writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}
	if !h.store.Delete(id) {
		writeError(w, http.StatusNotFound, "找不到订单")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id 必须是正整数")
		return 0, false
	}
	return id, true
}

需要注意的几点:

  • Routes 将所有路由声明集中在一个地方。您可以一次性看出 API 有哪些端点。
  • GET /orders 和 GET /orders/{id} 不冲突,因为段数不同。
  • ServeMux 不检查数据类型。{id} 也会匹配 abc,所以您必须使用 strconv.Atoi 自己验证。
  • MaxBytesReader 拒绝过大的正文。DisallowUnknownFields 拒绝像 "totl" 这样的陌生字段(打字错误)。

4.4. 中间件和 main 函数

net/http 中的中间件只是接收 http.Handler 并返回 http.Handler 的函数。更新 main.go:

package main

import (
	"log"
	"net/http"
	"time"
)

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
	})
}

func newServer() http.Handler {
	mux := http.NewServeMux()
	h := &OrderHandler{store: NewOrderStore()}
	h.Routes(mux)
	return logging(mux)
}

func main() {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           newServer(),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

函数 newServer 将处理程序构建从 main 中分离出来。这样测试可以重用相同的配置。http.Server 有 ReadHeaderTimeout 来防止客户端发送标头太慢。http.ListenAndServe 默认不设置此超时。

从 Go 1.23 开始,http.Request 增加了 Pattern 字段。您可以记录 r.Pattern(例如 GET /orders/{id})而不是实际路径。这种方式可以帮助按路由汇总指标。

尝试运行:

go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"id 必须是正整数"}
204

5. 使用 httptest 编写测试

Go 内置了 net/http/httptest,所以您不需要打开真正的端口。创建文件 handler_test.go:

package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestCreateAndGetOrder(t *testing.T) {
	srv := newServer()

	body := strings.NewReader(`{"customer":"Lan","total":120000}`)
	req := httptest.NewRequest(http.MethodPost, "/orders", body)
	rec := httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("POST status = %d, want 201", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/orders/1" {
		t.Errorf("Location = %q, want /orders/1", loc)
	}

	req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
	rec = httptest.NewRecorder()
	srv.ServeHTTP(rec, req)

	var got Order
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatal(err)
	}
	if got.Customer != "Lan" || got.Total != 120000 {
		t.Errorf("got %+v", got)
	}
}

func TestRouting(t *testing.T) {
	srv := newServer()
	tests := []struct {
		name, method, path string
		want               int
	}{
		{"id 不是数字", http.MethodGet, "/orders/abc", http.StatusBadRequest},
		{"不存在", http.MethodGet, "/orders/99", http.StatusNotFound},
		{"错误的方法", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
		{"陌生路由", http.MethodGet, "/customers", http.StatusNotFound},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
			if rec.Code != tc.want {
				t.Errorf("status = %d, want %d", rec.Code, tc.want)
			}
		})
	}
}

第一个测试检查创建然后读取订单的流程。第二个是表驱动测试,检查路由行为,包括 ServeMux 自动生成的 405。与竞争检测器一起运行:

go test -race -v ./...
=== RUN   TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN   TestRouting
=== RUN   TestRouting/id_不是数字
...
--- PASS: TestRouting (0.00s)
PASS

6. 常见错误和何时不使用

常见错误:

  • go.mod 写着旧版本。 新路由返回全部 404。请先检查 go.mod 中的 go 行。
  • 模式语法写错。 方法和路径之间只能有一个空格。方法必须大写,例如 GET 而不是 get。
  • 无意中使用 "/" 作为后备。 模式 "/" 捕获所有不匹配其他路由的请求。如果您只想要主页,请使用 "/{$}"。
  • 通配符占据整个段。 模式 /orders/{id}.json 无效。通配符不能在同一段中与其他字符共存。
  • 405 错误返回纯文本。 ServeMux 为 404 和 405 返回纯文本正文,而不是 JSON。如果客户端需要统一的 JSON,您必须添加额外的处理层。

注意: 如果一个旧项目在升级 go.mod 到 1.22 后出问题,您可以临时设置 GODEBUG=httpmuxgo121=1 回到旧行为。这只是过渡方案。您仍应修复模式。

当您需要以下内容时,纯 net/http 可能不足:

  • 直接在路由中进行类型约束,例如只匹配数字或正则表达式。
  • 为每组路由分配专属中间件。这仍然可以通过多个嵌套的 ServeMux 完成,但代码会很冗长。
  • 预构建的中间件生态系统,如 CORS、速率限制或恢复。

在这些情况下,chi 是一个轻量级且完全兼容 http.Handler 的选择。除了这些情况,标准库对于大多数 REST API 已经足够了。

7. 结论

Go 1.22 使 http.ServeMux 足够强大以支持实际 REST API。您可以按方法匹配、使用 r.PathValue 读取路径参数,并让 Go 自动处理 405 和优先级规则。您还构建了一个完整的 Order API,包括中间件、输入验证和 httptest 测试,无需外部依赖。

最重要的是要记住:在 go.mod 中设置 go 1.22 或更高版本,使用 {$} 进行精确匹配,并在使用前自行验证通配符值。

golangnet/httprest-apigo-1.22httptest