The proposal to enhance Go's HTTP router

Ben Hoyt

增强 Go HTTP 路由器的提案

原文由 Ben Hoyt 发布,订阅该博客

Go 的标准库一直都包含一个稳定、生产可用的 HTTP 服务器。然而,内置的请求路由器 http.ServeMux 却过于精简,以至于你常常需要自己编写路由代码。

具体来说,它不支持按 HTTP 方法进行匹配(例如区分 GETPOST),也不支持类似 /users/{user}/settings 这样的通配路径。而这两项功能几乎是所有类 REST API 服务器都需要的。

当然,这些功能你也可以自己实现。我之前写过关于 Go 中 HTTP 路由的各种实现方式:市面上有几个不错的第三方包可以实现更复杂的路由,即使不依赖任何第三方库,也只需大约 30 行代码就能加上类似的功能。

不过,这些变通方法和第三方包或许很快就不再必要了。目前有一个活跃的提案——还包含一个参考实现——旨在增强 ServeMux,使其能够匹配 HTTP 方法和通配路径。

该提案以及此前的讨论均由 Google Go 团队的 Jonathan Amsterdam 主导。Jonathan 曾负责向标准库添加结构化日志的提案并成功推动落地——他的 log/slog 包将被纳入 Go 1.21(预计于 2023 年 8 月发布)。

效果一览

目前,如果你想匹配发往 /users/{user}/settingsGET 请求,就得写一大堆样板代码(不过实际开发中你大概会直接使用第三方库):

mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != "GET" {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    remainder := r.URL.Path[len("/users/"):]
    userId, subPath, _ := strings.Cut(remainder, "/")
    switch subPath {
    case "settings":
        fmt.Fprintf(w, "user %s", userId)
    // cases for other sub-paths could go here
    default:
        http.NotFound(w, r)
    }
})

如果该提案获得通过,你就可以这样写:

mux.HandleFunc("GET /users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", r.PathValue("user"))
})

简洁多了!

这也与其他主流路由器的语法非常相似:

// github.com/go-chi/chi
router.Get("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", chi.URLParam(r, "slug"))
})

// github.com/gorilla/mux
router.HandleFunc("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", mux.Vars(r)["user"])
}).Methods("GET")

// github.com/bmizerany/pat
router.Get("/users/:user/settings", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "user %s", r.URL.Query().Get(":user"))
}))

// github.com/gin-gonic/gin
router.GET("/users/:user/settings", func(c *gin.Context) {
    fmt.Fprintf(w, "user %s", c.Param("user"))
})

该提案一个值得关注的决定是,它并没有为 ServeMux 新增方法,而是扩展了现有的 HandleHandleFunc 方法,使其支持方法前缀和 {wildcard} 路径段。

我理解避免新增方法的初衷,但我对这一决定持保留态度。遗憾的是,旧版本的 ServeMux 也会接受形如 Handle("GET /foo", h) 的模式。这意味着为增强版 ServeMux 编写的代码在旧版 Go 上也能编译通过、看似运行正常,但实际上路由根本不会匹配——很容易出错。如果由我来设计,可能会选择新增方法,例如 HandleMatch / HandleMatchFuncRoute / RouteFunc

提案还详细阐述了当两个模式重叠时的优先级处理,但归结起来就是一条简单的规则:“如果两个模式存在重叠(有共同匹配的请求),则更具体的模式优先”。

例如,如果你同时注册了 /users/(它会匹配 /users/*)和 /users/{user} 这两个模式,当收到对 /users/ben 的请求时,会匹配到第二个更具体的模式。这与现有 ServeMux 中带主机名的模式优先于不带主机名模式的机制类似。

匹配 URL 末尾的通配符

该提案还新增了 {$} 作为“特殊通配符”,它只匹配 URL 的末尾。这主要适用于只想精确匹配首页的路由。现在要正确实现这一点出奇地麻烦,因为以 / 结尾的模式会匹配 / 下的所有路径;单独的 / 模式也是如此。

所以目前,如果只想匹配首页,你不得不这样写:

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" { // ensure path is exactly "/"
        http.NotFound(w, r)
        return
    }
    serveHomepage(w, r)
})
mux.HandleFunc("/users", serveUsers)

这种写法很繁琐,而且如果你忘了做路径检查,就会把首页内容返回给所有其他 URL,而不是返回未找到页面,因为所有路径都在 / 之下。

而在新提案下,写法会变得简单得多:

mux.HandleFunc("/{$}", serveHomepage)
mux.HandleFunc("/users", serveUsers)

参考实现

Jonathan 在 github.com/jba/muxpatterns 包中编写了增强版 ServeMux 的示例实现。唯一的区别在于,由于它是一个独立的包,无法修改 http.Request 类型,因此你需要使用 mux.PathValue(request, "name") 而不是 request.PathValue("name") 来获取路径参数。

我在自己的 go-routing 仓库中提交了一个 PR,添加了一个使用 muxpatterns 的 widget API 版本。它与 chi 版本非常相似——简洁易读:

r.HandleFunc("GET /{$}", home)
r.HandleFunc("GET /contact", contact)
r.HandleFunc("GET /api/widgets", apiGetWidgets)
r.HandleFunc("POST /api/widgets", apiCreateWidget)
r.HandleFunc("POST /api/widgets/{slug}", apiUpdateWidget)
r.HandleFunc("POST /api/widgets/{slug}/parts", apiCreateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/update", apiUpdateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/delete", apiDeleteWidgetPart)
r.HandleFunc("GET /{slug}", widgetGet)
r.HandleFunc("GET /{slug}/admin", widgetAdmin)
r.HandleFunc("POST /{slug}/image", widgetImage)

我在最初测试参考实现时,其实还发现了几个小 bug,不过现在都已经修复了。

总结

尽管我对扩展现有 HandleHandleFunc 方法的做法仍持保留意见,但我很高兴看到这一提案被提出来。考虑到 Jonathan 为提案付出的细致工作、他在 log/slog 方面的成功经验,以及社区的积极反馈,该提案很有可能会获得通过。

如果这一功能能够进入标准库就太好了——我开发过的几乎每个网站和类 REST API 都需要它。Go 标准库已经能做很多事情,而这一改进将几乎完全消除对第三方路由器的需求。

如果它能在定于 2024 年 2 月发布的 Go 1.22 中落地,我一点也不会感到意外。拭目以待吧!

本文章由 muse-spark-1.2-contributor 进行翻译

评论