logo

Go 1.22 中更优秀的 HTTP 服务器路由

Published on

在 Go 1.22 中,一个令人兴奋的提案即将落地 —— 增强默认 HTTP 服务多路复用器(net/http 包)的模式匹配能力。

目前的多路复用器 (http.ServeMux) 仅提供基本的路径匹配,功能相当有限。这导致了大量第三方库的出现,以实现更强大的路由功能。

1.22 中的新多路复用器将通过提供高级匹配功能,显著缩小与第三方包的差距。在这篇短文中,我将简要介绍新的多路复用器(mux),并将对比新的标准库 mux 与 gorilla/mux

使用新的 mux

如果您之前使用过 Go 的第三方 mux/路由器包(如 gorilla/mux),那么使用新的标准 mux 将会非常熟悉和直观。建议先阅读其文档 - 文档简短精炼。

让我们看几个基本使用示例。第一个示例展示了新 mux 的模式匹配能力:

package main

import (
  "fmt"
  "net/http"
)

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("GET /path/", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "got path\n")
  })

  mux.HandleFunc("/task/{id}/", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "handling task with id=%v\n", id)
  })

  http.ListenAndServe("localhost:8090", mux)
}

经验丰富的 Go 程序员会立即注意到两个新特性:

  1. 在第一个处理器中,HTTP 方法(这里是 GET)被明确指定为模式的一部分。这意味着该处理器只会触发 /path/ 开头的 GET 请求,而不会响应其他 HTTP 方法。

  2. 在第二个处理器中,路径的第二个组件有一个通配符 - {id},这是之前版本不支持的。通配符将匹配单个路径组件,处理器可以通过 PathValue 方法获取匹配的值。

建议使用 gotip 运行此示例。让我们测试这个服务器:

$ gotip run sample.go

在另一个终端中,我们可以发送一些 curl 请求进行测试:

$ curl localhost:8090/what/
404 page not found

$ curl localhost:8090/path/
got path

$ curl -X POST localhost:8090/path/
Method Not Allowed

$ curl localhost:8090/task/f0cd2e/
handling task with id=f0cd2e

注意服务器如何拒绝对 /path/ 的 POST 请求,而允许(curl 默认的)GET 请求。另外,当请求匹配时,id 通配符会被赋值。如使用 {id}... 匹配尾随路径的通配符,使用 {$} 严格匹配路径末尾等规则。

在提案中,对不同模式之间潜在的冲突给予了特别的关注。考虑以下设置:

mux := http.NewServeMux()
mux.HandleFunc("/task/{id}/status/", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        fmt.Fprintf(w, "handling task status with id=%v\n", id)
})
mux.HandleFunc("/task/0/{action}/", func(w http.ResponseWriter, r *http.Request) {
        action := r.PathValue("action")
        fmt.Fprintf(w, "handling task 0 with action=%v\n", action)
})

假设服务器收到 /task/0/status/ 的请求 - 哪个处理器应该处理?它同时匹配这两个!因此,新的 ServeMux 文档详细描述了模式的优先级规则,并说明了潜在的冲突。如果存在冲突,注册将会引发 panic。对于上面的示例,我们会得到类似这样的错误:

panic: pattern "/task/0/{action}/" (registered at sample-conflict.go:14) conflicts with pattern "/task/{id}/status/" (registered at sample-conflict.go:10):
/task/0/{action}/ and /task/{id}/status/ both match some paths, like "/task/0/status/".
But neither is more specific than the other.
/task/0/{action}/ matches "/task/0/action/", but /task/{id}/status/ doesn't.
/task/{id}/status/ matches "/task/id/status/", but /task/0/{action}/ doesn't.

这个错误消息详细且有帮助。如果我们在复杂的注册方案中遇到冲突(尤其是在源代码的多个位置注册模式),这样的细节将会非常受欢迎。

使用新的 mux 重新实现任务服务器

让我们看几个代表性的代码示例,首先是路由注册:

mux := http.NewServeMux()
server := NewTaskServer()

mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)

就像在 gorilla/mux 示例中一样,这里我们使用特定的 HTTP 方法来将相同路径的请求路由到不同的处理器;在旧版 http.ServeMux 中,这些匹配必须转到同一个处理器,然后根据方法决定要做什么。

让我们看一个处理器的示例:

func (ts *taskServer) getTaskHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get task at %s\n", req.URL.Path)

  id, err := strconv.Atoi(req.PathValue("id"))
  if err != nil {
    http.Error(w, "invalid id", http.StatusBadRequest)
    return
  }

  task, err := ts.store.GetTask(id)
  if err != nil {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
  }

  renderJSON(w, task)
}

它通过 req.PathValue("id") 提取 ID 值,类似于 Gorilla 的方法;然而,由于没有正则表达式指定 {id} 只匹配整数,我们必须注意 strconv.Atoi 返回的错误。

总的来说,处理器的分离比原生标准库方法更好,因为现在的 mux 可以进行更复杂的路由,而无需将太多路由决策留给处理器本身。

结论

对于初学的 Go 程序员来说,"我应该使用哪个路由器包?"一直是一个常见问题。我认为,在 Go 1.22 发布后,常见的回答将会发生变化,许多开发者会发现新的标准库 mux 已经足够满足需求,无需求助于第三方包。

部分开发者可能会继续使用熟悉的第三方包,这也完全没问题。像 gorilla/mux 这样的路由器仍然提供比标准库更多的功能。此外,许多 Go 程序员会选择轻量级框架,如 Gin,它们不仅提供路由器,还提供构建 Web 后端的其他工具。

总的来说,这对所有 Go 用户来说都是一个积极的变化。让标准库更加强大,对整个社区来说是一个净正面的改进,无论人们是使用第三方包还是仅依赖标准库。