使用 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 或更高版本,使用 {$} 进行精确匹配,并在使用前自行验证通配符值。