強化 Go HTTP 路由器的提案
Go 的標準函式庫一直以來都包含一個穩固、可直接用於正式環境的 HTTP 伺服器。然而,內建的請求路由器 http.ServeMux 卻極為精簡,往往得自己撰寫路由程式碼。
特別是,它不支援依 HTTP 方法進行比對(例如用來區分 GET 和 POST),也不支援像 /users/{user}/settings 這樣的萬用字元路徑。這兩項功能幾乎是所有類 REST API 伺服器都需要的。
當然,這些功能你也可以自己實作。我之前曾寫過關於在 Go 中處理 HTTP 路由的不同做法:有幾個不錯的第三方套件可以做到更進階的路由,而且就算不靠第三方套件,也只需要大約 30 行程式碼就能加上類似的功能。
不過,這些變通方法和第三方套件或許很快就不需要了。目前有一個進行中的提案——包含一個參考實作——打算強化 ServeMux,讓它能比對 HTTP 方法與萬用字元路徑。
這份提案以及先前的討論,都是由 Google Go 團隊的 Jonathan Amsterdam 主導。Jonathan 之前負責了將結構化日誌加入標準函式庫的成功提案——他的 log/slog 套件將會包含在 Go 1.21(預計於 2023 年 8 月推出)中。
實際寫起來是什麼樣子
目前,如果你想比對指向 /users/{user}/settings 的 GET 請求,就得寫一大堆樣板程式碼,像這樣(不過實務上你大概最後還是會用第三方套件):
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 新增方法,而是擴充現有的 Handle 和 HandleFunc 方法,讓它們可以接受方法前綴以及 {wildcard} 形式的路徑區段。
我能理解想避免新增方法的考量,但我對這個決定不太確定。遺憾的是,舊版的 ServeMux 也會接受像 Handle("GET /foo", h) 這樣的模式。這意味著為強化版 ServeMux 撰寫的程式碼,在舊版 Go 上也能編譯、看起來也能正常執行,但實際上路由根本不會匹配到任何東西——有點容易出錯。如果是我,可能會另外新增方法,例如 HandleMatch / HandleMatchFunc 或 Route / 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)我最初測試參考實作時,其實還發現了幾個小錯誤,不過現在都已經修掉了。
結論
儘管對於擴充現有的 Handle 和 HandleFunc 方法這點我仍有所保留,但我很樂見這個提案被提出。考量到 Jonathan 在提案上的用心、他在 log/slog 上的實績,以及社群的正面回應,這份提案很有可能會被接受。
如果標準函式庫能納入這項功能就太好了——我開發過的幾乎每個網站和類 REST API 都需要這種功能。Go 標準函式庫本來就已經能做很多事,但這將幾乎完全省去對第三方路由器的需求。
如果這在預計於 2024 年 2 月推出的 Go 1.22 中落地,我一點也不會意外。不過就等著看吧!
隨機一篇部落格
留言
登入後參與討論