譯自 Routing Enhancements for Go 1.22
[name=Roy Thomas Fielding, on behalf of the Go team] 13 February 2024
Go 1.22 為 net/http
package 的路由器帶來了兩個改善:方法匹配 (method matching) 以及萬用字元 (wildcards)。這些特性能夠讓你將常見的路由表達成模式 (patterns) 而不是用 Go 語言撰寫的程式碼。雖然它們易於使用及解釋,但當一個請求匹配多個模式時,找到正確的規則去選擇最終的 (winning) 模式是一個挑戰。
這些改變是我們持續努力讓 Go 成為建立產品等級系統 (production systems) 的一種良好語言的一部分。我們研究了很多第三方的網站框架,提取我們認為最常使用到的功能,並將它們整合進 net/http
。然後我們在 GitHub discussion 和 proposal issue 中與社群協作,驗證我們的選擇並改進我們的設計。將這些功能加入標準函式庫 (standard library) 代表有許多專案可以少一個依賴 (dependency)。但對於目前的使用者或有進階路由需求的程式來說,第三方網站框架仍是一個不錯的選擇。
改善
新的路由特性幾乎只影響到了要傳進 net/http.ServeMux
的兩個方法 Handle
跟 HandleFunc
的模式字串 (pattern string),以及對應的 頂層函式 http.Handle
跟 http.HandleFunc
。唯一對 API 的變更是在 net/http.Request
上新增兩個處理萬用字元匹配的新方法。
我們將透過一個假想的部落格伺服器來說明這些變更,其中每篇文章都有一個整數識別碼。像 GET /posts/234
這樣的請求會找出 ID 為 234 的文章。在 Go 1.22 版本之前,處理這些請求的程式碼會以以下這樣的一行開始:
1 | http.HandleFunc("/posts/", handlePost) |
後方的 /
將所有以 /posts/
開頭的請求都路由到 handlePost
函式,該函式必須檢查 HTTP 方法是否為 GET
,提取出識別碼,然後找出文章。因方法檢查不是滿足請求的嚴格必要條件,遺漏它是一個容易犯的錯誤。這意味著像是 DELETE /posts/234
的請求也會去取得文章,這是令人意外的行為。
在 Go 1.22 中,現有的程式碼會繼續運作,或者你可以改為寫成這樣:
1 | http.HandleFunc("GET /posts/{id}", handlePost2) |
這個模式匹配到一個 GET
請求,其路徑從 /posts/
開始且有兩段。(作為一個特殊案例,GET
也會匹配到 HEAD
;其他所有的方法都需要完全匹配)。handlePost2
函數不再需要檢查方法,而且可以透過 net/http.Request
中的新 PathValue
方法提取識別碼字串:
1 | idString := req.PathValue("id") |
handlePost2
的其餘部分的行為會像 handlePost
一樣,將字串識別碼轉換為整數並取得文章。
如果沒有註冊其他匹配模式的話,像是 DELETE /posts/234
將會失敗。根據 HTTP semantics,一個 net/http
伺服器會對這樣的請求回應一個 405 Method Not Allowed
的錯誤,並在 Allow 這個標頭 (header) 中列出可使用的方法。
萬用字元可以匹配整個段落,像上方例子中的 {id},或者如果它以 ...
結尾,它可以匹配到路徑所有剩餘的段落,就像模式 /files/{pathname...}
。
最後一點語法。正如我們上面展示的,以 /
結尾的模式,如 /posts/
,會匹配所有以此字串開頭的路徑。要匹配只有 /
結尾的路徑,你可以寫成 /posts/{$}
。這將匹配 /posts/
但不匹配 /posts
或 /posts/234
。
還有最後一點 API: net/http.Request
有一個 SetPathValue
方法,這樣標準函式庫外的路由器可以透過 Request.PathValue
使他們自己的路徑解析結果是可使用的。
優先級
每個 HTTP 路由器都必須處理重疊 (overlap) 的模式,例如 /posts/{id}
和 /posts/latest
。這兩個模式都能匹配路徑 posts/latest
,但只能有一個可以處理這個請求。哪一個模式有優先權?
一些路由器不允許重疊;其他的會使用最後註冊的模式。Go 一直都允許重疊,並都會選擇較長的模式無論註冊順序。保持順序獨立性對我們來說很重要 (且對於向後兼容是必須的),但我們需要一個比”最長就贏”更好的規則。此規則會選擇 /posts/latest
而不是 /posts/{id}
,但會優先選擇 /posts/{identifier}
而不選擇這兩者。這看起來不太對:萬用字元的名稱不應該有影響。感覺 /posts/latest
應該總是在這場競爭中勝出,因為它只匹配到一個路徑,而不是多個。
我們探索一個好的優先級規則的過程,讓我們考慮到模式的許多特性。例如,我們考慮過優先選擇具有最長實際 (非萬用字元) 前綴的模式。這樣會選擇 /posts/latest
而不是 /posts/{id}
。但這樣無法區分 /users/{u}/posts/latest
和 /users/{u}/posts/{id}
,而前者似乎要具有優先權。我們最終選擇一條基於模式意義而不是外觀的規則。每個有效的模式都匹配一組請求。例如,/posts/latest
匹配路徑為 /posts/latest
的請求,而 /posts/{id}
匹配第一段為 posts
的任何有兩個段落的請求。如果一個模式匹配的請求是另一個模式匹配請求的嚴格子集,我們認為這個模式比另一個模式更精確。模式 /posts/latest
比 /posts/{id}
更精確,因為後者匹配的請求包含前者的匹配還有更多。
優先權規則很簡單:最精確的模式勝出。這個規則符合我們的直覺,認為 posts/latest
應該被優先於 posts/{id}
,而 /users/{u}/posts/latest
應該被優先於 /users/{u}/posts/{id}
。這對於方法也是有道理的。例如,GET /posts/{id}
有高於 /posts/{id}
的優先權,因為前者僅匹配 GET
和 HEAD
請求,而後者則匹配任何方法的請求。
“最精確獲勝”的規則是將原始”最長獲勝”的規則概括 (generalize),用於原始模式的路徑部分,即沒有萬用字元或 {$}
的部分。這樣的模式只有在一個是另一個的前綴時才會重疊,而較長的模式則是更精確的。
那如果兩個模式重疊但都沒有更精確呢?例如,/posts/{id}
和 /{resource}/latest
都能匹配 /posts/latest
。沒有明顯的答案指出哪個具優先權,所以我們認為這些模式彼此衝突。無論以何種順序註冊它們都會引發 panic
。
優先級規則對於方法和路徑的工作方式完全如上所述,但我們不得不為了保持兼容性對主機 (hosts) 做出一個例外:如果兩個模式在其他情況下會衝突,而其中一個有主機而另一個沒有,那麼帶有主機的模式將擁有優先權。
電腦科學的學生可能會回想起關於正規表達式 (regular expressions) 和正規語言 (regular languages) 的精彩理論。每個正規表達式都選定一個正規語言,即由該表達式匹配的字串集合。有些問題談論語言而非表達式的話,提出和回答會更容易。我們的優先權規則就是受到這個理論的啟發。實際上,每個路由模式都對應一個正規表達式,而匹配的請求集合扮演著正規語言的角色。
通過語言而非表達式來定義優先權,使其易於陳述和理解。但基於可能有無限集合的規則有一個缺點:如何有效實現它並不明確。我們可以通過檢查逐個段落來確定兩個模式是否衝突。粗略來說,如果一個模式在另一個模式有萬用字元的位置上有一個字面 (literal) 段落,則它更具體;但如果字面與萬用字元在兩個方向上都對齊,則這些模式衝突。
當新模式註冊到 ServeMux
時,它會檢查與先前註冊的模式是否存在衝突。但檢查每一對模式將會耗費二次方的時間。我們使用一個索引來跳過與新模式無法衝突的模式;在實踐中,這效果相當好。無論如何,這種檢查是在模式註冊時發生的,通常是在服務器啟動時。在 Go 1.22 中匹配進來的請求花費的時間與之前的版本相比並沒有太大變化。
兼容性
我們盡力保持新功能與舊版本的 Go 相容。新的模式語法是舊語法的超集,而新的優先級規則也是對舊規則的概括。但有一些邊緣案例 (edge cases)。例如,之前版本的 Go 接受帶有大括號的模式並將其作為字面處理,但 Go 1.22 使用大括號表示萬用字元。透過把 GODEBUG 設定成 httpmuxgo121
就能恢復舊的行為。
更多關於這些路由改善功能的細節,可以查看 net/http.ServeMux
的文件。